Package: drf-typed-views
Django REST Framework - Typed ViewsThis project extendsDjango Rest Frameworkto allow use of Python's type annotations for automatically validating and casting view parameters. This pattern makes for code that is easier to read and write. View inputs are individually declared, not buried inside all-encompassingrequestobjects. Meanwhile, you get even more out of type annotations: they can replace repetitive validation/sanitization code.More features:Pydanticmodels andMarshmallowschemas are compatible types for view parameters. Annotate your POST/PUT functions with them to automatically validate incoming request bodies.Advanced validators for more than just the type:min_value/max_valuefor numbersValidate string formats:email,uuidandipv4/6; use Python's nativeEnumfor "choices" validationQuick example:fromrest_typed_viewsimporttyped_api_view@typed_api_view(["GET"])defget_users(registered_on:date=None,groups:List[int]=None,is_staff:bool=None):print(registered_on,groups,is_staff)GET/users/registered/?registered_on=2019-03-03&groups=4,5&is_staff=yesStatus Code: 200date(2019, 3, 3)   [4, 5]  TrueGET/users/?registered_on=9999&groups=admin&is_staff=maybe:no_entry_sign: Status Code: 400ValidationError raised{"registered_on":"'9999' is not a valid date","groups":"'admin' is not a valid integer","is_staff":"'maybe' is not a valid boolean"}Table of ContentsInstall & DecoratorsHow It Works: Simple UsageBasic GET RequestBasic POST RequestHow It Works: Advanced UsageAdditional Validation RulesNested Body FieldsList ValidationAccessing the Request ObjectInterdependent Query Parameter Validation(Simple) Access ControlEnabling Marshmallow, Pydantic SchemasRequest Element ClassesQueryBodyPathHeaderCurrentUserSupported Types/Validator RulesintfloatDecimalstrbooldatetimedatetimetimedeltaListEnummarshmallow.Schemapydantic.BaseModelChange LogMotivation & InspirationInstall & Decoratorspip install drf-typed-viewsYou can add type annotation-enabled features to eitherViewSetmethods or function-based views using thetyped_actionandtyped_api_viewdecorators. They take the exact same arguments as Django REST'sapi_viewandactiondecorators.How It Works: Simple UsageFor many cases, you can rely on implicit behavior for how different parts of the request (URL path variables, query parameters, body) map to the parameters of a view function/method.The value of a view parameter will come from...the URL path if the path variable and the view argument have the same name,or:the request body if the view argument is annotated using a class from a supported library for complex object validation (Pydantic, MarshMallow),or:a query parameter with the same nameUnless a default value is given, the parameter isrequiredand aValidationErrorwill be raised if not set.Basic GET Requesturlpatterns=[url(r"^(?P<city>[\w+])/restaurants/",search_restaurants)]fromrest_typed_viewsimporttyped_api_view# Example request: /chicago/restaurants?delivery=yes@typed_api_view(["GET"])defsearch_restaurants(city:str,rating:float=None,offers_delivery:bool=None):restaurants=Restaurant.objects.filter(city=city)ifratingisnotNone:restaurants=restaurants.filter(rating__gte=rating)ifoffers_deliveryisnotNone:restaurants=restaurants.filter(delivery=offers_delivery)In this example,cityis required and must be its string. Its value comes from the URL path variable with the same name. The other parameters,ratingandoffers_delivery, are not part of the path parameters and are assumed to be query parameters. They both have a default value, so they are optional.Basic POST Request# urls.pyurlpatterns=[url(r"^(?P<city>[\w+])/bookings/",create_booking)]# settings.pyDRF_TYPED_VIEWS={"schema_packages":["pydantic"]}# views.pyfrompydanticimportBaseModelfromrest_typed_viewsimporttyped_api_viewclassRoomEnum(str,Enum):double='double'twin='twin'single='single'classBookingSchema(BaseModel):start_date:dateend_date:dateroom:RoomEnum=RoomEnum.doubleinclude_breakfast:bool=False# Example request: /chicago/bookings/@typed_api_view(["POST"])defcreate_booking(city:str,booking:BookingSchema):# do something with the validated booking...In this example,citywill again be populated using the URL path variable. Thebookingparameter is annotated using a supported complex schema class (Pydantic), so it's assumed to come from the request body, which will be read in as JSON, used to hydrate the PydanticBookingSchemaand then validated. If validation fails aValidationErrorwill be raised.How It Works: Advanced UsageFor more advanced use cases, you can explicitly declare how each parameter's value is sourced from the request -- from the query parameters, path, body or headers -- as well as define additional validation rules. You import a class named after the request element that is expected to hold the value and assign it to the parameter's default.fromrest_typed_viewsimporttyped_api_view,Query,Path@typed_api_view(["GET"])deflist_documents(year:date=Path(),title:str=Query(default=None)):# ORM logic here...In this example,yearis required and must come from the URL path andtitleis an optional query parameter because thedefaultis set. This is similar to Django REST'sserializer fields:  passing a default implies that the filed is not required.fromrest_typed_viewsimporttyped_api_view,Header@typed_api_view(["GET"])defget_cache_header(cache:str=Header()):# ORM logic here...In this example,cacheis required and must come from the headers.Additional Validation RulesYou can use the request element class (Query,Path,Body,Header) to set additional validation constraints. You'll find that these keywords are consistent with Django REST's serializer fields.fromrest_typed_viewsimporttyped_api_view,Query,Path@typed_api_view(["GET"])defsearch_restaurants(year:date=Path(),rating:int=Query(default=None,min_value=1,max_value=5)):# ORM logic here...@typed_api_view(["GET"])defget_document(id:str=Path(format="uuid")):# ORM logic here...@typed_api_view(["GET"])defsearch_users(email:str=Query(default=None,format="email"),ip_address:str=Query(default=None,format="ip"),):# ORM logic here...View afull listof supported types and additional validation rules.Nested Body FieldsSimilar to howsourceis used in Django REST to control field mappings during serialization, you can use it to specify the exact path to the request data.frompydanticimportBaseModelfromrest_typed_viewsimporttyped_api_view,Query,PathclassDocument(BaseModel):title:strbody:str"""POST{"strict": false,"data": {"title": "A Dark and Stormy Night","body": "Once upon a time"}}"""@typed_api_view(["POST"])defcreate_document(strict_mode:bool=Body(source="strict"),item:Document=Body(source="data")):# ORM logic here...You can also use dot-notation to source data multiple levels deep in the JSON payload.List ValidationFor the basic case of list validation - validating types within a comma-delimited string - declare the type to get automatic validation/coercion:fromrest_typed_viewsimporttyped_api_view,Query@typed_api_view(["GET"])defsearch_movies(item_ids:List[int]=[])):print(item_ids)# GET /movies?items_ids=41,64,3# [41, 64, 3]But you can also specifymin_lengthandmax_length, as well as thedelimiterand specify additional rules for the child items -- think Django REST'sListField.Import the genericParamclass and use it to set the rules for thechildelements:fromrest_typed_viewsimporttyped_api_view,Query,Param@typed_api_view(["GET"])defsearch_outcomes(scores:List[int]=Query(delimiter="|",child=Param(min_value=0,max_value=100))):# ORM logic ...@typed_api_view(["GET"])defsearch_message(recipients:List[str]=Query(min_length=1,max_length=10,child=Param(format="email"))):# ORM logic ...Accessing the Request ObjectYou probably won't need to access therequestobject directly, as this package will provide its relevant properties as view arguments. However, you can include it as a parameter annotated with its type and it will be injected:fromrest_framework.requestimportRequestfromrest_typed_viewsimporttyped_api_view@typed_api_view(["GET"])defsearch_documens(request:Request,q:str=None):# ORM logic ...Interdependent Query Parameter ValidationOften, it's useful to validate a combination of query parameters - for instance, astart_dateshouldn't come after anend_date. You can use complex schema object (Pydantic or Marshmallow) for this scenario. In the example below,Query(source="*")is instructing an instance ofSearchParamsSchemato be populated/validated using all of the query parameters together:request.query_params.dict().frommarshmallowimportSchema,fields,validates_schema,ValidationErrorfromrest_typed_viewsimporttyped_api_viewclassSearchParamsSchema(Schema):start_date=fields.Date()end_date=fields.Date()@validates_schemadefvalidate_numbers(self,data,**kwargs):ifdata["start_date"]>=data["end_date"]:raiseValidationError("end_date must come after start_date")@typed_api_view(["GET"])defsearch_documens(search_params:SearchParamsSchema=Query(source="*")):# ORM logic ...(Simple) Access ControlYou can apply some very basic access control by applying some validation rules to a view parameter sourced from theCurrentUserrequest element class. In the example below, aValidationErrorwill be raised if therequest.useris not a member of eithersuper_usersoradmins.frommy_pydantic_schemasimportBookingSchemafromrest_typed_viewsimporttyped_api_view,CurrentUser@typed_api_view(["POST"])defcreate_booking(booking:BookingSchema,user:User=CurrentUser(member_of_any=["super_users","admins"])):# Do something with the request.userRead more about theCurrent Userrequest element class.Enabling Marshmallow, Pydantic SchemasAs an alternative to Django REST's serializers, you can annotate views withPydanticmodels orMarshmallowschemas to have their parameters automatically validated and pass an instance of the Pydantic/Marshmallow class to your method/function.To enable support for third-party libraries for complex object validation, modify your settings:DRF_TYPED_VIEWS={"schema_packages":["pydantic","marshmallow"]}These third-party packages must be installed in your virtual environment/runtime.Request Element ClassesYou can specify the part of the request that holds each view parameter by using default function arguments, for example:fromrest_typed_viewsimportBody,Query@typed_api_view(["PUT"])defupdate_user(user:UserSchema=Body(),optimistic_update:bool=Query(default=False)):Theuserparameter will come from the request body and is required because no default is provided. Meanwhile,optimistic_updateis not required and will be populated from a query parameter with the same name.The core keyword arguments to these classes are:defaultthe default value for the parameter, which is required unless setsourceif the view parameter has a different name than its key embedded in the requestPassing keywords for additional validation constraints is apowerful capabilitythat gets youalmost the same feature setas Django REST's flexibleserializer fields. See acomplete listof validation keywords.QueryUse thesourceargument to alias the parameter value and pass keywords to set additional constraints. For example, your query parameters can have dashes, but be mapped to a parameter that have underscores:fromrest_typed_viewsimporttyped_api_view,Query@typed_api_view(["GET"])defsearch_events(starting_after:date=Query(source="starting-after"),available_tickets:int=Query(default=0,min_value=0)):# ORM logic here...BodyBy default, the entire request body is used to populate parameters marked with this class (source="*"):fromrest_typed_viewsimporttyped_api_view,Bodyfrommy_pydantic_schemasimportResidenceListing@typed_api_view(["POST"])defcreate_listing(residence:ResidenceListing=Body()):# ORM logic ...However, you can also specify nested fields in the request body, with support for dot notation."""POST  /users/{"first_name": "Homer","last_name": "Simpson","contact": {"phone" : "800-123-456","fax": "13235551234"}}"""fromrest_typed_viewsimporttyped_api_view,Body@typed_api_view(["POST"])defcreate_user(first_name:str=Body(source="first_name"),last_name:str=Body(source="last_name"),phone:str=Body(source="contact.phone",min_length=10,max_length=20)):# ORM logic ...PathUse thesourceargument to alias a view parameter name. More commonly, though, you can set additional validation rules for parameters coming from the URL path.fromrest_typed_viewsimporttyped_api_view,Query@typed_api_view(["GET"])defretrieve_event(id:int=Path(min_value=0,max_value=1000)):# ORM logic here...HeaderUse theHeaderrequest element class to automatically retrieve a value from a header. Underscores in variable names are automatically converted to dashes.fromrest_typed_viewsimporttyped_api_view,Header@typed_api_view(["GET"])defretrieve_event(id:int,cache_control:str=Header(default="no-cache")):# ORM logic here...If you prefer, you can explicitly specify the exact header key:fromrest_typed_viewsimporttyped_api_view,Header@typed_api_view(["GET"])defretrieve_event(id:int,cache_control:str=Header(source="cache-control",default="no-cache")):# ORM logic here...CurrentUserUse this class to have a view parameter populated with the current user of the request. You can even extract fields from the current user using thesourceoption.frommy_pydantic_schemasimportBookingSchemafromrest_typed_viewsimporttyped_api_view,CurrentUser@typed_api_view(["POST"])defcreate_booking(booking:BookingSchema,user:User=CurrentUser()):# Do something with the request.user@typed_api_view(["GET"])defretrieve_something(first_name:str=CurrentUser(source="first_name")):# Do something with the request.user's first nameYou can also pass some additional parameters to theCurrentUserrequest element class to implement simple access control:member_of(str) Validates that the currentrequest.useris a member of a group with this namemember_of_any(List[str]) Validates that the currentrequest.useris a member of one of these groupsUsing these keyword validators assumes that yourUsermodel has a many-to-many relationship withdjango.contrib.auth.models.Groupviauser.groups.An example:fromdjango.contrib.auth.modelsimportUserfromrest_typed_viewsimporttyped_api_view,CurrentUser@typed_api_view(["GET"])defdo_something(user:User=CurrentUser(member_of="admin")):# now have a user instance (assuming ValidationError wasn't raised)Supported Types and Validator RulesThe following native Python types are supported. Depending on the type, you can pass additional validation rules to the request element class (Query,Path,Body). You can think of the type combining with the validation rules to create a Django REST serializer field on the fly -- in fact, that's what happens behind the scenes.strAdditional arguments:max_lengthValidates that the input contains no more than this number of characters.min_lengthValidates that the input contains no fewer than this number of characters.trim_whitespace(bool; defaultTrue) Whether to trim leading and trailing white space.formatValidates that the string matches a common format; supported values:emailvalidates the text to be a valid e-mail address.slugvalidates the input against the pattern[a-zA-Z0-9_-]+.uuidvalidates the input is a valid UUID stringurlvalidates fully qualified URLs of the formhttp://<host>/<path>ipvalidates input is a valid IPv4 or IPv6 stringipv4validates input is a valid IPv4 stringipv6validates input is a valid IPv6 stringfile_pathvalidates that the input corresponds to filenames in a certain directory on the filesystem; allows all the same keyword arguments as Django REST'sFilePathFieldSome examples:fromrest_typed_viewsimporttyped_api_view,Query@typed_api_view(["GET"])defsearch_users(email:str=Query(format='email')):# ORM logic here...returnResponse(data)@typed_api_view(["GET"])defsearch_shared_links(url:str=Query(default=None,format='url')):# ORM logic here...returnResponse(data)@typed_api_view(["GET"])defsearch_request_logs(ip_address:str=Query(default=None,format='ip')):# ORM logic here...returnResponse(data)intAdditional arguments:max_valueValidate that the number provided is no greater than this value.min_valueValidate that the number provided is no less than this value.An example:fromrest_typed_viewsimporttyped_api_view,Query@typed_api_view(["GET"])defsearch_products(inventory:int=Query(min_value=0)):# ORM logic here...floatAdditional arguments:max_valueValidate that the number provided is no greater than this value.min_valueValidate that the number provided is no less than this value.An example:fromrest_typed_viewsimporttyped_api_view,Query@typed_api_view(["GET"])defsearch_products(price:float=Query(min_value=0)):# ORM logic here...DecimalAdditional arguments:max_valueValidate that the number provided is no greater than this value.min_valueValidate that the number provided is no less than this value... even more ... accepts the same arguments asDjango REST'sDecimalFieldboolView parameters annotated with this type will validate and coerce the same values as Django REST'sBooleanField, including but not limited to the following:true_values=["yes",1,"on","y","true"]false_values=["no",0,"off","n","false"]datetimeAdditional arguments:input_formatsA list of input formats which may be used to parse the date-time, defaults to Django'sDATETIME_INPUT_FORMATSsettings, which defaults to['iso-8601']default_timezoneApytz.timezoneof the timezone. If not specified, falls back to Django'sUSE_TZsetting.dateAdditional arguments:input_formatsA list of input formats which may be used to parse the date, defaults to Django'sDATETIME_INPUT_FORMATSsettings, which defaults to['iso-8601']timeAdditional arguments:input_formatsA list of input formats which may be used to parse the time, defaults to Django'sTIME_INPUT_FORMATSsettings, which defaults to['iso-8601']timedeltaValidates strings of the format'[DD] [HH:[MM:]]ss[.uuuuuu]'and converts them to adatetime.timedeltainstance.Additional arguments:max_valueValidate that the input duration is no greater than this value.min_valueValidate that the input duration is no less than this value.ListValidates strings of the format'[DD] [HH:[MM:]]ss[.uuuuuu]'and converts them to adatetime.timedeltainstance.Additional arguments:min_lengthValidates that the list contains no fewer than this number of elements.max_lengthValidates that the list contains no more than this number of elements.childPass keyword constraints via aParaminstance to to validate the members of the list.An example:fromrest_typed_viewsimporttyped_api_view,Param,Query@typed_api_view(["GET"])defsearch_contacts(emails:List[str]=Query(max_length=10,child=Param(format="email"))):# ORM logic here...EnumValidates that the value of the input is one of a limited set of choices. Think of this as mapping to a Django RESTChoiceField.An example:fromrest_typed_viewsimporttyped_api_view,QueryclassStraws(str,Enum):paper="paper"plastic="plastic"@typed_api_view(["GET"])defsearch_straws(type:Straws=None):# ORM logic here...marshmallow.SchemaYou can annotate view parameters withMarshmallow schemasto validate request data and pass an instance of the schema to the view.frommarshmallowimportSchema,fieldsfromrest_typed_viewsimporttyped_api_view,QueryclassArtistSchema(Schema):name=fields.Str()classAlbumSchema(Schema):title=fields.Str()release_date=fields.Date()artist=fields.Nested(ArtistSchema())"""POST{"title": "Michael Scott's Greatest Hits","release_date": "2019-03-03","artist": {"name": "Michael Scott"}}"""@typed_api_view(["POST"])defcreate_album(album:AlbumSchema):# now have an album instance (assuming ValidationError wasn't raised)pydantic.BaseModelYou can annotate view parameters withPydantic modelsto validate request data and pass an instance of the model to the view.frompydanticimportBaseModelfromrest_typed_viewsimporttyped_api_view,QueryclassUser(BaseModel):id:intname:strsignup_ts:datetime=Nonefriends:List[int]=[]"""POST{"id": 24529782,"name": "Michael Scott","friends": [24529782]}"""@typed_api_view(["POST"])defcreate_user(user:User):# now have a user instance (assuming ValidationError wasn't raised)Change LogJune 7, 2020Fixes compatability with DRF decorator. Thanks @sjquant!Makes Django's QueryDict work with Marshmallow and Pydantic validators. Thanks @filwaline!February 2, 2020: Adds support forHeaderrequest parameter. Thanks @bbkgh!MotivationWhile REST Framework's ModelViewSets and ModelSerializers are very productive when building out CRUD resources, I've felt less productive in the framework when developing other types of operations. Serializers are a powerful and flexible way to validate incoming request data, but are not as self-documenting as type annotations. Furthermore, the Django ecosystem is hugely productive and I see no reason why REST Framework cannot take advantage of more Python 3 features.InspirationI first came across type annotations for validation inAPI Star, which has since evolved into an OpenAPI toolkit. This pattern has also been offered byHugandMolten(I believe in that order). Furthermore, I've borrowed ideas fromFastAPI, specifically its use of default values to declare additional validation rules. Finally, thisblog postfrom Instagram's engineering team showed me how decorators can be used to implement these features on view functions.