OpenAPI - Next generation with built in validation

To: @core-devs (and specifically @open-api)

A word on sanic-openapi, with pretty minimal effort, I got the following operational locally.

@dataclass
class Person:
    name: str
    age: int

@app.post("/person")
@openapi.body(
    {"application/json": Person},
    description="Body description",
    required=True,
    validate=True,
)
async def person(request, body):
    print(f">>> {body=}")
    return json({"Hello": "world"})

Bad data

ValidationError: Invalid request content-type: application/x-www-form-urlencoded
ValidationError: Invalid request body: User. Error: __init__() got an unexpected keyword argument 'foo'
ValidationError: Invalid request body: User. Error: __init__() missing 1 required positional argument: 'age'

Good data

>>> body=Person(name='alice', age=40)

This could be super easily extensible to support Pydantic, Marshmallow, etc. As it stands now, it does support Pydantic when used in dataclass mode.

We would need to decide if it is worth the effort to built in a fallback validator for regular dataclasses. Part of me feels like we should not, and just allow the user to add that if they want it. Doing content-type, and key validation being simple enough, perhaps we leave it like that. Another option might be to look for __validate__ or something similar.


With this in mind, I am wondering about making a divide. The sanic-openapi library really is almost two. The OAS2 logic is very isolated from OAS3. I think perhaps this is okay.

What I am proposing is to branch the OAS3 logic off and create a new package called sanic-ext (other names?) that includes this functionality. I would want it to be backwards compatible as much as possible with existing sanic-openapi installations, but I would really like to just drop the OAS2 support and clean up the repository.

Thoughts?

1 Like

I’ve taken my initial proof of concept and built an initial package.

You can check it out here: GitHub - ahopkins/sanic-ext

In no particular order, here are some thoughts:

  1. This is basically a stripped down version of sanic-openapi as discussed
  2. It should ship and be configurable for both Swagger and Redoc, and allow you to specify the version (using CDN–defaults to latest of each)
  3. Supplies basic validation logic as explained above, with option to expand to RollYourOwn, Pydantic, and Marshmallow (maybe others in the future)
  4. Includes additional goodies like auto setup of HEAD, OPTIONS, and TRACE methods
  5. Auto add CORS (using as simple version like in the User Guide, not as fully featured as @ashleysommer sanic-cors)
  6. Requires 21.3+, I see no reason we need to break our back and add backward support for the LTS branches

Assuming there is support from the community, I would like to move it under the SCO umbrella.

Are there other “basic” features we should think about adding?

1 Like

Fantastic effort. Thanks!

RE CORS:
I had started building a stripped-down CORS plugin that uses the new request.ctx and app.ctx objects for tracking state, and foregoes the Sanic-Plugin-Toolkit dependency, for the basic CORS use-cases with zero dependencies.

But with that support baked into sanic-ext, now I don’t have to! And that’s now one less dependency for end-users now too (assuming they use sanic-ext instead).

Well, the CORS implementation I had in mind would be simpler and more basic. Some form of this:

def _add_cors_headers(response, methods: Iterable[str]) -> None:
    allow_methods = list(set(methods))
    if "OPTIONS" not in allow_methods:
        allow_methods.append("OPTIONS")
    headers = {
        "Access-Control-Allow-Methods": ",".join(allow_methods),
        "Access-Control-Allow-Origin": "mydomain.com",
        "Access-Control-Allow-Credentials": "true",
        "Access-Control-Allow-Headers": (
            "origin, content-type, accept, "
            "authorization, x-xsrf-token, x-request-id"
        ),
    }
    response.headers.extend(headers)

The values for Origin, Credentials, Allow-Headers coming from some config options, but not necessarily having the per-route decorators and control that your plugin affords. I think there is probably still a place for a more fully featured lib.

@ashleysommer I did an initial buildout for CORS support. It sort of tracks your logic for deciding what headers to add. As I mentioned before, the goal is a simple implementation, and it will not support any individual resource level management.

What it should enable is a smooth upgrade between the packages.


I am going to move the package to SCO and start adding in tests and making it a proper package.

Ok ok, hold on, I think I might have got lost somewhere: the sanic-ext package is supposed to be … What kind of extension to Sanic? OAS3 support? Or CORS? Or both if needed? Or something more? Sorry, it’ just a lot of information :sweat_smile:

:laughing: I think with some documentation it will be clear. It is meant to be a set of tools that are intentionally left from the core project that are geared towards web API developers. It takes the openapi3 package from sanic-openapi and builds upon it (dropping v2, adding validation, basic cors, etc)

Here’s a simple dependency injection API.

@dataclass
class PersonID:
    person_id: int


@dataclass
class Person:
    person_id: PersonID
    name: str
    age: int

    @classmethod
    async def create(cls, request: Request, person_id: int):
        return cls(person_id=PersonID(person_id), name="noname", age=111)


app = Sanic(__name__)
ext = Apply(app)
ext.injection(Person, Person.create)
ext.injection(PersonID)


@app.get("/person/<person_id:int>")
async def person_details(
    request: Request, person_id: PersonID, person: Person
):
    return text(f"{person_id}\n{person}")
$ curl localhost:9999/person/999
PersonID(person_id=999)
Person(person_id=PersonID(person_id=999), name='noname', age=111)