1
0
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:
Stefan Pratter
2019-01-11 11:24:00 +00:00
parent e0a354468a
commit b4eff356a1
6 changed files with 286 additions and 40 deletions

View File

@@ -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

View File

@@ -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',

View File

@@ -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,

View File

@@ -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': {

View File

@@ -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
View 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)