sanic-org / sanic

Accelerate your web app development | Build fast. Run fast.
https://sanic.dev
MIT License
17.86k stars 1.53k forks source link

OpenAPI Documentation Issues for Class-Based Views Generating RESTful APIs #2954

Open jsonvot opened 2 weeks ago

jsonvot commented 2 weeks ago

Is there an existing issue for this?

Describe the bug

I am implementing a feature for generating RESTful-style APIs based on class-based views. The expectation is to obtain both list and detail endpoints through a single resource route. However, I found that the OpenAPI documentation is not generated as expected. When there are multiple blueprints or views, it fails to correctly group by blueprints.

Code snippet

from dataclasses import dataclass

from sanic import Sanic
from sanic.blueprints import Blueprint
from sanic.constants import HTTPMethod
from sanic.views import HTTPMethodView

class CreateModelMixin:

    async def create(self, request, *args, **kwargs):
        ...

    async def post(self, request, *args, **kwargs):
        await self.create(request, *args, **kwargs)

class ListModelMixin:

    async def list(self, request, *args, **kwargs):
        ...

    async def get(self, request, *args, **kwargs):
        return await self.list(request, *args, **kwargs)

class UpdateModelMixin:

    async def update(self, request, *args, **kwargs):
        ...

    async def put(self, request, *args, **kwargs):
        return await self.update(request, *args, **kwargs)

class PartialUpdateModelMixin:

    async def partial_update(self, request, *args, **kwargs):
        ...

    async def patch(self, request, *args, **kwargs):
        return await self.partial_update(request, *args, **kwargs)

class DestroyModelMixin:

    async def destroy(self, request, *args, **kwargs):
        ...

    async def delete(self, request, *args, **kwargs):
        return await self.destroy(request, *args, **kwargs)

class RetrieveModelMixin:
    async def retrieve(self, request, *args, **kwargs):
        ...

    async def get(self, request, *args, **kwargs):
        return await self.retrieve(request, *args, **kwargs)

class GenericViewSet(HTTPMethodView):
    ...
    # async def get(self, request, *args, **kwargs):
    #     return text('get method')
    #
    # async def post(self, request, *args, **kwargs):
    #     return text('post method')
    #
    # async def put(self, request, *args, **kwargs):
    #     return text('put method')
    #
    # async def patch(self, request, *args, **kwargs):
    #     return text('patch method')
    #
    # async def delete(self, request, *args, **kwargs):
    #     return text('delete method')

class GenericViewSetB(HTTPMethodView):
    ...
    # async def get(self, request, *args, **kwargs):
    #     return text('get method')
    #
    # async def post(self, request, *args, **kwargs):
    #     return text('post method')

    # async def put(self, request, *args, **kwargs):
    #     return text('put method')
    #
    # async def patch(self, request, *args, **kwargs):
    #     return text('patch method')
    #
    # async def delete(self, request, *args, **kwargs):
    #     return text('delete method')

class AView(
    GenericViewSet
):
    ...

class BView(
    GenericViewSetB
):
    ...

@dataclass
class RestRoute:
    mixin_map: dict
    uri: str = ''

res_routes_map = {
    'list-view': RestRoute(
        mixin_map={
            HTTPMethod.GET: ListModelMixin,
            HTTPMethod.POST: CreateModelMixin,
        },
    ),
    'detail-view': RestRoute(
        mixin_map={
            HTTPMethod.GET: RetrieveModelMixin,
            HTTPMethod.PUT: UpdateModelMixin,
            HTTPMethod.PATCH: PartialUpdateModelMixin,
            HTTPMethod.DELETE: DestroyModelMixin
        },
    )
}

def get_rest_routes(uri: str):
    list_uri, detail_uri = '', ''
    lstr, rstr, end_char = '/<', '>', '/'
    if not all([rstr in uri, lstr in uri]):
        list_uri = uri
    else:
        idx = uri.rindex(lstr)
        list_uri = (idx > 0 and not uri.endswith(end_char)) and uri[:idx] or uri[:idx + 1]  # 结尾字符处理
        detail_uri = uri
    res_routes_map['list-view'].uri = list_uri
    res_routes_map['detail-view'].uri = detail_uri
    return res_routes_map

def add_res_route(bp, view, uri: str, **kwargs):
    class_kwargs = kwargs.pop('class_kwargs', {})
    for key, route in get_rest_routes(uri).items():
        view_class = type(f'{view.__name__}~{key}', (*route.mixin_map.values(), view), {})
        handler = view_class.as_view(*kwargs.pop('class_args', ()), **class_kwargs)
        bp.add_route(handler, route.uri, methods=route.mixin_map.keys(), **kwargs)

app = Sanic(__name__)
app.config.update(dict(
    OAS_URL_PREFIX='/docs',
    OAS_UI_DEFAULT='swagger',
    SWAGGER_UI_CONFIGURATION={
        "docExpansion": "list"
    },
    OAS_UI_SWAGGER_VERSION='5.0.0',
))
my = Blueprint('my', url_prefix='/my')
your = Blueprint('your', url_prefix='/your')
add_res_route(my, AView, '/aaa/<pk:int>/')

# Issue 1: The names displayed are all from BView and not grouped by blueprint names. Expanding one of the endpoints also expands another endpoint with the same name.
add_res_route(your, BView, '/bbb/<pk:int>/')
app.blueprint([my, your])

# Issue 2: The names displayed are all from AView. Expanding one of the endpoints also expands another endpoint with the same name.
# add_res_route(my, BView, '/bbb/<pk:int>/')
# app.blueprint([my])

Expected Behavior

  1. Hope to add a resource view feature where by filling in a URL like /users/<pk:int>, a RESTful-style endpoint can be generated.
http method mapping method associated view
GET list /api/users list view
POST create /api/users list view
GET retrieve /api/users/\<pk:int> detail view
PUT update /api/users/\<pk:int> detail view
PATCH partial_update /api/users/\<pk:int> detail view
DELETE destroy /api/users/\<pk:int> detail view

How do you run Sanic?

Sanic CLI

Operating System

Linux

Sanic Version

23.12.1

Additional context

No response