syncapi now also supports async.
This commit is contained in:
+133
-55
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user