OpenAPI / Swagger support for Sanic using attrs
Project description
Sanic with attrs towards Swagger 2.0 / OpenAPI support
Supercharge your Sanic app with:
Note: This is a fork of Sanic OpenAPI implementation from @channelcat, which I like a lot but it lacks some of the functionality I wanted (and I also went sideways by using a third-party lib (attrs
) as default for modeling input / output model classes).
Super quick introduction
Give your Sanic API an UI and OpenAPI documentation, all for the price of free!
Installation
Attention: since this fork came from a necessity of mine, a lot of features I want to implement are still not available, hence the status of pre-alpha
to this library! Also, don't try the examples folder, it was not converted (yet)! Shame on me ...
pip install sanic-attrs
Add OpenAPI and Swagger UI:
from sanic_attrs import swagger_blueprint, openapi_blueprint
app.blueprint(openapi_blueprint)
app.blueprint(swagger_blueprint)
You'll now have a Swagger UI at the URL /swagger
. Your routes will be automatically categorized by their blueprints. This is the default usage, but more advanced usage can be seen. Keep reading!
Note: the swagger_blueprint
is awesome but sometimes you don't want it open-wide for whatever reason you have (security, etc), so you can make it available only if running with debug=True
, for example. That's how I actually use it :smile:
typing
Since sanic-attrs
is, of course, based on attrs
and the Python target version is 3.5+, most of the typing definitions for your model will be made entirely using Python types, either global ones or from the typing
library. Also, enums
are supported as well! :sparkles:
Here's the types supported (so far):
int
float
str
bool
date
datetime
bytes
typing.Any
typing.Collection
typing.Dict
typing.Iterable
typing.List
typing.Mapping
typing.Optional
typing.Sequence
typing.Set
typing.Union
A note on list
and dict
: Please, use typing.List
and typing.Dict
for this.
Usage
Use simple decorators to document routes
from sanic_attrs import doc
@app.get("/user/<user_id:int>")
@doc.summary("Fetches a user by ID")
@doc.produces(SomeOutputModel)
async def get_user(request, user_id):
...
@app.post("/user")
@doc.summary("Creates a user")
@doc.consumes(SomeInputModel, location="body")
async def create_user(request):
...
Model your input/output
Yes, in this version you need to be descriptive :wink:
import typing
from sanic_attrs import doc
class Car(doc.Model):
make: str = doc.field(description="Who made the car")
model: str = doc.field(description="Type of car. This will vary by make")
year: int = doc.field(description="4-digit year of the car", required=False)
class Garage(doc.Model):
spaces: int = doc.field(description="How many cars can fit in the garage")
cars: typing.List[Car] = doc.field(description="All cars in the garage")
@app.get("/garage")
@doc.summary("Gets the whole garage")
@doc.produces(Garage)
async def get_garage(request):
return json({
"spaces": 2,
"cars": [{"make": "Nissan", "model": "370Z"}]
})
Advanced usage
Since doc.Model
and doc.field
are nothing more as syntatic sugar for the @attr.s
decorator and attr.ib
function, you can express your models using these provided classes and methods or use vanilla attrs
in your models. Here's a complex example that shows a mixed model:
from enum import Enum, IntEnum
from typing import (Any, Collection, Dict, Iterable, List, Mapping, Optional,
Sequence, Set, Union)
import attr
from sanic_attrs import doc
class PlatformEnum(str, Enum):
XBOX1 = "XBOX1"
PLAYSTATION4 = "PLAYSTATION4"
PC = "PC"
class LanguageEnum(IntEnum):
ENGLISH = 1
JAPANESE = 2
SPANISH = 3
GERMAN = 4
PORTUGUESE = 5
class Something(doc.Model):
some_name: str = doc.field(description="Something name")
@attr.s
class AnotherSomething:
another_name: str = attr.ib(metadata={"description": "Another field"})
class Game(doc.Model):
name: str = doc.field(description="The name of the game")
platform = doc.field(type=PlatformEnum, description="Which platform it runs on")
score: float = doc.field(description="The average score of the game")
resolution_tested: str = doc.field(description="The resolution which the game was tested")
genre: List[str] = doc.field(description="One or more genres this game is part of")
genre_extra: Sequence[str] = doc.field(description="One or more genres this game is part of")
rating: Dict[str, float] = doc.field(description="Ratings given on each country")
rating_outside: Mapping[str, float] = doc.field(description="Ratings given on each country")
screenshots: Set[bytes] = doc.field(description="Screenshots of the game")
screenshots_extra: Collection[bytes] = doc.field(description="Screenshots of the game")
players: Iterable[str] = doc.field(description="Some of the notorious players of this game")
review_link: Optional[str] = doc.field(description="The link of the game review (if exists)")
junk: Union[str, bytes] = doc.field(description="This should be strange")
more_junk: Any = doc.field(description="The more junk field")
language = doc.field(type=LanguageEnum, description="The language of the game")
something: List[Something] = doc.field(description="Something to go along the game")
another: AnotherSomething = doc.field(description="Another something to go along the game")
A note on enum
You may have noticed that in the example above, all enum
fields were given as the type
argument of the doc.field
function. The reason for this is quite simple: sanic-attrs
will automatically add a custom converter to your fields (if and only if your model is declared subclassing doc.Model
) so when your model is instantiated, the correspondent value of the enum
will be converted to the enum
itself, for practical reasons.
A note on a lot of features of attrs
There are a lot of features in attrs
that can be handy while declaring a model, such as validators, factories and etc. For this release, nothing is planned regarding those features and I would not encourage its usage while declaring models since I still hadn't time to actually test them :confused:
On-the-fly input model parsing
There are a few surprises inside sanic-attrs
. Let's say you have already declared your model, your endpoint and you still have to take the request.json
and load it as your model? That doesn't seems right ... Fortunatelly, a small middleware was written to handle these cases :wink:
To enable on-the-fly input model parsing, all you need to do is add a blueprint
to your Sanic app and access the object using the input_obj
keyword directly from the request:
from sanic_attrs import parser_blueprint
# ...
app.blueprint(parser_blueprint)
# ...
@app.post("/game", strict_slashes=True)
@doc.summary("Inserts the game data into the database")
@doc.response("200", "Game inserted successfuly", model=SuccessOutput)
@doc.response("403", "The user couldn't insert game to application", model=ErrorOutput)
@doc.consumes(Game, location="body", content_type="application/json")
@doc.produces(SuccessOutput)
async def insert_game(request):
my_object = request["input_obj"]
assert isinstance(my_object, Game)
# your logic here
Note: there are no validations to deal with broken data. If an exception occurs while populating your model, you will find that your input_obj
keyword will be None
, along with another key, input_exc
, that will contain the exception given (if any). If you want to further customize this behavior so you won't need to check for None
in every request, you can add your own middleware
after adding the parser_blueprint
to the app
instance, like the following:
from sanic.response import json
from sanic_attrs import parser_blueprint
# ...
app.blueprint(parser_blueprint)
# ...
@app.middleware("request")
async def check_if_input_is_none(request):
if "input_obj" in request:
if request["input_obj"] is None:
# error handling here
return json({"error": request["input_exc"].args[0]}, 500)
On-the-fly output model serialization
To keep things simple, it is also possible to handle the direct return of attrs
objects, instead of having to create a dictionary and then serialize or call sanic.responses.json
, although this is exactly what's running under the hood:
from sanic_attrs import response
# ...
@app.get("/game", strict_slashes=True)
@doc.summary("Gets the most played game in our database")
@doc.response("200", "Game data", model=Game)
@doc.response("403", "The user can't access this endpoint", model=ErrorOutput)
@doc.produces(Game)
async def get_game(request):
game = Game(
name="Cities: Skylines",
platform="PC",
score=9.0,
resolution_tested="1920x1080",
genre=["Simulators", "City Building"],
rating={
"IGN": 8.5,
"Gamespot": 8.0,
"Steam": 4.5
},
players=["Flux", "strictoaster"],
language=1
)
return response.model(game) # <--- the game instance, to be further serialized
Note: remember to create models that can have all its values serializable to JSON :+1:
Configure everything else
app.config.API_VERSION = '1.0.0'
app.config.API_TITLE = 'Car API'
app.config.API_DESCRIPTION = 'Car API'
app.config.API_TERMS_OF_SERVICE = 'Use with caution!'
app.config.API_PRODUCES_CONTENT_TYPES = ['application/json']
app.config.API_CONTACT_EMAIL = 'channelcat@gmail.com'
Types not yet avaiable
These are the types not available from typing
in the current version (with some notes so I can remember what to do later (if necessary)):
AbstractSet
- would be like set?AnyStr
- this is mostly like Optional[str] or just str?AsyncContextManager
- not a variable I thinkAsyncGenerator
- not a variable I thinkAsyncIterable
- not a variable I thinkAsyncIterator
- not a variable I thinkAwaitable
- not a variable I thinkBinaryIO
- hmmm, I don't know ... Bytes maybe?ByteString
- could be like bytes, for openapi is{"type":"string", "format": "byte"}
CT_co
- I don't even know what this is ...Callable
- not a variableCallableMeta
- not a variableChainMap
- not a variable (?)ClassVar
- generic ...Container
- genericContextManager
- not a variableCoroutine
- not a variableCounter
- not a variableDefaultDict
- perhaps like dict?Deque
- like List ?FrozenSet
- a "view-only list?Generator
- not a variableGeneric
- no way - or Any?Hashable
- a hashmap?IO
- hmmm, from docs: "Generic base class for TextIO and BinaryIO.", so ...ItemsView
- what is an Item? it inherits from AbstractSet ... from docs: "A set is a finite, iterable container."Iterator
- not a variableKT
- genericsKeysView
- dict "readonly" ?MappingView
- dict "readonly" ?Match
- generic (I think)MethodDescriptorType
- not a variableMethodWrapperType
- not a variableMutableMapping
- base class of Mapping, docs: "Abstract base class for generic types."MutableSequence
- same as above, but for SequenceMutableSet
- same as above, but for SetNamedTuple
- what to do here? NamedTuple is just an object with variables that can be anything I guess ...NamedTupleMeta
- baseclass of NamedTupleNewType
- not a variable / generic ?NoReturn
- not a variablePattern
- genericReversible
- generic (Iterable)Sized
- genericSupportsAbs
- not a variableSupportsBytes
- not a variableSupportsComplex
- not a variableSupportsFloat
- not a variableSupportsInt
- not a variableSupportsRound
- not a variableT
- genericTYPE_CHECKING
- ???T_co
- ???T_contra
- ???Text
- returns a str object if created, so I'll stick with str or map it too?TextIO
- buffer, like bytes ... map it?Tuple
- well ... Tuple like lists or Tuple like Tuple[int, str, float] ?TupleMeta
- baseclass of TupleType
- genericsTypeVar
- genericsTypingMeta
- generics
If there's anything missing or required, please fill in a issue or contribute with a PR. PR's are most welcome :smiley:
TODO
- Proper testing
- Increase use cases
- Find out if I can get the request model without calling the router
- Documentation
License
MIT, the same as sanic-openapi
.
Project details
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Hashes for sanic_attrs-0.1.3-py3-none-any.whl
Algorithm | Hash digest | |
---|---|---|
SHA256 | bc0a9e6d078674caaece2387f3ca864c25304cb18a84159ccc5d5a4c6d0dd275 |
|
MD5 | 7ab6128a7bc846b0334e41fa7cadac8a |
|
BLAKE2b-256 | 8271db6adf902e13e2bd6fd385e2c7a807ab2af67ebc026c311c1f4350e2ac80 |