mirror of
https://github.com/peeringdb/peeringdb.git
synced 2024-05-11 05:55:09 +00:00
add user agent and version check to server (#6)
This commit is contained in:
@@ -27,10 +27,17 @@ misc:
|
||||
enabled: true
|
||||
anon: "100/second"
|
||||
user: "100/second"
|
||||
compat:
|
||||
client:
|
||||
min: "0,6"
|
||||
max: "0,6"
|
||||
backends:
|
||||
- name: django_peeringdb
|
||||
min: "0,6"
|
||||
max: "0,6"
|
||||
maintenance:
|
||||
lockfile: "maintenance.lock"
|
||||
|
||||
|
||||
locale:
|
||||
# enable these locale
|
||||
- en
|
||||
|
@@ -44,6 +44,22 @@ REST_FRAMEWORK = {
|
||||
|
||||
}
|
||||
|
||||
CLIENT_COMPAT = {
|
||||
"client" : {
|
||||
"min": ({{ env.misc.api.compat.client.min }}),
|
||||
"max": ({{ env.misc.api.compat.client.min }})
|
||||
},
|
||||
"backends" : {
|
||||
{% for backend in env.misc.api.compat.backends %}
|
||||
"{{ backend.name }}" : {
|
||||
"min" : ({{ backend.min }}),
|
||||
"max" : ({{ backend.max }})
|
||||
},
|
||||
{% endfor %}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
SWAGGER_SETTINGS = {
|
||||
'api_version' : '0.1',
|
||||
'api_path' : '/api',
|
||||
|
@@ -38,9 +38,7 @@ class RestRouter(routers.DefaultRouter):
|
||||
routers.Route(url=r'^{prefix}{trailing_slash}$', mapping={
|
||||
'get': 'list',
|
||||
'post': 'create'
|
||||
}, name='{basename}-list', initkwargs={
|
||||
'suffix': 'List'
|
||||
}),
|
||||
}, name='{basename}-list', initkwargs={'suffix': 'List'}),
|
||||
# Detail route.
|
||||
routers.Route(
|
||||
url=r'^{prefix}/{lookup}{trailing_slash}$', mapping={
|
||||
@@ -48,9 +46,7 @@ class RestRouter(routers.DefaultRouter):
|
||||
'put': 'update',
|
||||
'patch': 'partial_update',
|
||||
'delete': 'destroy'
|
||||
}, name='{basename}-detail', initkwargs={
|
||||
'suffix': 'Instance'
|
||||
}),
|
||||
}, name='{basename}-detail', initkwargs={'suffix': 'Instance'}),
|
||||
routers.DynamicDetailRoute(
|
||||
url=r'^{prefix}/{lookup}/{methodnamehyphen}$',
|
||||
name='{basename}-{methodnamehyphen}', initkwargs={}),
|
||||
@@ -78,6 +74,141 @@ def pdb_exception_handler(exc):
|
||||
return exception_handler(exc)
|
||||
|
||||
|
||||
class client_check(object):
|
||||
"""
|
||||
decorator that can be attached to rest viewset responses and will
|
||||
generate an error response if the requesting peeringdb client
|
||||
is running a client or backend version that is incompatible with
|
||||
the server
|
||||
|
||||
compatibilty is controlled via facsimile during deploy and can
|
||||
be configured in env.misc.api.compat
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.min_version = settings.CLIENT_COMPAT.get("client").get("min")
|
||||
self.max_version = settings.CLIENT_COMPAT.get("client").get("max")
|
||||
self.backends = settings.CLIENT_COMPAT.get("backends", {})
|
||||
|
||||
def __call__(self, fn):
|
||||
compat_check = self.compat_check
|
||||
|
||||
def wrapped(self, request, *args, **kwargs):
|
||||
try:
|
||||
compat_check(request)
|
||||
except ValueError as exc:
|
||||
return Response(status=status.HTTP_400_BAD_REQUEST,
|
||||
data={"detail": str(exc)})
|
||||
|
||||
return fn(self, request, *args, **kwargs)
|
||||
|
||||
return wrapped
|
||||
|
||||
def version_tuple(self, str_version):
|
||||
""" take a semantic version string and turn into a tuple """
|
||||
return tuple([int(i) for i in str_version.split(".")])
|
||||
|
||||
def version_pad(self, version):
|
||||
""" take a semantic version tuple and zero pad to dev version """
|
||||
while len(version) < 4:
|
||||
version = version + (0, )
|
||||
return version
|
||||
|
||||
def version_string(self, version):
|
||||
""" take a semantic version tuple and turn into a "." delimited string """
|
||||
return ".".join([str(i) for i in version])
|
||||
|
||||
def backend_min_version(self, backend):
|
||||
""" return the min supported version for the specified backend """
|
||||
return self.backends.get(backend, {}).get("min")
|
||||
|
||||
def backend_max_version(self, backend):
|
||||
""" return the max supported version for the specified backend """
|
||||
return self.backends.get(backend, {}).get("max")
|
||||
|
||||
def client_info(self, request):
|
||||
"""
|
||||
parse the useragent in the request and return client version
|
||||
info if possible.
|
||||
|
||||
any connecting client that is NOT the peeringdb client will currently
|
||||
return an empty dict and not compatibility checking will be done
|
||||
"""
|
||||
|
||||
# if no user agent was specified in headers we bail
|
||||
try:
|
||||
agent = request.META["HTTP_USER_AGENT"]
|
||||
except KeyError:
|
||||
return {}
|
||||
|
||||
# check if connecting client is peeringdb-py client and
|
||||
# if it parse
|
||||
# - the client version
|
||||
# - backend name
|
||||
# - backend version
|
||||
m = re.match("PeeringDB/([\d\.]+) (\S+)/([\d\.]+)", agent)
|
||||
if m:
|
||||
return {
|
||||
"client": self.version_tuple(m.group(1)),
|
||||
"backend": {
|
||||
"name": m.group(2),
|
||||
"version": self.version_tuple(m.group(3))
|
||||
}
|
||||
}
|
||||
return {}
|
||||
|
||||
def compat_check(self, request):
|
||||
"""
|
||||
Check if the connecting client is compatible with the api
|
||||
|
||||
This is currently only sensible when the request is made through
|
||||
the official peeringdb-py client, any other client will be
|
||||
passed through without checks
|
||||
|
||||
On incompatibility a ValueError is raised
|
||||
"""
|
||||
|
||||
info = self.client_info(request)
|
||||
compat = True
|
||||
if info:
|
||||
backend = info["backend"]["name"]
|
||||
|
||||
if backend not in self.backends:
|
||||
return
|
||||
|
||||
backend_min = self.backend_min_version(backend)
|
||||
backend_max = self.backend_max_version(backend)
|
||||
client_version = info.get("client")
|
||||
backend_version = info.get("backend").get("version")
|
||||
|
||||
if self.version_pad(
|
||||
self.min_version) > self.version_pad(client_version):
|
||||
# client version is too low
|
||||
compat = False
|
||||
elif self.version_pad(
|
||||
self.max_version) < self.version_pad(client_version):
|
||||
# client version is too high
|
||||
compat = False
|
||||
|
||||
if self.version_pad(backend_min) > self.version_pad(
|
||||
backend_version):
|
||||
# client backend version is too low
|
||||
compat = False
|
||||
elif self.version_pad(backend_max) < self.version_pad(
|
||||
backend_version):
|
||||
# client backend version is too high
|
||||
compat = False
|
||||
|
||||
if not compat:
|
||||
raise ValueError(
|
||||
"Your client version is incompatible with server version of the api, please install peeringdb>={},<={} {}>={},<={}"
|
||||
.format(
|
||||
self.version_string(self.min_version),
|
||||
self.version_string(self.max_version), backend,
|
||||
self.version_string(backend_min),
|
||||
self.version_string(backend_max)))
|
||||
|
||||
|
||||
###############################################################################
|
||||
# VIEW SETS
|
||||
|
||||
@@ -279,6 +410,7 @@ class ModelViewSet(viewsets.ModelViewSet):
|
||||
else:
|
||||
return qset
|
||||
|
||||
@client_check()
|
||||
def list(self, request, *args, **kwargs):
|
||||
"""
|
||||
### Querying
|
||||
@@ -405,13 +537,11 @@ class ModelViewSet(viewsets.ModelViewSet):
|
||||
try:
|
||||
r = super(ModelViewSet, self).list(request, *args, **kwargs)
|
||||
except ValueError, inst:
|
||||
return Response(status=status.HTTP_400_BAD_REQUEST, data={
|
||||
"detail": str(inst)
|
||||
})
|
||||
return Response(status=status.HTTP_400_BAD_REQUEST,
|
||||
data={"detail": str(inst)})
|
||||
except TypeError, inst:
|
||||
return Response(status=status.HTTP_400_BAD_REQUEST, data={
|
||||
"detail": str(inst)
|
||||
})
|
||||
return Response(status=status.HTTP_400_BAD_REQUEST,
|
||||
data={"detail": str(inst)})
|
||||
except CacheRedirect, inst:
|
||||
r = Response(status=200, data=inst.loader.load())
|
||||
d = time.time() - t
|
||||
@@ -420,13 +550,15 @@ class ModelViewSet(viewsets.ModelViewSet):
|
||||
#FIXME: this waits for peeringdb-py fix to deal with 404 raise properly
|
||||
if not r or not len(r.data):
|
||||
if self.serializer_class.is_unique_query(request):
|
||||
return Response(status=404, data={
|
||||
return Response(
|
||||
status=404, data={
|
||||
"data": [],
|
||||
"detail": "Entity not found"
|
||||
})
|
||||
|
||||
return r
|
||||
|
||||
@client_check()
|
||||
def retrieve(self, request, *args, **kwargs):
|
||||
# could add fk relationships here, one at a time, but we need to define
|
||||
# them somewhere by the time we get the serializer, the data is already
|
||||
@@ -439,6 +571,7 @@ class ModelViewSet(viewsets.ModelViewSet):
|
||||
|
||||
return r
|
||||
|
||||
@client_check()
|
||||
def create(self, request, *args, **kwargs):
|
||||
"""
|
||||
Create object
|
||||
@@ -452,12 +585,12 @@ class ModelViewSet(viewsets.ModelViewSet):
|
||||
except PermissionDenied, inst:
|
||||
return Response(status=status.HTTP_403_FORBIDDEN)
|
||||
except ParentStatusException, inst:
|
||||
return Response(status=status.HTTP_400_BAD_REQUEST, data={
|
||||
"detail": str(inst)
|
||||
})
|
||||
return Response(status=status.HTTP_400_BAD_REQUEST,
|
||||
data={"detail": str(inst)})
|
||||
finally:
|
||||
self.get_serializer().finalize_create(request)
|
||||
|
||||
@client_check()
|
||||
def update(self, request, *args, **kwargs):
|
||||
"""
|
||||
Update object
|
||||
@@ -470,13 +603,11 @@ class ModelViewSet(viewsets.ModelViewSet):
|
||||
return super(ModelViewSet, self).update(
|
||||
request, *args, **kwargs)
|
||||
except TypeError, inst:
|
||||
return Response(status=status.HTTP_400_BAD_REQUEST, data={
|
||||
"detail": str(inst)
|
||||
})
|
||||
return Response(status=status.HTTP_400_BAD_REQUEST,
|
||||
data={"detail": str(inst)})
|
||||
except ValueError, inst:
|
||||
return Response(status=status.HTTP_400_BAD_REQUEST, data={
|
||||
"detail": str(inst)
|
||||
})
|
||||
return Response(status=status.HTTP_400_BAD_REQUEST,
|
||||
data={"detail": str(inst)})
|
||||
finally:
|
||||
self.get_serializer().finalize_update(request)
|
||||
|
||||
@@ -486,6 +617,7 @@ class ModelViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
return Response(status=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
@client_check()
|
||||
def destroy(self, request, pk, format=None):
|
||||
"""
|
||||
Delete object
|
||||
@@ -494,9 +626,8 @@ class ModelViewSet(viewsets.ModelViewSet):
|
||||
try:
|
||||
obj = self.model.objects.get(pk=pk)
|
||||
except ValueError:
|
||||
return Response(status=status.HTTP_400_BAD_REQUEST, data={
|
||||
"extra": "Invalid id"
|
||||
})
|
||||
return Response(status=status.HTTP_400_BAD_REQUEST,
|
||||
data={"extra": "Invalid id"})
|
||||
except self.model.DoesNotExist:
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@@ -576,9 +707,8 @@ class NetworkASNViewSet(NetworkViewSet):
|
||||
try:
|
||||
network = Network.objects.get(asn=int(asn))
|
||||
except ValueError:
|
||||
return Response(status=status.HTTP_400_BAD_REQUEST, data={
|
||||
"detail": "Invalid ASN"
|
||||
})
|
||||
return Response(status=status.HTTP_400_BAD_REQUEST,
|
||||
data={"detail": "Invalid ASN"})
|
||||
except ObjectDoesNotExist:
|
||||
return Response(status=status.HTTP_404_NOT_FOUND)
|
||||
return super(NetworkViewSet, self).retrieve(request, network.id)
|
||||
@@ -607,9 +737,7 @@ class NetworkASNViewSet(NetworkViewSet):
|
||||
# set here in case we want to add more urls later
|
||||
urls = router.urls
|
||||
|
||||
REFTAG_MAP = dict(
|
||||
[(cls.model.handleref.tag, cls)
|
||||
for cls in [
|
||||
REFTAG_MAP = dict([(cls.model.handleref.tag, cls) for cls in [
|
||||
OrganizationViewSet, NetworkViewSet, FacilityViewSet,
|
||||
InternetExchangeViewSet, InternetExchangeFacilityViewSet,
|
||||
NetworkFacilityViewSet, NetworkIXLanViewSet, NetworkContactViewSet,
|
||||
|
@@ -74,6 +74,12 @@ settings.configure(
|
||||
TABLE_PREFIX='peeringdb',
|
||||
PEERINGDB_ABSTRACT_ONLY=True,
|
||||
COUNTRIES_OVERRIDE={'XK': _('Kosovo')},
|
||||
CLIENT_COMPAT={
|
||||
"client":{"min": (0,6), "max":(0,6,5)},
|
||||
"backends":{
|
||||
"django_peeringdb":{"min":(0,6), "max":(0,6,5)}
|
||||
}
|
||||
},
|
||||
DATABASE_ENGINE='django.db.backends.sqlite3',
|
||||
DATABASES={
|
||||
'default': {
|
||||
|
@@ -85,6 +85,7 @@ class DummyRestClient(RestClient):
|
||||
super(DummyRestClient, self).__init__(*args, **kwargs)
|
||||
self.factory = APIRequestFactory()
|
||||
self.api_client = APIClient()
|
||||
self.useragent = kwargs.get("useragent")
|
||||
if self.user:
|
||||
self.user_inst = models.User.objects.get(username=self.user)
|
||||
else:
|
||||
|
88
tests/test_api_compat.py
Normal file
88
tests/test_api_compat.py
Normal file
@@ -0,0 +1,88 @@
|
||||
import json
|
||||
import pytest
|
||||
|
||||
from util import ClientCase
|
||||
from test_api import setup_module, teardown_module
|
||||
from peeringdb_server.models import REFTAG_MAP, User
|
||||
from rest_framework.test import APIClient, force_authenticate
|
||||
|
||||
|
||||
class TestAPIClientCompat(ClientCase):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
super(TestAPIClientCompat, cls).setUpTestData()
|
||||
cls.superuser = User.objects.create_user("su", "su@localhost", "su",
|
||||
is_superuser=True)
|
||||
cls.org = REFTAG_MAP["org"].objects.create(name="Test Org",
|
||||
status="ok")
|
||||
|
||||
@property
|
||||
def expected_compat_err_str(self):
|
||||
return "Your client version is incompatible with server version of the api, please install peeringdb>={},<={} {}>={},<={}".format(
|
||||
"0.6", "0.6.5", "django_peeringdb", "0.6", "0.6.5")
|
||||
|
||||
def _compat(self, ua_c, ua_be, error):
|
||||
if ua_c and ua_be:
|
||||
useragent = "PeeringDB/{} django_peeringdb/{}".format(ua_c, ua_be)
|
||||
self.client = APIClient(HTTP_USER_AGENT=useragent)
|
||||
else:
|
||||
self.client = APIClient()
|
||||
self.client.force_authenticate(self.superuser)
|
||||
|
||||
r = self.client.get("/api/org/1", format="json")
|
||||
content = json.loads(r.content)
|
||||
if error:
|
||||
assert r.status_code == 400
|
||||
assert content["meta"]["error"] == self.expected_compat_err_str
|
||||
else:
|
||||
assert r.status_code == 200
|
||||
|
||||
r = self.client.post("/api/net", {
|
||||
"org_id": 1,
|
||||
"name": "Test net",
|
||||
"asn": 9000000
|
||||
}, format="json")
|
||||
content = json.loads(r.content)
|
||||
if error:
|
||||
assert r.status_code == 400
|
||||
assert content["meta"]["error"] == self.expected_compat_err_str
|
||||
net = {"id": 1}
|
||||
else:
|
||||
assert r.status_code == 201
|
||||
net = content["data"][0]
|
||||
del net["org"]
|
||||
|
||||
r = self.client.put("/api/net/{}".format(net["id"]), net,
|
||||
format="json")
|
||||
content = json.loads(r.content)
|
||||
if error:
|
||||
assert r.status_code == 400
|
||||
assert content["meta"]["error"] == self.expected_compat_err_str
|
||||
else:
|
||||
assert r.status_code == 200
|
||||
|
||||
r = self.client.delete("/api/net/{}".format(net["id"]), {},
|
||||
format="json")
|
||||
if error:
|
||||
content = json.loads(r.content)
|
||||
assert r.status_code == 400
|
||||
assert content["meta"]["error"] == self.expected_compat_err_str
|
||||
else:
|
||||
assert r.status_code == 204
|
||||
|
||||
REFTAG_MAP["net"].objects.all().delete()
|
||||
|
||||
def test_incompatible(self):
|
||||
self._compat("0.5.0", "0.4.0", True)
|
||||
self._compat("0.6.0", "0.5.0", True)
|
||||
self._compat("0.5.0", "0.6.0", True)
|
||||
self._compat("0.7.0", "0.6.0", True)
|
||||
self._compat("0.6.0", "0.7.0", True)
|
||||
|
||||
def test_compatible(self):
|
||||
self._compat("0.6.0", "0.6.0", False)
|
||||
self._compat("0.6", "0.6", False)
|
||||
self._compat("0.6.1", "0.6.1", False)
|
||||
self._compat("0.6", "0.6.1", False)
|
||||
self._compat("0.6.1", "0.6", False)
|
||||
self._compat(None, None, False)
|
Reference in New Issue
Block a user