syncapi now also supports async.

This commit is contained in:
Queue A
2026-08-14 08:30:42 +02:00
parent 6b2a583b16
commit 4bf684f4b5
+133 -55
View File
@@ -4,11 +4,10 @@
# Automatically generates urlpatterns from signatures and guards # Automatically generates urlpatterns from signatures and guards
# #
## ##
import logging; logger = logging.getLogger(__name__) import logging; logger = logging.getLogger(__name__)
import collections, functools, inspect import collections, functools, inspect
import json import json
from asgiref.sync import iscoroutinefunction
from django.core.serializers.json import DjangoJSONEncoder from django.core.serializers.json import DjangoJSONEncoder
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
from django.http import HttpResponse, JsonResponse from django.http import HttpResponse, JsonResponse
@@ -20,58 +19,96 @@ class CustomJSONEncoder( DjangoJSONEncoder ):
if isinstance(o, timezone.datetime): o = o.replace( microsecond = 0 ) #IOS can't handle microseconds! if isinstance(o, timezone.datetime): o = o.replace( microsecond = 0 ) #IOS can't handle microseconds!
return super().default(o) return super().default(o)
def forced_identity( f ):
@functools.wraps(f)
def decorated( x ):
f( x )
return x
return decorated
urlpatterns = [] urlpatterns = []
class Endpoint:
route_to_index = {} #path route -> number route_to_index = {} #path route -> number
route_to_others = collections.defaultdict( dict ) #path router -> decorated_apis[] route_to_others = collections.defaultdict( dict ) #path router -> decorated_apis[]
bad_method_response = functools.partial( HttpResponse, "Bad Method", status = 400 ) #call it to get a fresh instance
def Endpoint( sig, *guard_args ): def __init__( self, sig, *guard_args ):
method, route = sig.split(' /', 1) self.sig = sig
self.method, self.route = sig.split(' /', 1)
self.guard_args = guard_args
@forced_identity #no point messing with the original function
def decorator( f ):
f_args_specs = inspect.getfullargspec(f)
@functools.wraps(f) #This function returns f on success, no point messing with the function.
@csrf_exempt def __call__( self, f ):
def decorated( request, *args, **kwargs ): if self.method in Endpoint.route_to_others.get(self.route, {}):
raise ValueError( f"Endpoint '{self.sig}': method '{self.method}' is already registered for route '{self.route}'" )
if request.method != method: return HttpResponse("Bad Method", status = 400) self.f = f
self.f_args_specs = inspect.getfullargspec(f)
# f and all its guards must agree on flavor (all sync or all async)
named_callables = { f"view '{getattr(f, '__qualname__', f)}'": f }
named_callables.update({
f"guard #{i} '{getattr(guard, '__qualname__', guard)}'": guard
for i, guard in enumerate(self.guard_args)
})
self.is_async = self.check_same_flavor( named_callables )
build_decorated = self.build_async_decorated if self.is_async else self.build_sync_decorated
decorated = build_decorated()
endoint_path = path(self.route, decorated)
unsafe_kwargs = set( self.f_args_specs.kwonlyargs ) & set( endoint_path.pattern.converters )
if unsafe_kwargs:
logger.warning( f"Skipping '{self.sig}' due to Security Issue: Keyword only arguments {unsafe_kwargs} can only be provided from user input." )
return f
Endpoint.route_to_others[self.route][self.method] = decorated
if self.route not in Endpoint.route_to_index: #If it's the first time seeing this route, just append the decorated endpoint
Endpoint.route_to_index[self.route] = len(urlpatterns)
urlpatterns.append( endoint_path )
return f
#Every method sharing this route must also be either all sync or async
route_flavors = { m: iscoroutinefunction(view) for m, view in Endpoint.route_to_others[self.route].items() }
if len(set(route_flavors.values())) > 1:
detail = ", ".join( f"{m}={'async' if a else 'sync'}" for m, a in route_flavors.items() )
raise TypeError( f"Route '{self.route}': all methods sharing a route must be the same flavor (sync/async), got: {detail}" )
build_conjoined = self.build_async_conjoined if self.is_async else self.build_sync_conjoined
urlpatterns[ Endpoint.route_to_index[self.route] ] = path(self.route, build_conjoined())
return f
def check_same_flavor( self, named_callables ):
flavors = { label: iscoroutinefunction(fn) for label, fn in named_callables.items() }
distinct = set(flavors.values())
if len(distinct) > 1:
detail = ", ".join( f"{label}={'async' if is_async else 'sync'}" for label, is_async in flavors.items() )
raise TypeError( f"Endpoint '{self.sig}': view and guards must all be sync or all be async, got mixed flavors: {detail}" )
return distinct.pop()
def sanity_check( self, request ):
return request.method == self.method
def prepare_kwargs( self, request, kwargs ):
request.is_json = False request.is_json = False
if request.body and request.headers['Content-Type'].startswith('application/json'): #Coule be: application/json; charset=utf-8 if request.body and request.headers.get('Content-Type', '').startswith('application/json'): #Coule be: application/json; charset=utf-8
try:
try: request.json = json.loads( request.body ) request.json = json.loads( request.body )
except: request.is_json = False except (json.JSONDecodeError, UnicodeDecodeError):
else: request.is_json = True request.is_json = False
else:
request.is_json = True
if isinstance( request.json, dict ): if isinstance( request.json, dict ):
#Security check bellow (unsafe_kwargs), should make this a non issue #Security check bellow (unsafe_kwargs), should make this a non issue
kwargs.update({ kwargs.update({
k : v k : v
for k, v in request.json.items() for k, v in request.json.items()
if k in f_args_specs.kwonlyargs if k in self.f_args_specs.kwonlyargs
and k not in kwargs #Still Extra Security and k not in kwargs #Still Extra Security
}) })
extended_args = [] # v for v in kwargs.values() ] @staticmethod
request.guard_blocked = False def finalize_response( response ):
for guard in guard_args:
response = guard(request)
if request.guard_blocked == True: break
extended_args.append( response )
else:
response = f( request, *extended_args, **kwargs )
if isinstance(response, HttpResponse): if isinstance(response, HttpResponse):
return response return response
@@ -79,35 +116,76 @@ def Endpoint( sig, *guard_args ):
assert len(response) == 2 assert len(response) == 2
status_code, response = response status_code, response = response
assert isinstance(status_code, int) #TODO: accept http.HTTPStatus() instances assert isinstance(status_code, int) #TODO: accept http.HTTPStatus() instances
else: status_code = 200 else: status_code = 200
return JsonResponse( response, status = status_code, encoder = CustomJSONEncoder, safe = False ) return JsonResponse( response, status = status_code, encoder = CustomJSONEncoder, safe = False )
route_to_others[route][method] = decorated def build_sync_decorated( self ):
endoint_path = path(route, decorated) f = self.f
guard_args = self.guard_args
unsafe_kwargs = set( f_args_specs.kwonlyargs ) & set( endoint_path.pattern.converters ) @functools.wraps(f)
if unsafe_kwargs: @csrf_exempt
logger.warning( f"Skipping '{sig}' due to Security Issue: Keyword only arguments {unsafe_kwargs} can only be provided from user input." ) def decorated( request, *args, **kwargs ):
return if not self.sanity_check(request): return Endpoint.bad_method_response()
if route not in route_to_index: #If it's the first time seeing this route, just append the decorated endpoint self.prepare_kwargs( request, kwargs )
route_to_index[route] = len(urlpatterns) extended_args = []
urlpatterns.append( endoint_path ) request.guard_blocked = False
return for guard in guard_args:
response = guard(request)
if request.guard_blocked == True: break
extended_args.append( response )
else:
response = f( request, *extended_args, **kwargs )
return self.finalize_response( response )
return decorated
def build_async_decorated( self ):
f = self.f
guard_args = self.guard_args
@functools.wraps(f)
@csrf_exempt
async def decorated( request, *args, **kwargs ):
if not self.sanity_check(request): return Endpoint.bad_method_response()
self.prepare_kwargs( request, kwargs )
extended_args = []
request.guard_blocked = False
for guard in guard_args:
response = await guard(request)
if request.guard_blocked == True: break
extended_args.append( response )
else:
response = await f( request, *extended_args, **kwargs )
return self.finalize_response( response )
return decorated
def build_sync_conjoined( self ):
route = self.route
@csrf_exempt @csrf_exempt
def conjoined( request, *args, **kwargs ): def conjoined( request, *args, **kwargs ):
try: try:
decorated = route_to_others[route][request.method] target = Endpoint.route_to_others[route][request.method]
except KeyError: except KeyError:
return HttpResponse("Bad Method", status = 400) return Endpoint.bad_method_response()
else: else:
return decorated( request, *args, **kwargs ) return target( request, *args, **kwargs )
return conjoined
urlpatterns[ route_to_index[route] ] = path(route, conjoined) def build_async_conjoined( self ):
route = self.route
@csrf_exempt
async def conjoined( request, *args, **kwargs ):
return decorator try:
target = Endpoint.route_to_others[route][request.method]
except KeyError:
return Endpoint.bad_method_response()
else:
return await target( request, *args, **kwargs )
return conjoined