浏览代码

feat(api): add API versioning, closes #137

Nils Wisiol 6 年之前
父节点
当前提交
35bc15c19e

+ 18 - 5
README.md

@@ -1,5 +1,5 @@
 deSEC Stack
 deSEC Stack
-=====
+===========
 
 
 This is a docker-compose application providing the basic stack for deSEC name services. It consists of
 This is a docker-compose application providing the basic stack for deSEC name services. It consists of
 
 
@@ -11,7 +11,7 @@ This is a docker-compose application providing the basic stack for deSEC name se
 
 
 
 
 Requirements
 Requirements
------
+------------
 
 
 Although most configuration is contained in this repository, some external dependencies need to be met before the application can be run. Dependencies are:
 Although most configuration is contained in this repository, some external dependencies need to be met before the application can be run. Dependencies are:
 
 
@@ -61,7 +61,7 @@ Running the standard stack will also fire up an instance of the `www` proxy serv
 
 
 
 
 How to Run
 How to Run
------
+----------
 
 
 Development:
 Development:
 
 
@@ -73,13 +73,26 @@ Production:
 
 
 
 
 Storage
 Storage
----
+-------
 All important data is stored in the databases managed by the `db*` containers. They use Docker volumes which, by default, reside in `/var/lib/docker/volumes/desecstack_{dbapi,dblord,dbmaster}_mysql`.
 All important data is stored in the databases managed by the `db*` containers. They use Docker volumes which, by default, reside in `/var/lib/docker/volumes/desecstack_{dbapi,dblord,dbmaster}_mysql`.
 This is the location you will want to back up. (Be sure to follow standard MySQL backup practices, i.e. make sure things are consistent.)
 This is the location you will want to back up. (Be sure to follow standard MySQL backup practices, i.e. make sure things are consistent.)
 
 
 
 
+API Versions and Roadmap
+------------------------
+
+deSEC currently maintains the following API versions:
+
+API Version | URL Prefix | Status                                   | Support Ends
+----------- | ---------- | ---------------------------------------- | ------------
+Version 1   | `/api/v1/` |  unstable, stable release exp. June 2019 | earliest 6 months after v2 is declared stable
+Version 2   | `/api/v2/` |  unstable
+
+You can find our documentation for all API versions at https://desec.readthedocs.io/. (Select the version of interest in the navigation bar.)
+
+
 Notes on IPv6
 Notes on IPv6
------
+-------------
 
 
 This stack is IPv6-capable. Caveats:
 This stack is IPv6-capable. Caveats:
 
 

+ 2 - 0
api/api/settings.py

@@ -88,6 +88,8 @@ REST_FRAMEWORK = {
     ),
     ),
     'TEST_REQUEST_DEFAULT_FORMAT': 'json',
     'TEST_REQUEST_DEFAULT_FORMAT': 'json',
     'EXCEPTION_HANDLER': 'desecapi.exception_handlers.handle_db_unavailable',
     'EXCEPTION_HANDLER': 'desecapi.exception_handlers.handle_db_unavailable',
+    'DEFAULT_VERSIONING_CLASS': 'rest_framework.versioning.NamespaceVersioning',
+    'ALLOWED_VERSIONS': ['v1', 'v2'],
 }
 }
 
 
 # user management configuration
 # user management configuration

+ 56 - 33
api/api/urls.py

@@ -1,38 +1,61 @@
-from django.conf.urls import include, url
-from desecapi import views
-from djoser.views import UserView
-from rest_framework.routers import SimpleRouter
+from django.urls import include, path
 
 
-tokens_router = SimpleRouter()
-tokens_router.register(r'', views.TokenViewSet, base_name='token')
 
 
-auth_urls = [
-    url(r'^users/create/$', views.UserCreateView.as_view(), name='user-create'),  # deprecated
-    url(r'^token/create/$', views.TokenCreateView.as_view(), name='token-create'),  # deprecated
-    url(r'^token/destroy/$', views.TokenDestroyView.as_view(), name='token-destroy'),  # deprecated
-    url(r'^users/$', views.UserCreateView.as_view(), name='register'),
-    url(r'^token/login/$', views.TokenCreateView.as_view(), name='login'),
-    url(r'^token/logout/$', views.TokenDestroyView.as_view(), name='logout'),
-    url(r'^tokens/', include(tokens_router.urls)),
-    url(r'^me/?$', UserView.as_view(), name='user'),
-    url(r'^', include('djoser.urls.authtoken')),
-]
-
-api_urls = [
-    url(r'^$', views.Root.as_view(), name='root'),
-    url(r'^domains/$', views.DomainList.as_view(), name='domain-list'),
-    url(r'^domains/(?P<name>[a-zA-Z\.\-_0-9]+)/$', views.DomainDetail.as_view(), name='domain-detail'),
-    url(r'^domains/(?P<name>[a-zA-Z\.\-_0-9]+)/rrsets/$', views.RRsetList.as_view(), name='rrsets'),
-    url(r'^domains/(?P<name>[a-zA-Z\.\-_0-9]+)/rrsets/(?P<subname>(\*)?[a-zA-Z\.\-_0-9=]*)\.\.\./(?P<type>[A-Z][A-Z0-9]*)/$', views.RRsetDetail.as_view(), name='rrset'),
-    url(r'^domains/(?P<name>[a-zA-Z\.\-_0-9]+)/rrsets/(?P<subname>[*@]|[a-zA-Z\.\-_0-9=]+)/(?P<type>[A-Z][A-Z0-9]*)/$', views.RRsetDetail.as_view(), name='rrset@'),
-    url(r'^dns$', views.DnsQuery.as_view(), name='dns-query'),
-    url(r'^dyndns/update$', views.DynDNS12Update.as_view(), name='dyndns12update'),
-    url(r'^donation/', views.DonationList.as_view(), name='donation'),
-    url(r'^unlock/user/(?P<email>.+)$', views.unlock, name='unlock/byEmail'),
-    url(r'^unlock/done', views.unlock_done, name='unlock/done'),
-]
+#
+# On Reversing URLs
+# =================
+#
+# Recommended Usage:
+# Always use rest_framework.reverse.reverse, do not directly use django.urls.reverse.
+# If a request object r is available, use reverse(name, request=r). With the name as defined
+# in desecapi.urls.v1 or desecapi.urls.v2. It will return an URL maintaining the currently requested API version.
+# If there is no request object available, e.g. in commands, a mock object can be constructed
+# carrying all information that is necessary to construct a full URL:
+#
+#         from django.test import RequestFactory
+#         from rest_framework.versioning import NamespaceVersioning
+#         from api import settings
+#
+#         r = RequestFactory().request(HTTP_HOST=settings.ALLOWED_HOSTS[0])
+#         r.version = 'v1'
+#         r.versioning_scheme = NamespaceVersioning()
+#
+# Also note in this context settings.REST_FRAMEWORK['ALLOWED_VERSIONS'] and
+# settings.REST_FRAMEWORK['DEFAULT_VERSIONING_CLASS']. (The latter is of type string.)
+#
+# Advanced Usage:
+# Prefix the name of any path with 'desecapi' to get the default version,
+# or prefix the name of any path with the desired namespace, e.g. 'v1:root'.
+# In this case, the version information of the request will be ignored and
+# providing a request object is optional. However, if no request object is provided,
+# only a relative URL can be generated.
+#
+# Examples:
+# The examples refer to the version used by the client to connect as the REQUESTED version,
+# the version specified by the first argument to reverse as the SPECIFIED version, and to the
+# version defined as default (see below) as the DEFAULT version.
+#
+#         reverse('root', request) -> absolute URL, e.g. https://.../api/v1/, with the REQUESTED version
+#         reverse('root') -> django.urls.exceptions.NoReverseMatch
+#         reverse('desecapi:root') -> relative URL, e.g. api/v1/, with the DEFAULT version
+#         reverse('v2:root') -> relative URL, e.g. api/v2/, with the SPECIFIED version
+#         reverse('v2:root', request) -> absolute URL, e.g. https://.../api/v2/, with the SPECIFIED version
+#         reverse('desecapi:root', request) -> absolute URL, e.g. https://.../api/v1/, with the DEFAULT version
+#         reverse('v1:root', request) -> absolute URL, e.g. https://.../api/v1/, with the SPECIFIED version
+#
+# See Also:
+# https://github.com/encode/django-rest-framework/issues/5659
+# https://github.com/encode/django-rest-framework/issues/3825
+#
+# Note that from the client's perspective, there is no default version: each request needs to
+# specify the version in the request URL.
+#
 
 
+# IMPORTANT: specify default version as the last element in the list
+# if no other information is available, the last-specified version will be used as default for reversing URLs
 urlpatterns = [
 urlpatterns = [
-    url(r'^api/v1/auth/', include(auth_urls)),
-    url(r'^api/v1/', include(api_urls)),
+    # other available versions in no particular order
+    path('api/v2/', include('desecapi.urls.version_2', namespace='v2')),
+    # the DEFAULT version
+    path('api/v1/', include('desecapi.urls.version_1', namespace='v1')),
 ]
 ]

+ 28 - 28
api/desecapi/tests/testdomains.py

@@ -1,7 +1,7 @@
-from django.urls import reverse
+from rest_framework.reverse import reverse
 from rest_framework import status
 from rest_framework import status
 from rest_framework.test import APITestCase
 from rest_framework.test import APITestCase
-from .utils import utils
+from desecapi.tests.utils import utils
 from desecapi.models import Domain
 from desecapi.models import Domain
 from django.core import mail
 from django.core import mail
 import httpretty
 import httpretty
@@ -11,22 +11,22 @@ import json
 
 
 class UnauthenticatedDomainTests(APITestCase):
 class UnauthenticatedDomainTests(APITestCase):
     def testExpectUnauthorizedOnGet(self):
     def testExpectUnauthorizedOnGet(self):
-        url = reverse('domain-list')
+        url = reverse('v1:domain-list')
         response = self.client.get(url, format='json')
         response = self.client.get(url, format='json')
         self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
         self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
 
 
     def testExpectUnauthorizedOnPost(self):
     def testExpectUnauthorizedOnPost(self):
-        url = reverse('domain-list')
+        url = reverse('v1:domain-list')
         response = self.client.post(url, format='json')
         response = self.client.post(url, format='json')
         self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
         self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
 
 
     def testExpectUnauthorizedOnPut(self):
     def testExpectUnauthorizedOnPut(self):
-        url = reverse('domain-detail', args=('example.com',))
+        url = reverse('v1:domain-detail', args=('example.com',))
         response = self.client.put(url, format='json')
         response = self.client.put(url, format='json')
         self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
         self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
 
 
     def testExpectUnauthorizedOnDelete(self):
     def testExpectUnauthorizedOnDelete(self):
-        url = reverse('domain-detail', args=('example.com',))
+        url = reverse('v1:domain-detail', args=('example.com',))
         response = self.client.delete(url, format='json')
         response = self.client.delete(url, format='json')
         self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
         self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
 
 
@@ -45,7 +45,7 @@ class AuthenticatedDomainTests(APITestCase):
         httpretty.disable()
         httpretty.disable()
 
 
     def testExpectOnlyOwnedDomains(self):
     def testExpectOnlyOwnedDomains(self):
-        url = reverse('domain-list')
+        url = reverse('v1:domain-list')
         response = self.client.get(url, format='json')
         response = self.client.get(url, format='json')
         self.assertEqual(response.status_code, status.HTTP_200_OK)
         self.assertEqual(response.status_code, status.HTTP_200_OK)
         self.assertEqual(len(response.data), 2)
         self.assertEqual(len(response.data), 2)
@@ -57,7 +57,7 @@ class AuthenticatedDomainTests(APITestCase):
         httpretty.register_uri(httpretty.DELETE, settings.NSLORD_PDNS_API + '/zones/' + self.ownedDomains[1].name + '.')
         httpretty.register_uri(httpretty.DELETE, settings.NSLORD_PDNS_API + '/zones/' + self.ownedDomains[1].name + '.')
         httpretty.register_uri(httpretty.DELETE, settings.NSMASTER_PDNS_API + '/zones/' + self.ownedDomains[1].name+ '.')
         httpretty.register_uri(httpretty.DELETE, settings.NSMASTER_PDNS_API + '/zones/' + self.ownedDomains[1].name+ '.')
 
 
-        url = reverse('domain-detail', args=(self.ownedDomains[1].name,))
+        url = reverse('v1:domain-detail', args=(self.ownedDomains[1].name,))
         response = self.client.delete(url)
         response = self.client.delete(url)
         self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
         self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
         self.assertEqual(httpretty.last_request().method, 'DELETE')
         self.assertEqual(httpretty.last_request().method, 'DELETE')
@@ -76,7 +76,7 @@ class AuthenticatedDomainTests(APITestCase):
         httpretty.register_uri(httpretty.DELETE, settings.NSLORD_PDNS_API + '/zones/' + self.otherDomains[1].name + '.')
         httpretty.register_uri(httpretty.DELETE, settings.NSLORD_PDNS_API + '/zones/' + self.otherDomains[1].name + '.')
         httpretty.register_uri(httpretty.DELETE, settings.NSMASTER_PDNS_API + '/zones/' + self.otherDomains[1].name+ '.')
         httpretty.register_uri(httpretty.DELETE, settings.NSMASTER_PDNS_API + '/zones/' + self.otherDomains[1].name+ '.')
 
 
-        url = reverse('domain-detail', args=(self.otherDomains[1].name,))
+        url = reverse('v1:domain-detail', args=(self.otherDomains[1].name,))
         response = self.client.delete(url)
         response = self.client.delete(url)
         self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
         self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
         self.assertTrue(isinstance(httpretty.last_request(), httpretty.core.HTTPrettyRequestEmpty))
         self.assertTrue(isinstance(httpretty.last_request(), httpretty.core.HTTPrettyRequestEmpty))
@@ -89,19 +89,19 @@ class AuthenticatedDomainTests(APITestCase):
                                body='[]',
                                body='[]',
                                content_type="application/json")
                                content_type="application/json")
 
 
-        url = reverse('domain-detail', args=(self.ownedDomains[1].name,))
+        url = reverse('v1:domain-detail', args=(self.ownedDomains[1].name,))
         response = self.client.get(url)
         response = self.client.get(url)
         self.assertEqual(response.status_code, status.HTTP_200_OK)
         self.assertEqual(response.status_code, status.HTTP_200_OK)
         self.assertEqual(response.data['name'], self.ownedDomains[1].name)
         self.assertEqual(response.data['name'], self.ownedDomains[1].name)
         self.assertTrue(isinstance(response.data['keys'], list))
         self.assertTrue(isinstance(response.data['keys'], list))
 
 
     def testCantGetOtherDomains(self):
     def testCantGetOtherDomains(self):
-        url = reverse('domain-detail', args=(self.otherDomains[1].name,))
+        url = reverse('v1:domain-detail', args=(self.otherDomains[1].name,))
         response = self.client.get(url)
         response = self.client.get(url)
         self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
         self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
 
 
     def testCantChangeDomainName(self):
     def testCantChangeDomainName(self):
-        url = reverse('domain-detail', args=(self.ownedDomains[1].name,))
+        url = reverse('v1:domain-detail', args=(self.ownedDomains[1].name,))
         response = self.client.get(url)
         response = self.client.get(url)
         newname = utils.generateDomainname()
         newname = utils.generateDomainname()
         response.data['name'] = newname
         response.data['name'] = newname
@@ -112,12 +112,12 @@ class AuthenticatedDomainTests(APITestCase):
         self.assertEqual(response.data['name'], self.ownedDomains[1].name)
         self.assertEqual(response.data['name'], self.ownedDomains[1].name)
 
 
     def testCantPutOtherDomains(self):
     def testCantPutOtherDomains(self):
-        url = reverse('domain-detail', args=(self.otherDomains[1].name,))
+        url = reverse('v1:domain-detail', args=(self.otherDomains[1].name,))
         response = self.client.put(url, json.dumps({}), content_type='application/json')
         response = self.client.put(url, json.dumps({}), content_type='application/json')
         self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
         self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
 
 
     def testCanPostDomains(self):
     def testCanPostDomains(self):
-        url = reverse('domain-list')
+        url = reverse('v1:domain-list')
         data = {'name': utils.generateDomainname()}
         data = {'name': utils.generateDomainname()}
         response = self.client.post(url, data)
         response = self.client.post(url, data)
         self.assertEqual(response.status_code, status.HTTP_201_CREATED)
         self.assertEqual(response.status_code, status.HTTP_201_CREATED)
@@ -138,14 +138,14 @@ class AuthenticatedDomainTests(APITestCase):
                                content_type="application/json")
                                content_type="application/json")
         httpretty.register_uri(httpretty.PUT, settings.NSLORD_PDNS_API + '/zones/' + name + './notify', status=200)
         httpretty.register_uri(httpretty.PUT, settings.NSLORD_PDNS_API + '/zones/' + name + './notify', status=200)
 
 
-        url = reverse('domain-list')
+        url = reverse('v1:domain-list')
         data = {'name': name}
         data = {'name': name}
         response = self.client.post(url, data)
         response = self.client.post(url, data)
         self.assertEqual(response.status_code, status.HTTP_201_CREATED)
         self.assertEqual(response.status_code, status.HTTP_201_CREATED)
         self.assertEqual(len(mail.outbox), 0)
         self.assertEqual(len(mail.outbox), 0)
 
 
     def testCantPostDomainAlreadyTakenInAPI(self):
     def testCantPostDomainAlreadyTakenInAPI(self):
-        url = reverse('domain-list')
+        url = reverse('v1:domain-list')
 
 
         data = {'name': utils.generateDomainname()}
         data = {'name': utils.generateDomainname()}
         response = self.client.post(url, data)
         response = self.client.post(url, data)
@@ -168,13 +168,13 @@ class AuthenticatedDomainTests(APITestCase):
         httpretty.register_uri(httpretty.POST, settings.NSLORD_PDNS_API + '/zones',
         httpretty.register_uri(httpretty.POST, settings.NSLORD_PDNS_API + '/zones',
                                body='{"error": "Domain \'' + name + '.\' already exists"}', status=422)
                                body='{"error": "Domain \'' + name + '.\' already exists"}', status=422)
 
 
-        url = reverse('domain-list')
+        url = reverse('v1:domain-list')
         data = {'name': name}
         data = {'name': name}
         response = self.client.post(url, data)
         response = self.client.post(url, data)
         self.assertEqual(response.status_code, status.HTTP_409_CONFLICT)
         self.assertEqual(response.status_code, status.HTTP_409_CONFLICT)
 
 
     def testCantPostDomainsViolatingPolicy(self):
     def testCantPostDomainsViolatingPolicy(self):
-        url = reverse('domain-list')
+        url = reverse('v1:domain-list')
 
 
         data = {'name': '*.' + utils.generateDomainname()}
         data = {'name': '*.' + utils.generateDomainname()}
         response = self.client.post(url, data)
         response = self.client.post(url, data)
@@ -182,7 +182,7 @@ class AuthenticatedDomainTests(APITestCase):
         self.assertTrue("does not match the required pattern." in response.data['name'][0])
         self.assertTrue("does not match the required pattern." in response.data['name'][0])
 
 
     def testCanPostComplicatedDomains(self):
     def testCanPostComplicatedDomains(self):
-        url = reverse('domain-list')
+        url = reverse('v1:domain-list')
         data = {'name': 'very.long.domain.name.' + utils.generateDomainname()}
         data = {'name': 'very.long.domain.name.' + utils.generateDomainname()}
         response = self.client.post(url, data)
         response = self.client.post(url, data)
         self.assertEqual(response.status_code, status.HTTP_201_CREATED)
         self.assertEqual(response.status_code, status.HTTP_201_CREATED)
@@ -202,7 +202,7 @@ class AuthenticatedDomainTests(APITestCase):
                                content_type="application/json")
                                content_type="application/json")
         httpretty.register_uri(httpretty.PUT, settings.NSLORD_PDNS_API + '/zones/' + name + './notify', status=200)
         httpretty.register_uri(httpretty.PUT, settings.NSLORD_PDNS_API + '/zones/' + name + './notify', status=200)
 
 
-        url = reverse('domain-list')
+        url = reverse('v1:domain-list')
         self.client.post(url, {'name': name})
         self.client.post(url, {'name': name})
 
 
         self.assertEqual(httpretty.httpretty.latest_requests[-4].method, 'POST')
         self.assertEqual(httpretty.httpretty.latest_requests[-4].method, 'POST')
@@ -213,7 +213,7 @@ class AuthenticatedDomainTests(APITestCase):
         self.assertTrue((settings.NSLORD_PDNS_API + '/zones/' + name + '.').endswith(httpretty.httpretty.latest_requests[-2].path))
         self.assertTrue((settings.NSLORD_PDNS_API + '/zones/' + name + '.').endswith(httpretty.httpretty.latest_requests[-2].path))
 
 
     def testDomainDetailURL(self):
     def testDomainDetailURL(self):
-        url = reverse('domain-detail', args=(self.ownedDomains[1].name,))
+        url = reverse('v1:domain-detail', args=(self.ownedDomains[1].name,))
         self.assertTrue("/" + self.ownedDomains[1].name in url)
         self.assertTrue("/" + self.ownedDomains[1].name in url)
 
 
     def testRollback(self):
     def testRollback(self):
@@ -222,7 +222,7 @@ class AuthenticatedDomainTests(APITestCase):
         httpretty.enable()
         httpretty.enable()
         httpretty.register_uri(httpretty.POST, settings.NSLORD_PDNS_API + '/zones', body="some error", status=500)
         httpretty.register_uri(httpretty.POST, settings.NSLORD_PDNS_API + '/zones', body="some error", status=500)
 
 
-        url = reverse('domain-list')
+        url = reverse('v1:domain-list')
         data = {'name': name}
         data = {'name': name}
         self.client.post(url, data)
         self.client.post(url, data)
 
 
@@ -247,7 +247,7 @@ class AuthenticatedDynDomainTests(APITestCase):
         httpretty.register_uri(httpretty.DELETE, settings.NSLORD_PDNS_API + '/zones/' + self.ownedDomains[1].name + '.')
         httpretty.register_uri(httpretty.DELETE, settings.NSLORD_PDNS_API + '/zones/' + self.ownedDomains[1].name + '.')
         httpretty.register_uri(httpretty.DELETE, settings.NSMASTER_PDNS_API + '/zones/' + self.ownedDomains[1].name + '.')
         httpretty.register_uri(httpretty.DELETE, settings.NSMASTER_PDNS_API + '/zones/' + self.ownedDomains[1].name + '.')
 
 
-        url = reverse('domain-detail', args=(self.ownedDomains[1].name,))
+        url = reverse('v1:domain-detail', args=(self.ownedDomains[1].name,))
         response = self.client.delete(url)
         response = self.client.delete(url)
         self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
         self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
 
 
@@ -268,14 +268,14 @@ class AuthenticatedDynDomainTests(APITestCase):
         httpretty.register_uri(httpretty.DELETE, settings.NSLORD_PDNS_API + '/zones/' + self.otherDomains[1].name + '.')
         httpretty.register_uri(httpretty.DELETE, settings.NSLORD_PDNS_API + '/zones/' + self.otherDomains[1].name + '.')
         httpretty.register_uri(httpretty.DELETE, settings.NSMASTER_PDNS_API + '/zones/' + self.otherDomains[1].name+ '.')
         httpretty.register_uri(httpretty.DELETE, settings.NSMASTER_PDNS_API + '/zones/' + self.otherDomains[1].name+ '.')
 
 
-        url = reverse('domain-detail', args=(self.otherDomains[1].name,))
+        url = reverse('v1:domain-detail', args=(self.otherDomains[1].name,))
         response = self.client.delete(url)
         response = self.client.delete(url)
         self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
         self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
         self.assertTrue(isinstance(httpretty.last_request(), httpretty.core.HTTPrettyRequestEmpty))
         self.assertTrue(isinstance(httpretty.last_request(), httpretty.core.HTTPrettyRequestEmpty))
         self.assertTrue(Domain.objects.filter(pk=self.otherDomains[1].pk).exists())
         self.assertTrue(Domain.objects.filter(pk=self.otherDomains[1].pk).exists())
 
 
     def testCanPostDynDomains(self):
     def testCanPostDynDomains(self):
-        url = reverse('domain-list')
+        url = reverse('v1:domain-list')
         data = {'name': utils.generateDynDomainname()}
         data = {'name': utils.generateDynDomainname()}
         response = self.client.post(url, data)
         response = self.client.post(url, data)
         email = str(mail.outbox[0].message())
         email = str(mail.outbox[0].message())
@@ -289,7 +289,7 @@ class AuthenticatedDynDomainTests(APITestCase):
         # it is currently not covered.
         # it is currently not covered.
 
 
     def testCantPostNonDynDomains(self):
     def testCantPostNonDynDomains(self):
-        url = reverse('domain-list')
+        url = reverse('v1:domain-list')
 
 
         data = {'name': utils.generateDomainname()}
         data = {'name': utils.generateDomainname()}
         response = self.client.post(url, data)
         response = self.client.post(url, data)
@@ -308,7 +308,7 @@ class AuthenticatedDynDomainTests(APITestCase):
 
 
         outboxlen = len(mail.outbox)
         outboxlen = len(mail.outbox)
 
 
-        url = reverse('domain-list')
+        url = reverse('v1:domain-list')
         for i in range(settings.LIMIT_USER_DOMAIN_COUNT_DEFAULT-2):
         for i in range(settings.LIMIT_USER_DOMAIN_COUNT_DEFAULT-2):
             name = utils.generateDynDomainname()
             name = utils.generateDynDomainname()
 
 
@@ -352,7 +352,7 @@ class AuthenticatedDynDomainTests(APITestCase):
             'at@sign.com',
             'at@sign.com',
         ]
         ]
 
 
-        url = reverse('domain-list')
+        url = reverse('v1:domain-list')
         for domainname in invalidnames:
         for domainname in invalidnames:
             data = {'name': domainname}
             data = {'name': domainname}
             response = self.client.post(url, data)
             response = self.client.post(url, data)

+ 6 - 10
api/desecapi/tests/testdonations.py

@@ -1,35 +1,31 @@
 # coding: utf-8
 # coding: utf-8
-from django.urls import reverse
+from rest_framework.reverse import reverse
 from rest_framework import status
 from rest_framework import status
 from rest_framework.test import APITestCase
 from rest_framework.test import APITestCase
-from .utils import utils
-from django.db import transaction
-from desecapi.models import Domain
+from desecapi.tests.utils import utils
 from django.core import mail
 from django.core import mail
-import httpretty
-from django.conf import settings
 
 
 
 
 class UnsuccessfulDonationTests(APITestCase):
 class UnsuccessfulDonationTests(APITestCase):
     def testExpectUnauthorizedOnGet(self):
     def testExpectUnauthorizedOnGet(self):
-        url = reverse('donation')
+        url = reverse('v1:donation')
         response = self.client.get(url, format='json')
         response = self.client.get(url, format='json')
         self.assertEqual(response.status_code, status.HTTP_405_METHOD_NOT_ALLOWED)
         self.assertEqual(response.status_code, status.HTTP_405_METHOD_NOT_ALLOWED)
 
 
     def testExpectUnauthorizedOnPut(self):
     def testExpectUnauthorizedOnPut(self):
-        url = reverse('donation')
+        url = reverse('v1:donation')
         response = self.client.put(url, format='json')
         response = self.client.put(url, format='json')
         self.assertEqual(response.status_code, status.HTTP_405_METHOD_NOT_ALLOWED)
         self.assertEqual(response.status_code, status.HTTP_405_METHOD_NOT_ALLOWED)
 
 
     def testExpectUnauthorizedOnDelete(self):
     def testExpectUnauthorizedOnDelete(self):
-        url = reverse('donation')
+        url = reverse('v1:donation')
         response = self.client.delete(url, format='json')
         response = self.client.delete(url, format='json')
         self.assertEqual(response.status_code, status.HTTP_405_METHOD_NOT_ALLOWED)
         self.assertEqual(response.status_code, status.HTTP_405_METHOD_NOT_ALLOWED)
 
 
 
 
 class SuccessfulDonationTests(APITestCase):
 class SuccessfulDonationTests(APITestCase):
     def testCanPostDonations(self):
     def testCanPostDonations(self):
-        url = reverse('donation')
+        url = reverse('v1:donation')
         data = \
         data = \
             {
             {
                 'name': 'Komplizierter Vörnämü-ßßß 马大为',
                 'name': 'Komplizierter Vörnämü-ßßß 马大为',

+ 22 - 22
api/desecapi/tests/testdyndns12update.py

@@ -1,7 +1,7 @@
-from django.urls import reverse
+from rest_framework.reverse import reverse
 from rest_framework import status
 from rest_framework import status
 from rest_framework.test import APITestCase
 from rest_framework.test import APITestCase
-from .utils import utils
+from desecapi.tests.utils import utils
 import base64
 import base64
 import httpretty
 import httpretty
 from django.conf import settings
 from django.conf import settings
@@ -22,7 +22,7 @@ class DynDNS12UpdateTest(APITestCase):
         self.domain = utils.generateDynDomainname()
         self.domain = utils.generateDynDomainname()
         self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token)
         self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token)
 
 
-        url = reverse('domain-list')
+        url = reverse('v1:domain-list')
         data = {'name': self.domain}
         data = {'name': self.domain}
         response = self.client.post(url, data)
         response = self.client.post(url, data)
         self.assertEqual(response.status_code, status.HTTP_201_CREATED)
         self.assertEqual(response.status_code, status.HTTP_201_CREATED)
@@ -59,7 +59,7 @@ class DynDNS12UpdateTest(APITestCase):
         name = name or self.username
         name = name or self.username
 
 
         def verify_response(type_, ip):
         def verify_response(type_, ip):
-            url = reverse('rrset', args=(name, '', type_,))
+            url = reverse('v1:rrset', args=(name, '', type_,))
             response = self.client.get(url)
             response = self.client.get(url)
 
 
             if ip is not None:
             if ip is not None:
@@ -76,7 +76,7 @@ class DynDNS12UpdateTest(APITestCase):
 
 
     def testDynDNS1UpdateDDClientSuccess(self):
     def testDynDNS1UpdateDDClientSuccess(self):
         # /nic/dyndns?action=edit&started=1&hostname=YES&host_id=foobar.dedyn.io&myip=10.1.2.3
         # /nic/dyndns?action=edit&started=1&hostname=YES&host_id=foobar.dedyn.io&myip=10.1.2.3
-        url = reverse('dyndns12update')
+        url = reverse('v1:dyndns12update')
         response = self.client.get(url,
         response = self.client.get(url,
                                    {
                                    {
                                        'action': 'edit',
                                        'action': 'edit',
@@ -91,7 +91,7 @@ class DynDNS12UpdateTest(APITestCase):
 
 
     def testDynDNS1UpdateDDClientIPv6Success(self):
     def testDynDNS1UpdateDDClientIPv6Success(self):
         # /nic/dyndns?action=edit&started=1&hostname=YES&host_id=foobar.dedyn.io&myipv6=::1337
         # /nic/dyndns?action=edit&started=1&hostname=YES&host_id=foobar.dedyn.io&myipv6=::1337
-        url = reverse('dyndns12update')
+        url = reverse('v1:dyndns12update')
         response = self.client.get(url,
         response = self.client.get(url,
                                    {
                                    {
                                        'action': 'edit',
                                        'action': 'edit',
@@ -106,7 +106,7 @@ class DynDNS12UpdateTest(APITestCase):
 
 
     def testDynDNS2UpdateDDClientIPv4Success(self):
     def testDynDNS2UpdateDDClientIPv4Success(self):
         #/nic/update?system=dyndns&hostname=foobar.dedyn.io&myip=10.2.3.4
         #/nic/update?system=dyndns&hostname=foobar.dedyn.io&myip=10.2.3.4
-        url = reverse('dyndns12update')
+        url = reverse('v1:dyndns12update')
         response = self.client.get(url,
         response = self.client.get(url,
                                    {
                                    {
                                        'system': 'dyndns',
                                        'system': 'dyndns',
@@ -119,7 +119,7 @@ class DynDNS12UpdateTest(APITestCase):
 
 
     def testDynDNS2UpdateDDClientIPv6Success(self):
     def testDynDNS2UpdateDDClientIPv6Success(self):
         #/nic/update?system=dyndns&hostname=foobar.dedyn.io&myipv6=::1338
         #/nic/update?system=dyndns&hostname=foobar.dedyn.io&myipv6=::1338
-        url = reverse('dyndns12update')
+        url = reverse('v1:dyndns12update')
         response = self.client.get(url,
         response = self.client.get(url,
                                    {
                                    {
                                        'system': 'dyndns',
                                        'system': 'dyndns',
@@ -132,14 +132,14 @@ class DynDNS12UpdateTest(APITestCase):
 
 
     def testFritzBox(self):
     def testFritzBox(self):
         #/
         #/
-        url = reverse('dyndns12update')
+        url = reverse('v1:dyndns12update')
         response = self.client.get(url)
         response = self.client.get(url)
         self.assertEqual(response.status_code, status.HTTP_200_OK)
         self.assertEqual(response.status_code, status.HTTP_200_OK)
         self.assertEqual(response.data, 'good')
         self.assertEqual(response.data, 'good')
         self.assertIP(ipv4='127.0.0.1')
         self.assertIP(ipv4='127.0.0.1')
 
 
     def testUnsetIP(self):
     def testUnsetIP(self):
-        url = reverse('dyndns12update')
+        url = reverse('v1:dyndns12update')
 
 
         def testVariant(params, **kwargs):
         def testVariant(params, **kwargs):
             response = self.client.get(url, params)
             response = self.client.get(url, params)
@@ -168,25 +168,25 @@ class DynDNS12UpdateTest(APITestCase):
         httpretty.register_uri(httpretty.PUT, settings.NSLORD_PDNS_API + '/zones/' + name + './notify', status=200)
         httpretty.register_uri(httpretty.PUT, settings.NSLORD_PDNS_API + '/zones/' + name + './notify', status=200)
 
 
         self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token)
         self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token)
-        url = reverse('domain-list')
+        url = reverse('v1:domain-list')
         response = self.client.post(url, {'name': name})
         response = self.client.post(url, {'name': name})
         self.assertEqual(response.status_code, status.HTTP_201_CREATED)
         self.assertEqual(response.status_code, status.HTTP_201_CREATED)
 
 
         self.client.credentials(HTTP_AUTHORIZATION='Basic ' + base64.b64encode((self.username + ':' + self.password).encode()).decode())
         self.client.credentials(HTTP_AUTHORIZATION='Basic ' + base64.b64encode((self.username + ':' + self.password).encode()).decode())
-        url = reverse('dyndns12update')
+        url = reverse('v1:dyndns12update')
         response = self.client.get(url, REMOTE_ADDR='10.5.5.5')
         response = self.client.get(url, REMOTE_ADDR='10.5.5.5')
         self.assertEqual(response.status_code, status.HTTP_200_OK)
         self.assertEqual(response.status_code, status.HTTP_200_OK)
         self.assertEqual(response.data, 'good')
         self.assertEqual(response.data, 'good')
         self.assertIP(ipv4='10.5.5.5')
         self.assertIP(ipv4='10.5.5.5')
 
 
         self.client.credentials(HTTP_AUTHORIZATION='Basic ' + base64.b64encode((self.username + '.invalid:' + self.password).encode()).decode())
         self.client.credentials(HTTP_AUTHORIZATION='Basic ' + base64.b64encode((self.username + '.invalid:' + self.password).encode()).decode())
-        url = reverse('dyndns12update')
+        url = reverse('v1:dyndns12update')
         response = self.client.get(url, REMOTE_ADDR='10.5.5.5')
         response = self.client.get(url, REMOTE_ADDR='10.5.5.5')
         self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
         self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
 
 
     def testIdentificationByTokenWithEmptyUser(self):
     def testIdentificationByTokenWithEmptyUser(self):
         self.client.credentials(HTTP_AUTHORIZATION='Basic ' + base64.b64encode((':' + self.password).encode()).decode())
         self.client.credentials(HTTP_AUTHORIZATION='Basic ' + base64.b64encode((':' + self.password).encode()).decode())
-        url = reverse('dyndns12update')
+        url = reverse('v1:dyndns12update')
         response = self.client.get(url, REMOTE_ADDR='10.5.5.6')
         response = self.client.get(url, REMOTE_ADDR='10.5.5.6')
         self.assertEqual(response.status_code, status.HTTP_200_OK)
         self.assertEqual(response.status_code, status.HTTP_200_OK)
         self.assertEqual(response.data, 'good')
         self.assertEqual(response.data, 'good')
@@ -207,18 +207,18 @@ class DynDNS12UpdateTest(APITestCase):
         httpretty.register_uri(httpretty.PUT, settings.NSLORD_PDNS_API + '/zones/' + name + './notify', status=200)
         httpretty.register_uri(httpretty.PUT, settings.NSLORD_PDNS_API + '/zones/' + name + './notify', status=200)
 
 
         self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token)
         self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token)
-        url = reverse('domain-list')
+        url = reverse('v1:domain-list')
         response = self.client.post(url, {'name': name})
         response = self.client.post(url, {'name': name})
         self.assertEqual(response.status_code, status.HTTP_201_CREATED)
         self.assertEqual(response.status_code, status.HTTP_201_CREATED)
 
 
-        url = reverse('dyndns12update')
+        url = reverse('v1:dyndns12update')
         response = self.client.get(url, REMOTE_ADDR='10.5.5.7')
         response = self.client.get(url, REMOTE_ADDR='10.5.5.7')
         self.assertEqual(response.status_code, status.HTTP_409_CONFLICT)
         self.assertEqual(response.status_code, status.HTTP_409_CONFLICT)
 
 
     def testManual(self):
     def testManual(self):
         #/update?username=foobar.dedyn.io&password=secret
         #/update?username=foobar.dedyn.io&password=secret
         self.client.credentials(HTTP_AUTHORIZATION='')
         self.client.credentials(HTTP_AUTHORIZATION='')
-        url = reverse('dyndns12update')
+        url = reverse('v1:dyndns12update')
         response = self.client.get(url,
         response = self.client.get(url,
                                    {
                                    {
                                        'username': self.username,
                                        'username': self.username,
@@ -232,7 +232,7 @@ class DynDNS12UpdateTest(APITestCase):
         # The dynamic update will try to set the TTL to 60. Here, we create
         # The dynamic update will try to set the TTL to 60. Here, we create
         # a record with a different TTL beforehand and then make sure that
         # a record with a different TTL beforehand and then make sure that
         # updates still work properly.
         # updates still work properly.
-        url = reverse('rrsets', args=(self.domain,))
+        url = reverse('v1:rrsets', args=(self.domain,))
         data = {'records': ['127.0.0.1'], 'ttl': 3600, 'type': 'A'}
         data = {'records': ['127.0.0.1'], 'ttl': 3600, 'type': 'A'}
         self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token)
         self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token)
         response = self.client.post(url, json.dumps(data),
         response = self.client.post(url, json.dumps(data),
@@ -241,7 +241,7 @@ class DynDNS12UpdateTest(APITestCase):
 
 
         self.httpretty_reset_uris()
         self.httpretty_reset_uris()
 
 
-        url = reverse('dyndns12update')
+        url = reverse('v1:dyndns12update')
         self.client.credentials(HTTP_AUTHORIZATION='Basic ' + base64.b64encode((self.username + ':' + self.password).encode()).decode())
         self.client.credentials(HTTP_AUTHORIZATION='Basic ' + base64.b64encode((self.username + ':' + self.password).encode()).decode())
         response = self.client.get(url)
         response = self.client.get(url)
         self.assertEqual(httpretty.httpretty.latest_requests[-2].method, 'PATCH')
         self.assertEqual(httpretty.httpretty.latest_requests[-2].method, 'PATCH')
@@ -254,7 +254,7 @@ class DynDNS12UpdateTest(APITestCase):
         self.owner.locked = timezone.now()
         self.owner.locked = timezone.now()
         self.owner.save()
         self.owner.save()
 
 
-        url = reverse('dyndns12update')
+        url = reverse('v1:dyndns12update')
         response = self.client.get(url,
         response = self.client.get(url,
                                    {
                                    {
                                        'system': 'dyndns',
                                        'system': 'dyndns',
@@ -289,7 +289,7 @@ class DynDNS12UpdateTest(APITestCase):
         self.owner.save()
         self.owner.save()
 
 
         # While in locked state, create a domain and set some records
         # While in locked state, create a domain and set some records
-        url = reverse('domain-list')
+        url = reverse('v1:domain-list')
         newdomain = utils.generateDynDomainname()
         newdomain = utils.generateDynDomainname()
 
 
         data = {'name': newdomain}
         data = {'name': newdomain}
@@ -301,7 +301,7 @@ class DynDNS12UpdateTest(APITestCase):
         response = self.client.post(url, data)
         response = self.client.post(url, data)
         self.assertEqual(response.status_code, status.HTTP_201_CREATED)
         self.assertEqual(response.status_code, status.HTTP_201_CREATED)
 
 
-        url = reverse('dyndns12update')
+        url = reverse('v1:dyndns12update')
         response = self.client.get(url,
         response = self.client.get(url,
                                    {
                                    {
                                        'system': 'dyndns',
                                        'system': 'dyndns',

+ 4 - 4
api/desecapi/tests/testdynupdateauthentication.py

@@ -1,7 +1,7 @@
-from django.urls import reverse
+from rest_framework.reverse import reverse
 from rest_framework import status
 from rest_framework import status
 from rest_framework.test import APITestCase
 from rest_framework.test import APITestCase
-from .utils import utils
+from desecapi.tests.utils import utils
 import httpretty
 import httpretty
 import base64
 import base64
 from django.conf import settings
 from django.conf import settings
@@ -20,11 +20,11 @@ class DynUpdateAuthenticationTests(APITestCase):
             self.user = utils.createUser(self.username, self.password)
             self.user = utils.createUser(self.username, self.password)
             self.token = utils.createToken(user=self.user)
             self.token = utils.createToken(user=self.user)
             self.setCredentials(self.username, self.password)
             self.setCredentials(self.username, self.password)
-            self.url = reverse('dyndns12update')
+            self.url = reverse('v1:dyndns12update')
 
 
             self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token)
             self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token)
             self.domain = utils.generateDynDomainname()
             self.domain = utils.generateDynDomainname()
-            url = reverse('domain-list')
+            url = reverse('v1:domain-list')
             data = {'name': self.domain}
             data = {'name': self.domain}
             response = self.client.post(url, data)
             response = self.client.post(url, data)
             self.assertEqual(response.status_code, status.HTTP_201_CREATED)
             self.assertEqual(response.status_code, status.HTTP_201_CREATED)

+ 23 - 17
api/desecapi/tests/testregistration.py

@@ -1,7 +1,10 @@
-from django.urls import reverse
+from django.test import RequestFactory
+from rest_framework.reverse import reverse
 from rest_framework import status
 from rest_framework import status
 from rest_framework.test import APITestCase
 from rest_framework.test import APITestCase
-from .utils import utils
+from rest_framework.versioning import NamespaceVersioning
+
+from desecapi.tests.utils import utils
 from desecapi import models
 from desecapi import models
 from datetime import timedelta
 from datetime import timedelta
 from django.utils import timezone
 from django.utils import timezone
@@ -13,7 +16,7 @@ from api import settings
 class RegistrationTest(APITestCase):
 class RegistrationTest(APITestCase):
 
 
     def test_registration_successful(self):
     def test_registration_successful(self):
-        url = reverse('register')
+        url = reverse('v1:register')
         data = {'email': utils.generateUsername(), 'password': utils.generateRandomString(size=12)}
         data = {'email': utils.generateUsername(), 'password': utils.generateRandomString(size=12)}
         response = self.client.post(url, data, REMOTE_ADDR="1.3.3.7")
         response = self.client.post(url, data, REMOTE_ADDR="1.3.3.7")
         self.assertEqual(response.status_code, status.HTTP_201_CREATED)
         self.assertEqual(response.status_code, status.HTTP_201_CREATED)
@@ -24,7 +27,7 @@ class RegistrationTest(APITestCase):
     def test_multiple_registration_locked_same_ip_short_time(self):
     def test_multiple_registration_locked_same_ip_short_time(self):
         outboxlen = len(mail.outbox)
         outboxlen = len(mail.outbox)
 
 
-        url = reverse('register')
+        url = reverse('v1:register')
         data = {'email': utils.generateUsername(),
         data = {'email': utils.generateUsername(),
                 'password': utils.generateRandomString(size=12), 'dyn': True}
                 'password': utils.generateRandomString(size=12), 'dyn': True}
         response = self.client.post(url, data, REMOTE_ADDR="1.3.3.7")
         response = self.client.post(url, data, REMOTE_ADDR="1.3.3.7")
@@ -36,7 +39,7 @@ class RegistrationTest(APITestCase):
 
 
         self.assertEqual(len(mail.outbox), outboxlen)
         self.assertEqual(len(mail.outbox), outboxlen)
 
 
-        url = reverse('register')
+        url = reverse('v1:register')
         data = {'email': utils.generateUsername(),
         data = {'email': utils.generateUsername(),
                 'password': utils.generateRandomString(size=12), 'dyn': True}
                 'password': utils.generateRandomString(size=12), 'dyn': True}
         response = self.client.post(url, data, REMOTE_ADDR="1.3.3.7")
         response = self.client.post(url, data, REMOTE_ADDR="1.3.3.7")
@@ -48,7 +51,7 @@ class RegistrationTest(APITestCase):
 
 
         self.assertEqual(len(mail.outbox), outboxlen + 1)
         self.assertEqual(len(mail.outbox), outboxlen + 1)
 
 
-        url = reverse('register')
+        url = reverse('v1:register')
         data = {'email': utils.generateUsername(),
         data = {'email': utils.generateUsername(),
                 'password': utils.generateRandomString(size=12), 'dyn': True}
                 'password': utils.generateRandomString(size=12), 'dyn': True}
         response = self.client.post(url, data, REMOTE_ADDR="1.3.3.7")
         response = self.client.post(url, data, REMOTE_ADDR="1.3.3.7")
@@ -61,7 +64,7 @@ class RegistrationTest(APITestCase):
         self.assertEqual(len(mail.outbox), outboxlen + 2)
         self.assertEqual(len(mail.outbox), outboxlen + 2)
 
 
     def test_multiple_registration_not_locked_different_ip(self):
     def test_multiple_registration_not_locked_different_ip(self):
-        url = reverse('register')
+        url = reverse('v1:register')
         data = {'email': utils.generateUsername(), 'password': utils.generateRandomString(size=12)}
         data = {'email': utils.generateUsername(), 'password': utils.generateRandomString(size=12)}
         response = self.client.post(url, data, REMOTE_ADDR="1.3.3.8")
         response = self.client.post(url, data, REMOTE_ADDR="1.3.3.8")
         self.assertEqual(response.status_code, status.HTTP_201_CREATED)
         self.assertEqual(response.status_code, status.HTTP_201_CREATED)
@@ -70,7 +73,7 @@ class RegistrationTest(APITestCase):
         self.assertEqual(user.registration_remote_ip, "1.3.3.8")
         self.assertEqual(user.registration_remote_ip, "1.3.3.8")
         self.assertIsNone(user.locked)
         self.assertIsNone(user.locked)
 
 
-        url = reverse('register')
+        url = reverse('v1:register')
         data = {'email': utils.generateUsername(), 'password': utils.generateRandomString(size=12)}
         data = {'email': utils.generateUsername(), 'password': utils.generateRandomString(size=12)}
         response = self.client.post(url, data, REMOTE_ADDR="1.3.3.9")
         response = self.client.post(url, data, REMOTE_ADDR="1.3.3.9")
         self.assertEqual(response.status_code, status.HTTP_201_CREATED)
         self.assertEqual(response.status_code, status.HTTP_201_CREATED)
@@ -80,7 +83,7 @@ class RegistrationTest(APITestCase):
         self.assertIsNone(user.locked)
         self.assertIsNone(user.locked)
 
 
     def test_multiple_registration_not_locked_same_ip_long_time(self):
     def test_multiple_registration_not_locked_same_ip_long_time(self):
-        url = reverse('register')
+        url = reverse('v1:register')
         data = {'email': utils.generateUsername(), 'password': utils.generateRandomString(size=12)}
         data = {'email': utils.generateUsername(), 'password': utils.generateRandomString(size=12)}
         response = self.client.post(url, data, REMOTE_ADDR="1.3.3.10")
         response = self.client.post(url, data, REMOTE_ADDR="1.3.3.10")
         self.assertEqual(response.status_code, status.HTTP_201_CREATED)
         self.assertEqual(response.status_code, status.HTTP_201_CREATED)
@@ -93,7 +96,7 @@ class RegistrationTest(APITestCase):
         user.created = timezone.now() - timedelta(hours=settings.ABUSE_BY_REMOTE_IP_PERIOD_HRS+1)
         user.created = timezone.now() - timedelta(hours=settings.ABUSE_BY_REMOTE_IP_PERIOD_HRS+1)
         user.save()
         user.save()
 
 
-        url = reverse('register')
+        url = reverse('v1:register')
         data = {'email': utils.generateUsername(), 'password': utils.generateRandomString(size=12)}
         data = {'email': utils.generateUsername(), 'password': utils.generateRandomString(size=12)}
         response = self.client.post(url, data, REMOTE_ADDR="1.3.3.10")
         response = self.client.post(url, data, REMOTE_ADDR="1.3.3.10")
         self.assertEqual(response.status_code, status.HTTP_201_CREATED)
         self.assertEqual(response.status_code, status.HTTP_201_CREATED)
@@ -105,20 +108,23 @@ class RegistrationTest(APITestCase):
     def test_send_captcha_email_manually(self):
     def test_send_captcha_email_manually(self):
         outboxlen = len(mail.outbox)
         outboxlen = len(mail.outbox)
 
 
-        url = reverse('register')
+        url = reverse('v1:register')
         data = {'email': utils.generateUsername(),
         data = {'email': utils.generateUsername(),
                 'password': utils.generateRandomString(size=12), 'dyn': True}
                 'password': utils.generateRandomString(size=12), 'dyn': True}
         response = self.client.post(url, data, REMOTE_ADDR="1.3.3.10")
         response = self.client.post(url, data, REMOTE_ADDR="1.3.3.10")
         self.assertEqual(response.status_code, status.HTTP_201_CREATED)
         self.assertEqual(response.status_code, status.HTTP_201_CREATED)
         user = models.User.objects.get(email=data['email'])
         user = models.User.objects.get(email=data['email'])
-        send_account_lock_email(None, user)
+        r = RequestFactory().request(HTTP_HOST=settings.ALLOWED_HOSTS[0])
+        r.version = 'v1'
+        r.versioning_scheme = NamespaceVersioning()
+        send_account_lock_email(r, user)
 
 
         self.assertEqual(len(mail.outbox), outboxlen+1)
         self.assertEqual(len(mail.outbox), outboxlen+1)
 
 
     def test_multiple_registration_locked_same_email_host(self):
     def test_multiple_registration_locked_same_email_host(self):
         outboxlen = len(mail.outbox)
         outboxlen = len(mail.outbox)
 
 
-        url = reverse('register')
+        url = reverse('v1:register')
         for i in range(settings.ABUSE_BY_EMAIL_HOSTNAME_LIMIT):
         for i in range(settings.ABUSE_BY_EMAIL_HOSTNAME_LIMIT):
             data = {
             data = {
                 'email': utils.generateRandomString() + '@test-same-email.desec.io',
                 'email': utils.generateRandomString() + '@test-same-email.desec.io',
@@ -133,7 +139,7 @@ class RegistrationTest(APITestCase):
 
 
         self.assertEqual(len(mail.outbox), outboxlen)
         self.assertEqual(len(mail.outbox), outboxlen)
 
 
-        url = reverse('register')
+        url = reverse('v1:register')
         data = {
         data = {
             'email': utils.generateRandomString() + '@test-same-email.desec.io',
             'email': utils.generateRandomString() + '@test-same-email.desec.io',
             'password': utils.generateRandomString(size=12),
             'password': utils.generateRandomString(size=12),
@@ -150,7 +156,7 @@ class RegistrationTest(APITestCase):
     def test_multiple_registration_not_locked_same_email_host_long_time(self):
     def test_multiple_registration_not_locked_same_email_host_long_time(self):
         outboxlen = len(mail.outbox)
         outboxlen = len(mail.outbox)
 
 
-        url = reverse('register')
+        url = reverse('v1:register')
         for i in range(settings.ABUSE_BY_EMAIL_HOSTNAME_LIMIT):
         for i in range(settings.ABUSE_BY_EMAIL_HOSTNAME_LIMIT):
             data = {
             data = {
                 'email': utils.generateRandomString() + '@test-same-email-1.desec.io',
                 'email': utils.generateRandomString() + '@test-same-email-1.desec.io',
@@ -170,7 +176,7 @@ class RegistrationTest(APITestCase):
 
 
         self.assertEqual(len(mail.outbox), outboxlen)
         self.assertEqual(len(mail.outbox), outboxlen)
 
 
-        url = reverse('register')
+        url = reverse('v1:register')
         data = {
         data = {
             'email': utils.generateRandomString() + '@test-same-email-1.desec.io',
             'email': utils.generateRandomString() + '@test-same-email-1.desec.io',
             'password': utils.generateRandomString(size=12),
             'password': utils.generateRandomString(size=12),
@@ -187,7 +193,7 @@ class RegistrationTest(APITestCase):
     def test_token_email(self):
     def test_token_email(self):
         outboxlen = len(mail.outbox)
         outboxlen = len(mail.outbox)
 
 
-        url = reverse('register')
+        url = reverse('v1:register')
         data = {
         data = {
             'email': utils.generateRandomString() + '@test-same-email.desec.io',
             'email': utils.generateRandomString() + '@test-same-email.desec.io',
             'password': utils.generateRandomString(size=12),
             'password': utils.generateRandomString(size=12),

+ 67 - 67
api/desecapi/tests/testrrsets.py

@@ -1,7 +1,7 @@
-from django.urls import reverse
+from rest_framework.reverse import reverse
 from rest_framework import status
 from rest_framework import status
 from rest_framework.test import APITestCase
 from rest_framework.test import APITestCase
-from .utils import utils
+from desecapi.tests.utils import utils
 import httpretty
 import httpretty
 from django.conf import settings
 from django.conf import settings
 import json
 import json
@@ -11,22 +11,22 @@ from django.utils import timezone
 
 
 class UnauthenticatedDomainTests(APITestCase):
 class UnauthenticatedDomainTests(APITestCase):
     def testExpectUnauthorizedOnGet(self):
     def testExpectUnauthorizedOnGet(self):
-        url = reverse('rrsets', args=('example.com',))
+        url = reverse('v1:rrsets', args=('example.com',))
         response = self.client.get(url, format='json')
         response = self.client.get(url, format='json')
         self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
         self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
 
 
     def testExpectUnauthorizedOnPost(self):
     def testExpectUnauthorizedOnPost(self):
-        url = reverse('rrsets', args=('example.com',))
+        url = reverse('v1:rrsets', args=('example.com',))
         response = self.client.post(url, format='json')
         response = self.client.post(url, format='json')
         self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
         self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
 
 
     def testExpectUnauthorizedOnPut(self):
     def testExpectUnauthorizedOnPut(self):
-        url = reverse('rrsets', args=('example.com',))
+        url = reverse('v1:rrsets', args=('example.com',))
         response = self.client.put(url, format='json')
         response = self.client.put(url, format='json')
         self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
         self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
 
 
     def testExpectUnauthorizedOnDelete(self):
     def testExpectUnauthorizedOnDelete(self):
-        url = reverse('rrsets', args=('example.com',))
+        url = reverse('v1:rrsets', args=('example.com',))
         response = self.client.delete(url, format='json')
         response = self.client.delete(url, format='json')
         self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
         self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
 
 
@@ -50,24 +50,24 @@ class AuthenticatedRRsetTests(APITestCase):
             self.otherToken = utils.createToken(user=self.otherOwner)
             self.otherToken = utils.createToken(user=self.otherOwner)
 
 
     def testCanGetOwnRRsets(self):
     def testCanGetOwnRRsets(self):
-        url = reverse('rrsets', args=(self.ownedDomains[1].name,))
+        url = reverse('v1:rrsets', args=(self.ownedDomains[1].name,))
         response = self.client.get(url)
         response = self.client.get(url)
         self.assertEqual(response.status_code, status.HTTP_200_OK)
         self.assertEqual(response.status_code, status.HTTP_200_OK)
         self.assertEqual(len(response.data), 1) # don't forget NS RRset
         self.assertEqual(len(response.data), 1) # don't forget NS RRset
 
 
     def testCantGetForeignRRsets(self):
     def testCantGetForeignRRsets(self):
-        url = reverse('rrsets', args=(self.otherDomains[1].name,))
+        url = reverse('v1:rrsets', args=(self.otherDomains[1].name,))
         response = self.client.get(url)
         response = self.client.get(url)
         self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
         self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
 
 
     def testCanGetOwnRRsetsEmptySubname(self):
     def testCanGetOwnRRsetsEmptySubname(self):
-        url = reverse('rrsets', args=(self.ownedDomains[1].name,))
+        url = reverse('v1:rrsets', args=(self.ownedDomains[1].name,))
         response = self.client.get(url + '?subname=')
         response = self.client.get(url + '?subname=')
         self.assertEqual(response.status_code, status.HTTP_200_OK)
         self.assertEqual(response.status_code, status.HTTP_200_OK)
         self.assertEqual(len(response.data), 1) # don't forget NS RRset
         self.assertEqual(len(response.data), 1) # don't forget NS RRset
 
 
     def testCanGetOwnRRsetsFromSubname(self):
     def testCanGetOwnRRsetsFromSubname(self):
-        url = reverse('rrsets', args=(self.ownedDomains[1].name,))
+        url = reverse('v1:rrsets', args=(self.ownedDomains[1].name,))
 
 
         data = {'records': ['1.2.3.4'], 'ttl': 120, 'type': 'A'}
         data = {'records': ['1.2.3.4'], 'ttl': 120, 'type': 'A'}
         response = self.client.post(url, json.dumps(data), content_type='application/json')
         response = self.client.post(url, json.dumps(data), content_type='application/json')
@@ -90,12 +90,12 @@ class AuthenticatedRRsetTests(APITestCase):
         self.assertEqual(len(response.data), 2)
         self.assertEqual(len(response.data), 2)
 
 
     def testCantGetForeignRRsetsFromSubname(self):
     def testCantGetForeignRRsetsFromSubname(self):
-        url = reverse('rrsets', args=(self.otherDomains[1].name,))
+        url = reverse('v1:rrsets', args=(self.otherDomains[1].name,))
         response = self.client.get(url + '?subname=test')
         response = self.client.get(url + '?subname=test')
         self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
         self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
 
 
     def testCanGetOwnRRsetsFromType(self):
     def testCanGetOwnRRsetsFromType(self):
-        url = reverse('rrsets', args=(self.ownedDomains[1].name,))
+        url = reverse('v1:rrsets', args=(self.ownedDomains[1].name,))
 
 
         data = {'records': ['1.2.3.4'], 'ttl': 120, 'type': 'A'}
         data = {'records': ['1.2.3.4'], 'ttl': 120, 'type': 'A'}
         response = self.client.post(url, json.dumps(data), content_type='application/json')
         response = self.client.post(url, json.dumps(data), content_type='application/json')
@@ -118,12 +118,12 @@ class AuthenticatedRRsetTests(APITestCase):
         self.assertEqual(len(response.data), 2)
         self.assertEqual(len(response.data), 2)
 
 
     def testCantGetForeignRRsetsFromType(self):
     def testCantGetForeignRRsetsFromType(self):
-        url = reverse('rrsets', args=(self.otherDomains[1].name,))
+        url = reverse('v1:rrsets', args=(self.otherDomains[1].name,))
         response = self.client.get(url + '?test=A')
         response = self.client.get(url + '?test=A')
         self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
         self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
 
 
     def testCanPostOwnRRsets(self):
     def testCanPostOwnRRsets(self):
-        url = reverse('rrsets', args=(self.ownedDomains[1].name,))
+        url = reverse('v1:rrsets', args=(self.ownedDomains[1].name,))
         data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
         data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
         response = self.client.post(url, json.dumps(data), content_type='application/json')
         response = self.client.post(url, json.dumps(data), content_type='application/json')
         self.assertEqual(response.status_code, status.HTTP_201_CREATED)
         self.assertEqual(response.status_code, status.HTTP_201_CREATED)
@@ -132,18 +132,18 @@ class AuthenticatedRRsetTests(APITestCase):
         self.assertEqual(response.status_code, status.HTTP_200_OK)
         self.assertEqual(response.status_code, status.HTTP_200_OK)
         self.assertEqual(len(response.data), 1 + 1) # don't forget NS RRset
         self.assertEqual(len(response.data), 1 + 1) # don't forget NS RRset
 
 
-        url = reverse('rrset', args=(self.ownedDomains[1].name, '', 'A',))
+        url = reverse('v1:rrset', args=(self.ownedDomains[1].name, '', 'A',))
         response = self.client.get(url)
         response = self.client.get(url)
         self.assertEqual(response.status_code, status.HTTP_200_OK)
         self.assertEqual(response.status_code, status.HTTP_200_OK)
         self.assertEqual(response.data['records'][0], '1.2.3.4')
         self.assertEqual(response.data['records'][0], '1.2.3.4')
 
 
-        url = reverse('rrsets', args=(self.ownedDomains[1].name,))
+        url = reverse('v1:rrsets', args=(self.ownedDomains[1].name,))
         data = {'records': ['desec.io.'], 'ttl': 900, 'type': 'PTR'}
         data = {'records': ['desec.io.'], 'ttl': 900, 'type': 'PTR'}
         response = self.client.post(url, json.dumps(data), content_type='application/json')
         response = self.client.post(url, json.dumps(data), content_type='application/json')
         self.assertEqual(response.status_code, status.HTTP_201_CREATED)
         self.assertEqual(response.status_code, status.HTTP_201_CREATED)
 
 
     def testCantPostEmptyRRset(self):
     def testCantPostEmptyRRset(self):
-        url = reverse('rrsets', args=(self.ownedDomains[1].name,))
+        url = reverse('v1:rrsets', args=(self.ownedDomains[1].name,))
         data = {'records': [], 'ttl': 60, 'type': 'A'}
         data = {'records': [], 'ttl': 60, 'type': 'A'}
         response = self.client.post(url, json.dumps(data), content_type='application/json')
         response = self.client.post(url, json.dumps(data), content_type='application/json')
         self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
         self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
@@ -154,37 +154,37 @@ class AuthenticatedRRsetTests(APITestCase):
 
 
     def testCantPostDeadTypes(self):
     def testCantPostDeadTypes(self):
         for type_ in self.dead_types:
         for type_ in self.dead_types:
-            url = reverse('rrsets', args=(self.ownedDomains[1].name,))
+            url = reverse('v1:rrsets', args=(self.ownedDomains[1].name,))
             data = {'records': ['www.example.com.'], 'ttl': 60, 'type': type_}
             data = {'records': ['www.example.com.'], 'ttl': 60, 'type': type_}
             response = self.client.post(url, json.dumps(data), content_type='application/json')
             response = self.client.post(url, json.dumps(data), content_type='application/json')
             self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
             self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
 
 
     def testCantPostRestrictedTypes(self):
     def testCantPostRestrictedTypes(self):
         for type_ in self.restricted_types:
         for type_ in self.restricted_types:
-            url = reverse('rrsets', args=(self.ownedDomains[1].name,))
+            url = reverse('v1:rrsets', args=(self.ownedDomains[1].name,))
             data = {'records': ['ns1.desec.io. peter.desec.io. 2584 10800 3600 604800 60'], 'ttl': 60, 'type': type_}
             data = {'records': ['ns1.desec.io. peter.desec.io. 2584 10800 3600 604800 60'], 'ttl': 60, 'type': type_}
             response = self.client.post(url, json.dumps(data), content_type='application/json')
             response = self.client.post(url, json.dumps(data), content_type='application/json')
             self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
             self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
 
 
     def testCantPostForeignRRsets(self):
     def testCantPostForeignRRsets(self):
-        url = reverse('rrsets', args=(self.otherDomains[1].name,))
+        url = reverse('v1:rrsets', args=(self.otherDomains[1].name,))
         data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
         data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
         response = self.client.post(url, json.dumps(data), content_type='application/json')
         response = self.client.post(url, json.dumps(data), content_type='application/json')
         self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
         self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
 
 
     def testCantPostTwiceRRsets(self):
     def testCantPostTwiceRRsets(self):
-        url = reverse('rrsets', args=(self.ownedDomains[1].name,))
+        url = reverse('v1:rrsets', args=(self.ownedDomains[1].name,))
         data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
         data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
         response = self.client.post(url, json.dumps(data), content_type='application/json')
         response = self.client.post(url, json.dumps(data), content_type='application/json')
         self.assertEqual(response.status_code, status.HTTP_201_CREATED)
         self.assertEqual(response.status_code, status.HTTP_201_CREATED)
 
 
-        url = reverse('rrsets', args=(self.ownedDomains[1].name,))
+        url = reverse('v1:rrsets', args=(self.ownedDomains[1].name,))
         data = {'records': ['3.2.2.1'], 'ttl': 60, 'type': 'A'}
         data = {'records': ['3.2.2.1'], 'ttl': 60, 'type': 'A'}
         response = self.client.post(url, json.dumps(data), content_type='application/json')
         response = self.client.post(url, json.dumps(data), content_type='application/json')
         self.assertEqual(response.status_code, status.HTTP_409_CONFLICT)
         self.assertEqual(response.status_code, status.HTTP_409_CONFLICT)
 
 
     def testCantPostFaultyRRsets(self):
     def testCantPostFaultyRRsets(self):
-        url = reverse('rrsets', args=(self.ownedDomains[1].name,))
+        url = reverse('v1:rrsets', args=(self.ownedDomains[1].name,))
 
 
         # New record without a value is a syntactical error --> 400
         # New record without a value is a syntactical error --> 400
         data = {'records': [], 'ttl': 60, 'type': 'TXT'}
         data = {'records': [], 'ttl': 60, 'type': 'TXT'}
@@ -197,30 +197,30 @@ class AuthenticatedRRsetTests(APITestCase):
         self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
         self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
 
 
         # Unknown type is a semantical error --> 422
         # Unknown type is a semantical error --> 422
-        url = reverse('rrsets', args=(self.ownedDomains[1].name,))
+        url = reverse('v1:rrsets', args=(self.ownedDomains[1].name,))
         data = {'records': ['123456'], 'ttl': 60, 'type': 'AA'}
         data = {'records': ['123456'], 'ttl': 60, 'type': 'AA'}
         response = self.client.post(url, json.dumps(data), content_type='application/json')
         response = self.client.post(url, json.dumps(data), content_type='application/json')
         self.assertEqual(response.status_code, status.HTTP_422_UNPROCESSABLE_ENTITY)
         self.assertEqual(response.status_code, status.HTTP_422_UNPROCESSABLE_ENTITY)
 
 
     def testCanGetOwnRRset(self):
     def testCanGetOwnRRset(self):
-        url = reverse('rrsets', args=(self.ownedDomains[1].name,))
+        url = reverse('v1:rrsets', args=(self.ownedDomains[1].name,))
         data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
         data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
         response = self.client.post(url, json.dumps(data), content_type='application/json')
         response = self.client.post(url, json.dumps(data), content_type='application/json')
         self.assertEqual(response.status_code, status.HTTP_201_CREATED)
         self.assertEqual(response.status_code, status.HTTP_201_CREATED)
 
 
-        url = reverse('rrset', args=(self.ownedDomains[1].name, '', 'A',))
+        url = reverse('v1:rrset', args=(self.ownedDomains[1].name, '', 'A',))
         response = self.client.get(url)
         response = self.client.get(url)
         self.assertEqual(response.status_code, status.HTTP_200_OK)
         self.assertEqual(response.status_code, status.HTTP_200_OK)
         self.assertEqual(response.data['records'][0], '1.2.3.4')
         self.assertEqual(response.data['records'][0], '1.2.3.4')
         self.assertEqual(response.data['ttl'], 60)
         self.assertEqual(response.data['ttl'], 60)
 
 
     def testCanGetOwnRRsetApex(self):
     def testCanGetOwnRRsetApex(self):
-        url = reverse('rrsets', args=(self.ownedDomains[1].name,))
+        url = reverse('v1:rrsets', args=(self.ownedDomains[1].name,))
         data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
         data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
         response = self.client.post(url, json.dumps(data), content_type='application/json')
         response = self.client.post(url, json.dumps(data), content_type='application/json')
         self.assertEqual(response.status_code, status.HTTP_201_CREATED)
         self.assertEqual(response.status_code, status.HTTP_201_CREATED)
 
 
-        url = reverse('rrset@', args=(self.ownedDomains[1].name, '@', 'A',))
+        url = reverse('v1:rrset@', args=(self.ownedDomains[1].name, '@', 'A',))
         response = self.client.get(url)
         response = self.client.get(url)
         self.assertEqual(response.status_code, status.HTTP_200_OK)
         self.assertEqual(response.status_code, status.HTTP_200_OK)
         self.assertEqual(response.data['records'][0], '1.2.3.4')
         self.assertEqual(response.data['records'][0], '1.2.3.4')
@@ -228,28 +228,28 @@ class AuthenticatedRRsetTests(APITestCase):
 
 
     def testCantGetRestrictedTypes(self):
     def testCantGetRestrictedTypes(self):
         for type_ in self.restricted_types:
         for type_ in self.restricted_types:
-            url = reverse('rrsets', args=(self.ownedDomains[1].name,))
+            url = reverse('v1:rrsets', args=(self.ownedDomains[1].name,))
             response = self.client.get(url + '?type=%s' % type_)
             response = self.client.get(url + '?type=%s' % type_)
             self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
             self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
 
 
-            url = reverse('rrset', args=(self.ownedDomains[1].name, '', type_,))
+            url = reverse('v1:rrset', args=(self.ownedDomains[1].name, '', type_,))
             response = self.client.get(url)
             response = self.client.get(url)
             self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
             self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
 
 
     def testCantGetForeignRRset(self):
     def testCantGetForeignRRset(self):
         self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.otherToken)
         self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.otherToken)
-        url = reverse('rrsets', args=(self.otherDomains[0].name,))
+        url = reverse('v1:rrsets', args=(self.otherDomains[0].name,))
         data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
         data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
         response = self.client.post(url, json.dumps(data), content_type='application/json')
         response = self.client.post(url, json.dumps(data), content_type='application/json')
         self.assertEqual(response.status_code, status.HTTP_201_CREATED)
         self.assertEqual(response.status_code, status.HTTP_201_CREATED)
 
 
         self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token)
         self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token)
-        url = reverse('rrset', args=(self.otherDomains[0].name, '', 'A',))
+        url = reverse('v1:rrset', args=(self.otherDomains[0].name, '', 'A',))
         response = self.client.get(url)
         response = self.client.get(url)
         self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
         self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
 
 
     def testCanGetOwnRRsetWithSubname(self):
     def testCanGetOwnRRsetWithSubname(self):
-        url = reverse('rrsets', args=(self.ownedDomains[1].name,))
+        url = reverse('v1:rrsets', args=(self.ownedDomains[1].name,))
 
 
         data = {'records': ['1.2.3.4'], 'ttl': 120, 'type': 'A'}
         data = {'records': ['1.2.3.4'], 'ttl': 120, 'type': 'A'}
         response = self.client.post(url, json.dumps(data), content_type='application/json')
         response = self.client.post(url, json.dumps(data), content_type='application/json')
@@ -267,7 +267,7 @@ class AuthenticatedRRsetTests(APITestCase):
         self.assertEqual(response.status_code, status.HTTP_200_OK)
         self.assertEqual(response.status_code, status.HTTP_200_OK)
         self.assertEqual(len(response.data), 3 + 1) # don't forget NS RRset
         self.assertEqual(len(response.data), 3 + 1) # don't forget NS RRset
 
 
-        url = reverse('rrset', args=(self.ownedDomains[1].name, 'test', 'A',))
+        url = reverse('v1:rrset', args=(self.ownedDomains[1].name, 'test', 'A',))
         response = self.client.get(url)
         response = self.client.get(url)
         self.assertEqual(response.status_code, status.HTTP_200_OK)
         self.assertEqual(response.status_code, status.HTTP_200_OK)
         self.assertEqual(response.data['records'][0], '2.2.3.4')
         self.assertEqual(response.data['records'][0], '2.2.3.4')
@@ -276,7 +276,7 @@ class AuthenticatedRRsetTests(APITestCase):
 
 
     def testCanGetOwnRRsetWithWildcard(self):
     def testCanGetOwnRRsetWithWildcard(self):
         for subname in ('*', '*.foobar'):
         for subname in ('*', '*.foobar'):
-            url = reverse('rrsets', args=(self.ownedDomains[1].name,))
+            url = reverse('v1:rrsets', args=(self.ownedDomains[1].name,))
 
 
             data = {'records': ['"barfoo"'], 'ttl': 120, 'type': 'TXT', 'subname': subname}
             data = {'records': ['"barfoo"'], 'ttl': 120, 'type': 'TXT', 'subname': subname}
             response = self.client.post(url, json.dumps(data), content_type='application/json')
             response = self.client.post(url, json.dumps(data), content_type='application/json')
@@ -288,17 +288,17 @@ class AuthenticatedRRsetTests(APITestCase):
             self.assertEqual(response1.data[0]['ttl'], 120)
             self.assertEqual(response1.data[0]['ttl'], 120)
             self.assertEqual(response1.data[0]['name'], subname + '.' + self.ownedDomains[1].name + '.')
             self.assertEqual(response1.data[0]['name'], subname + '.' + self.ownedDomains[1].name + '.')
 
 
-            url = reverse('rrset', args=(self.ownedDomains[1].name, subname, 'TXT',))
+            url = reverse('v1:rrset', args=(self.ownedDomains[1].name, subname, 'TXT',))
             response2 = self.client.get(url)
             response2 = self.client.get(url)
             self.assertEqual(response2.data, response1.data[0])
             self.assertEqual(response2.data, response1.data[0])
 
 
     def testCanPutOwnRRset(self):
     def testCanPutOwnRRset(self):
-        url = reverse('rrsets', args=(self.ownedDomains[1].name,))
+        url = reverse('v1:rrsets', args=(self.ownedDomains[1].name,))
         data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
         data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
         response = self.client.post(url, json.dumps(data), content_type='application/json')
         response = self.client.post(url, json.dumps(data), content_type='application/json')
         self.assertEqual(response.status_code, status.HTTP_201_CREATED)
         self.assertEqual(response.status_code, status.HTTP_201_CREATED)
 
 
-        url = reverse('rrset', args=(self.ownedDomains[1].name, '', 'A',))
+        url = reverse('v1:rrset', args=(self.ownedDomains[1].name, '', 'A',))
 
 
         data = {'records': ['2.2.3.4'], 'ttl': 30}
         data = {'records': ['2.2.3.4'], 'ttl': 30}
         response = self.client.put(url, json.dumps(data), content_type='application/json')
         response = self.client.put(url, json.dumps(data), content_type='application/json')
@@ -318,12 +318,12 @@ class AuthenticatedRRsetTests(APITestCase):
         self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
         self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
 
 
     def testCanPutOwnRRsetApex(self):
     def testCanPutOwnRRsetApex(self):
-        url = reverse('rrsets', args=(self.ownedDomains[1].name,))
+        url = reverse('v1:rrsets', args=(self.ownedDomains[1].name,))
         data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
         data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
         response = self.client.post(url, json.dumps(data), content_type='application/json')
         response = self.client.post(url, json.dumps(data), content_type='application/json')
         self.assertEqual(response.status_code, status.HTTP_201_CREATED)
         self.assertEqual(response.status_code, status.HTTP_201_CREATED)
 
 
-        url = reverse('rrset@', args=(self.ownedDomains[1].name, '@', 'A',))
+        url = reverse('v1:rrset@', args=(self.ownedDomains[1].name, '@', 'A',))
 
 
         data = {'records': ['2.2.3.4'], 'ttl': 30}
         data = {'records': ['2.2.3.4'], 'ttl': 30}
         response = self.client.put(url, json.dumps(data), content_type='application/json')
         response = self.client.put(url, json.dumps(data), content_type='application/json')
@@ -343,13 +343,13 @@ class AuthenticatedRRsetTests(APITestCase):
         self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
         self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
 
 
     def testCanPatchOwnRRset(self):
     def testCanPatchOwnRRset(self):
-        url = reverse('rrsets', args=(self.ownedDomains[1].name,))
+        url = reverse('v1:rrsets', args=(self.ownedDomains[1].name,))
         data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
         data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
         response = self.client.post(url, json.dumps(data), content_type='application/json')
         response = self.client.post(url, json.dumps(data), content_type='application/json')
         self.assertEqual(response.status_code, status.HTTP_201_CREATED)
         self.assertEqual(response.status_code, status.HTTP_201_CREATED)
 
 
         # Change records and TTL
         # Change records and TTL
-        url = reverse('rrset', args=(self.ownedDomains[1].name, '', 'A',))
+        url = reverse('v1:rrset', args=(self.ownedDomains[1].name, '', 'A',))
         data = {'records': ['3.2.3.4'], 'ttl': 32}
         data = {'records': ['3.2.3.4'], 'ttl': 32}
         response = self.client.patch(url, json.dumps(data), content_type='application/json')
         response = self.client.patch(url, json.dumps(data), content_type='application/json')
         self.assertEqual(response.status_code, status.HTTP_200_OK)
         self.assertEqual(response.status_code, status.HTTP_200_OK)
@@ -381,13 +381,13 @@ class AuthenticatedRRsetTests(APITestCase):
         self.assertEqual(response.data['ttl'], 37)
         self.assertEqual(response.data['ttl'], 37)
 
 
     def testCanPatchOwnRRsetApex(self):
     def testCanPatchOwnRRsetApex(self):
-        url = reverse('rrsets', args=(self.ownedDomains[1].name,))
+        url = reverse('v1:rrsets', args=(self.ownedDomains[1].name,))
         data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
         data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
         response = self.client.post(url, json.dumps(data), content_type='application/json')
         response = self.client.post(url, json.dumps(data), content_type='application/json')
         self.assertEqual(response.status_code, status.HTTP_201_CREATED)
         self.assertEqual(response.status_code, status.HTTP_201_CREATED)
 
 
         # Change records and TTL
         # Change records and TTL
-        url = reverse('rrset@', args=(self.ownedDomains[1].name, '@', 'A',))
+        url = reverse('v1:rrset@', args=(self.ownedDomains[1].name, '@', 'A',))
         data = {'records': ['3.2.3.4'], 'ttl': 32}
         data = {'records': ['3.2.3.4'], 'ttl': 32}
         response = self.client.patch(url, json.dumps(data), content_type='application/json')
         response = self.client.patch(url, json.dumps(data), content_type='application/json')
         self.assertEqual(response.status_code, status.HTTP_200_OK)
         self.assertEqual(response.status_code, status.HTTP_200_OK)
@@ -420,13 +420,13 @@ class AuthenticatedRRsetTests(APITestCase):
 
 
     def testCantChangeForeignRRset(self):
     def testCantChangeForeignRRset(self):
         self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.otherToken)
         self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.otherToken)
-        url = reverse('rrsets', args=(self.otherDomains[0].name,))
+        url = reverse('v1:rrsets', args=(self.otherDomains[0].name,))
         data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
         data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
         response = self.client.post(url, json.dumps(data), content_type='application/json')
         response = self.client.post(url, json.dumps(data), content_type='application/json')
         self.assertEqual(response.status_code, status.HTTP_201_CREATED)
         self.assertEqual(response.status_code, status.HTTP_201_CREATED)
 
 
         self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token)
         self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token)
-        url = reverse('rrset', args=(self.otherDomains[0].name, '', 'A',))
+        url = reverse('v1:rrset', args=(self.otherDomains[0].name, '', 'A',))
         data = {'records': ['3.2.3.4'], 'ttl': 30, 'type': 'A'}
         data = {'records': ['3.2.3.4'], 'ttl': 30, 'type': 'A'}
 
 
         response = self.client.patch(url, json.dumps(data), content_type='application/json')
         response = self.client.patch(url, json.dumps(data), content_type='application/json')
@@ -437,13 +437,13 @@ class AuthenticatedRRsetTests(APITestCase):
 
 
     def testCantChangeForeignRRsetApex(self):
     def testCantChangeForeignRRsetApex(self):
         self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.otherToken)
         self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.otherToken)
-        url = reverse('rrsets', args=(self.otherDomains[0].name,))
+        url = reverse('v1:rrsets', args=(self.otherDomains[0].name,))
         data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
         data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
         response = self.client.post(url, json.dumps(data), content_type='application/json')
         response = self.client.post(url, json.dumps(data), content_type='application/json')
         self.assertEqual(response.status_code, status.HTTP_201_CREATED)
         self.assertEqual(response.status_code, status.HTTP_201_CREATED)
 
 
         self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token)
         self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token)
-        url = reverse('rrset@', args=(self.otherDomains[0].name, '@', 'A',))
+        url = reverse('v1:rrset@', args=(self.otherDomains[0].name, '@', 'A',))
         data = {'records': ['3.2.3.4'], 'ttl': 30, 'type': 'A'}
         data = {'records': ['3.2.3.4'], 'ttl': 30, 'type': 'A'}
 
 
         response = self.client.patch(url, json.dumps(data), content_type='application/json')
         response = self.client.patch(url, json.dumps(data), content_type='application/json')
@@ -453,13 +453,13 @@ class AuthenticatedRRsetTests(APITestCase):
         self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
         self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
 
 
     def testCantChangeEssentialProperties(self):
     def testCantChangeEssentialProperties(self):
-        url = reverse('rrsets', args=(self.ownedDomains[1].name,))
+        url = reverse('v1:rrsets', args=(self.ownedDomains[1].name,))
         data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A', 'subname': 'test1'}
         data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A', 'subname': 'test1'}
         response = self.client.post(url, json.dumps(data), content_type='application/json')
         response = self.client.post(url, json.dumps(data), content_type='application/json')
         self.assertEqual(response.status_code, status.HTTP_201_CREATED)
         self.assertEqual(response.status_code, status.HTTP_201_CREATED)
 
 
         # Changing the subname is expected to cause an error
         # Changing the subname is expected to cause an error
-        url = reverse('rrset', args=(self.ownedDomains[1].name, 'test1', 'A',))
+        url = reverse('v1:rrset', args=(self.ownedDomains[1].name, 'test1', 'A',))
         data = {'records': ['3.2.3.4'], 'ttl': 120, 'subname': 'test2'}
         data = {'records': ['3.2.3.4'], 'ttl': 120, 'subname': 'test2'}
         response = self.client.patch(url, json.dumps(data), content_type='application/json')
         response = self.client.patch(url, json.dumps(data), content_type='application/json')
         self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
         self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
@@ -495,12 +495,12 @@ class AuthenticatedRRsetTests(APITestCase):
 
 
     def testCanDeleteOwnRRset(self):
     def testCanDeleteOwnRRset(self):
         # Try PATCH with empty records
         # Try PATCH with empty records
-        url = reverse('rrsets', args=(self.ownedDomains[1].name,))
+        url = reverse('v1:rrsets', args=(self.ownedDomains[1].name,))
         data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
         data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
         response = self.client.post(url, json.dumps(data), content_type='application/json')
         response = self.client.post(url, json.dumps(data), content_type='application/json')
         self.assertEqual(response.status_code, status.HTTP_201_CREATED)
         self.assertEqual(response.status_code, status.HTTP_201_CREATED)
 
 
-        url = reverse('rrset', args=(self.ownedDomains[1].name, '', 'A',))
+        url = reverse('v1:rrset', args=(self.ownedDomains[1].name, '', 'A',))
         data = {'records': []}
         data = {'records': []}
         response = self.client.patch(url, json.dumps(data), content_type='application/json')
         response = self.client.patch(url, json.dumps(data), content_type='application/json')
         self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
         self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
@@ -509,12 +509,12 @@ class AuthenticatedRRsetTests(APITestCase):
         self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
         self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
 
 
         # Try DELETE
         # Try DELETE
-        url = reverse('rrsets', args=(self.ownedDomains[1].name,))
+        url = reverse('v1:rrsets', args=(self.ownedDomains[1].name,))
         data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
         data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
         response = self.client.post(url, json.dumps(data), content_type='application/json')
         response = self.client.post(url, json.dumps(data), content_type='application/json')
         self.assertEqual(response.status_code, status.HTTP_201_CREATED)
         self.assertEqual(response.status_code, status.HTTP_201_CREATED)
 
 
-        url = reverse('rrset', args=(self.ownedDomains[1].name, '', 'A',))
+        url = reverse('v1:rrset', args=(self.ownedDomains[1].name, '', 'A',))
         response = self.client.delete(url)
         response = self.client.delete(url)
         self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
         self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
 
 
@@ -523,12 +523,12 @@ class AuthenticatedRRsetTests(APITestCase):
 
 
     def testCanDeleteOwnRRsetApex(self):
     def testCanDeleteOwnRRsetApex(self):
         # Try PATCH with empty records
         # Try PATCH with empty records
-        url = reverse('rrsets', args=(self.ownedDomains[1].name,))
+        url = reverse('v1:rrsets', args=(self.ownedDomains[1].name,))
         data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
         data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
         response = self.client.post(url, json.dumps(data), content_type='application/json')
         response = self.client.post(url, json.dumps(data), content_type='application/json')
         self.assertEqual(response.status_code, status.HTTP_201_CREATED)
         self.assertEqual(response.status_code, status.HTTP_201_CREATED)
 
 
-        url = reverse('rrset@', args=(self.ownedDomains[1].name, '@', 'A',))
+        url = reverse('v1:rrset@', args=(self.ownedDomains[1].name, '@', 'A',))
         data = {'records': []}
         data = {'records': []}
         response = self.client.patch(url, json.dumps(data), content_type='application/json')
         response = self.client.patch(url, json.dumps(data), content_type='application/json')
         self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
         self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
@@ -537,12 +537,12 @@ class AuthenticatedRRsetTests(APITestCase):
         self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
         self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
 
 
         # Try DELETE
         # Try DELETE
-        url = reverse('rrsets', args=(self.ownedDomains[1].name,))
+        url = reverse('v1:rrsets', args=(self.ownedDomains[1].name,))
         data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
         data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
         response = self.client.post(url, json.dumps(data), content_type='application/json')
         response = self.client.post(url, json.dumps(data), content_type='application/json')
         self.assertEqual(response.status_code, status.HTTP_201_CREATED)
         self.assertEqual(response.status_code, status.HTTP_201_CREATED)
 
 
-        url = reverse('rrset@', args=(self.ownedDomains[1].name, '@', 'A',))
+        url = reverse('v1:rrset@', args=(self.ownedDomains[1].name, '@', 'A',))
         response = self.client.delete(url)
         response = self.client.delete(url)
         self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
         self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
 
 
@@ -551,13 +551,13 @@ class AuthenticatedRRsetTests(APITestCase):
 
 
     def testCantDeleteForeignRRset(self):
     def testCantDeleteForeignRRset(self):
         self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.otherToken)
         self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.otherToken)
-        url = reverse('rrsets', args=(self.otherDomains[0].name,))
+        url = reverse('v1:rrsets', args=(self.otherDomains[0].name,))
         data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
         data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
         response = self.client.post(url, json.dumps(data), content_type='application/json')
         response = self.client.post(url, json.dumps(data), content_type='application/json')
         self.assertEqual(response.status_code, status.HTTP_201_CREATED)
         self.assertEqual(response.status_code, status.HTTP_201_CREATED)
 
 
         self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token)
         self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token)
-        url = reverse('rrset', args=(self.otherDomains[0].name, '', 'A',))
+        url = reverse('v1:rrset', args=(self.otherDomains[0].name, '', 'A',))
 
 
         # Try PATCH with empty records
         # Try PATCH with empty records
         data = {'records': []}
         data = {'records': []}
@@ -570,7 +570,7 @@ class AuthenticatedRRsetTests(APITestCase):
 
 
         # Make sure it actually is still there
         # Make sure it actually is still there
         self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.otherToken)
         self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.otherToken)
-        url = reverse('rrset@', args=(self.otherDomains[0].name, '@', 'A',))
+        url = reverse('v1:rrset@', args=(self.otherDomains[0].name, '@', 'A',))
         response = self.client.get(url)
         response = self.client.get(url)
         self.assertEqual(response.status_code, status.HTTP_200_OK)
         self.assertEqual(response.status_code, status.HTTP_200_OK)
         self.assertEqual(response.data['records'][0], '1.2.3.4')
         self.assertEqual(response.data['records'][0], '1.2.3.4')
@@ -579,12 +579,12 @@ class AuthenticatedRRsetTests(APITestCase):
         self.owner.locked = timezone.now()
         self.owner.locked = timezone.now()
         self.owner.save()
         self.owner.save()
 
 
-        url = reverse('rrsets', args=(self.ownedDomains[1].name,))
+        url = reverse('v1:rrsets', args=(self.ownedDomains[1].name,))
         data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
         data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
         response = self.client.post(url, json.dumps(data), content_type='application/json')
         response = self.client.post(url, json.dumps(data), content_type='application/json')
         self.assertEqual(response.status_code, status.HTTP_201_CREATED)
         self.assertEqual(response.status_code, status.HTTP_201_CREATED)
 
 
-        url = reverse('rrset', args=(self.ownedDomains[1].name, '', 'A',))
+        url = reverse('v1:rrset', args=(self.ownedDomains[1].name, '', 'A',))
 
 
         # Try PATCH with empty records
         # Try PATCH with empty records
         data = {'records': []}
         data = {'records': []}
@@ -600,7 +600,7 @@ class AuthenticatedRRsetTests(APITestCase):
         httpretty.register_uri(httpretty.PATCH, settings.NSLORD_PDNS_API + '/zones/' + self.ownedDomains[1].name + '.')
         httpretty.register_uri(httpretty.PATCH, settings.NSLORD_PDNS_API + '/zones/' + self.ownedDomains[1].name + '.')
         httpretty.register_uri(httpretty.PUT, settings.NSLORD_PDNS_API + '/zones/' + self.ownedDomains[1].name + './notify')
         httpretty.register_uri(httpretty.PUT, settings.NSLORD_PDNS_API + '/zones/' + self.ownedDomains[1].name + './notify')
 
 
-        url = reverse('rrsets', args=(self.ownedDomains[1].name,))
+        url = reverse('v1:rrsets', args=(self.ownedDomains[1].name,))
         data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
         data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
         self.client.post(url, json.dumps(data), content_type='application/json')
         self.client.post(url, json.dumps(data), content_type='application/json')
 
 
@@ -615,12 +615,12 @@ class AuthenticatedRRsetTests(APITestCase):
         httpretty.register_uri(httpretty.PUT, settings.NSLORD_PDNS_API + '/zones/' + self.ownedDomains[1].name + './notify')
         httpretty.register_uri(httpretty.PUT, settings.NSLORD_PDNS_API + '/zones/' + self.ownedDomains[1].name + './notify')
 
 
         # Create record, should cause a pdns PATCH request and a notify
         # Create record, should cause a pdns PATCH request and a notify
-        url = reverse('rrsets', args=(self.ownedDomains[1].name,))
+        url = reverse('v1:rrsets', args=(self.ownedDomains[1].name,))
         data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
         data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
         self.client.post(url, json.dumps(data), content_type='application/json')
         self.client.post(url, json.dumps(data), content_type='application/json')
 
 
         # Delete record, should cause a pdns PATCH request and a notify
         # Delete record, should cause a pdns PATCH request and a notify
-        url = reverse('rrset', args=(self.ownedDomains[1].name, '', 'A',))
+        url = reverse('v1:rrset', args=(self.ownedDomains[1].name, '', 'A',))
         response = self.client.delete(url)
         response = self.client.delete(url)
         self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
         self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
 
 
@@ -637,7 +637,7 @@ class AuthenticatedRRsetTests(APITestCase):
         self.assertEqual(httpretty.httpretty.latest_requests[-1].method, 'PUT')
         self.assertEqual(httpretty.httpretty.latest_requests[-1].method, 'PUT')
 
 
     def testImportRRsets(self):
     def testImportRRsets(self):
-        url = reverse('rrsets', args=(self.ownedDomains[1].name,))
+        url = reverse('v1:rrsets', args=(self.ownedDomains[1].name,))
         data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
         data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
         response = self.client.post(url, json.dumps(data), content_type='application/json')
         response = self.client.post(url, json.dumps(data), content_type='application/json')
         self.assertEqual(response.status_code, status.HTTP_201_CREATED)
         self.assertEqual(response.status_code, status.HTTP_201_CREATED)

+ 61 - 0
api/desecapi/urls/version_1.py

@@ -0,0 +1,61 @@
+from django.urls import include, path, re_path
+from djoser.views import UserView
+from rest_framework.routers import SimpleRouter
+
+from desecapi import views
+
+
+tokens_router = SimpleRouter()
+tokens_router.register(r'', views.TokenViewSet, base_name='token')
+
+auth_urls = [
+    # Old user management
+    # TODO deprecated, remove
+    path('users/create/', views.UserCreateView.as_view(), name='user-create'),  # deprecated
+    path('token/create/', views.TokenCreateView.as_view(), name='token-create'),  # deprecated
+    path('token/destroy/', views.TokenDestroyView.as_view(), name='token-destroy'),  # deprecated
+
+    # New user management
+    path('users/', views.UserCreateView.as_view(), name='register'),
+
+    # Token management
+    path('token/login/', views.TokenCreateView.as_view(), name='login'),
+    path('token/logout/', views.TokenDestroyView.as_view(), name='logout'),
+    path('', include('djoser.urls.authtoken')),  # note: this is partially overwritten by the two lines above
+    path('tokens/', include(tokens_router.urls)),
+
+    # User home
+    path('me/', UserView.as_view(), name='user'),
+]
+
+api_urls = [
+    # API home
+    path('', views.Root.as_view(), name='root'),
+
+    # Domain and RRSet endpoints
+    path('domains/', views.DomainList.as_view(), name='domain-list'),
+    re_path(r'^domains/(?P<name>[a-zA-Z\.\-_0-9]+)/$', views.DomainDetail.as_view(), name='domain-detail'),
+    re_path(r'^domains/(?P<name>[a-zA-Z\.\-_0-9]+)/rrsets/$', views.RRsetList.as_view(), name='rrsets'),
+    re_path(r'^domains/(?P<name>[a-zA-Z\.\-_0-9]+)/rrsets/(?P<subname>(\*)?[a-zA-Z\.\-_0-9=]*)\.\.\./(?P<type>[A-Z][A-Z0-9]*)/$', views.RRsetDetail.as_view(), name='rrset'),
+    re_path(r'^domains/(?P<name>[a-zA-Z\.\-_0-9]+)/rrsets/(?P<subname>[*@]|[a-zA-Z\.\-_0-9=]+)/(?P<type>[A-Z][A-Z0-9]*)/$', views.RRsetDetail.as_view(), name='rrset@'),
+
+    # DNS query endpoint
+    # TODO remove?
+    path('dns', views.DnsQuery.as_view(), name='dns-query'),
+
+    # DynDNS update endpoint
+    path('dyndns/update', views.DynDNS12Update.as_view(), name='dyndns12update'),
+
+    # Donation endpoints
+    path('donation/', views.DonationList.as_view(), name='donation'),
+
+    # Unlock endpoints
+    path('unlock/user/<email>', views.unlock, name='unlock/byEmail'),
+    path('unlock/done', views.unlock_done, name='unlock/done'),
+]
+
+app_name = 'desecapi'
+urlpatterns = [
+    path('auth/', include(auth_urls)),
+    path('', include(api_urls)),
+]

+ 4 - 0
api/desecapi/urls/version_2.py

@@ -0,0 +1,4 @@
+from desecapi.urls.version_1 import urlpatterns as version_1
+
+app_name = 'desecapi'
+urlpatterns = version_1

+ 6 - 6
api/desecapi/views.py

@@ -297,14 +297,14 @@ class Root(APIView):
     def get(self, request, format=None):
     def get(self, request, format=None):
         if self.request.user and self.request.user.is_authenticated:
         if self.request.user and self.request.user.is_authenticated:
             return Response({
             return Response({
-                'domains': reverse('domain-list'),
-                'user': reverse('user'),
-                'logout': reverse('token-destroy'),  # TODO change interface to token-destroy, too?
+                'domains': reverse('domain-list', request=request),
+                'user': reverse('user', request=request),
+                'logout': reverse('token-destroy', request=request),  # TODO change interface to token-destroy, too?
             })
             })
         else:
         else:
             return Response({
             return Response({
-                'login': reverse('token-create', request=request, format=format),  # TODO change interface to token-create, too?
-                'register': reverse('register', request=request, format=format),
+                'login': reverse('token-create', request=request),
+                'register': reverse('register', request=request),
             })
             })
 
 
 
 
@@ -546,7 +546,7 @@ def unlock(request, email):
                 # fail silently, so people can't probe registered addresses
                 # fail silently, so people can't probe registered addresses
                 pass
                 pass
 
 
-            return HttpResponseRedirect(reverse('unlock/done'))
+            return HttpResponseRedirect(reverse('unlock/done', request=request))
 
 
     # if a GET (or any other method) we'll create a blank form
     # if a GET (or any other method) we'll create a blank form
     else:
     else:

+ 1 - 0
docs/index.rst

@@ -3,6 +3,7 @@
 .. include:: domains.rst
 .. include:: domains.rst
 .. include:: rrsets.rst
 .. include:: rrsets.rst
 .. include:: endpoint-reference.rst
 .. include:: endpoint-reference.rst
+.. include:: lifecycle.rst
 
 
 Getting Help
 Getting Help
 ------------
 ------------

+ 39 - 0
docs/lifecycle.rst

@@ -0,0 +1,39 @@
+API Versions and Lifecycle
+--------------------------
+
+To enable users to build reliable tools on top of the deSEC API, we
+maintain stable versions of the API for extended periods of time.
+
+Each API version will advance through the API version lifecycle,
+starting from `unstable` and proceeding to `stable`, `deprecated`,
+and, finally, to `historical`.
+
+Check out the `current status of the API versions`_ to make sure you
+are using the latest stable API whenever using our service in
+production.
+
+.. _current status of the API versions: https://github.com/desec-io/desec-stack/#api-versions-and-road-map
+
+**Unstable API versions** are currently under development and may
+change without prior notice, but we promise to keep an eye on users
+affected by incompatible changes.
+
+For all **stable API versions**, we guarantee that
+
+1. it will be maintained at least until the end of the given support
+   period,
+
+2. there will be no incompatible changes made to the interface, unless
+   security vulnerabilities make such changes inevitable,
+
+3. users will be warned before the end of the support period.
+
+**Deprecated API versions** are going to be disabled in the future.
+Users will be notified via email and are encouraged to migrate to the
+next stable version as soon as possible. For this purpose, a migration
+advisory will be provided. After the support period is over, deprecated
+API versions may be disabled without further warning and transition to
+historical state.
+
+**Historical API versions** are permanently disabled and cannot be used.
+

+ 7 - 0
test/e2e/schemas.js

@@ -1,5 +1,12 @@
 // For format specs, see https://json-schema.org/latest/json-schema-validation.html#rfc.section.7.3
 // For format specs, see https://json-schema.org/latest/json-schema-validation.html#rfc.section.7.3
 
 
+exports.rootNoLogin = {
+    properties: {
+        login: { type: "string" },
+        register: { type: "string" },
+    }
+};
+
 exports.user = {
 exports.user = {
     properties: {
     properties: {
         dyn: { type: "boolean" },
         dyn: { type: "boolean" },

+ 34 - 3
test/e2e/spec/api_spec.js

@@ -4,7 +4,35 @@ var itPropagatesToTheApi = require("./../setup.js").itPropagatesToTheApi;
 var itShowsUpInPdnsAs = require("./../setup.js").itShowsUpInPdnsAs;
 var itShowsUpInPdnsAs = require("./../setup.js").itShowsUpInPdnsAs;
 var schemas = require("./../schemas.js");
 var schemas = require("./../schemas.js");
 
 
-describe("API", function () {
+describe("API Versioning", function () {
+
+    before(function () {
+        chakram.setRequestDefaults({
+            headers: {
+                'Host': 'desec.' + process.env.DESECSTACK_DOMAIN,
+            },
+            followRedirect: false,
+            baseUrl: 'https://www/api',
+        })
+    });
+
+    [
+        'v1',
+        'v2',
+    ].forEach(function (version) {
+        it("maintains the requested version " + version, function() {
+            chakram.get('/' + version + '/').then(function (response) {
+                expect(response).to.have.schema(schemas.rootNoLogin);
+                let regex = new RegExp('http://[^/]+/api/' + version + '/auth/users/', 'g')
+                expect(response.body.login).to.match(regex);
+                return chakram.wait();
+            });
+        });
+    })
+
+});
+
+describe("API v1", function () {
     this.timeout(3000);
     this.timeout(3000);
 
 
     before(function () {
     before(function () {
@@ -18,8 +46,11 @@ describe("API", function () {
     });
     });
 
 
     it("provides an index page", function () {
     it("provides an index page", function () {
-        var response = chakram.get('/');
-        return expect(response).to.have.status(200);
+        chakram.get('/').then(function (response) {
+            expect(response).to.have.schema(schemas.rootNoLogin);
+            expect(response.body.login).to.match(/http:\/\/[^\/]+\/api\/v1\/auth\/users\//);
+            return chakram.wait();
+        });
     });
     });
 
 
     describe("user registration", function () {
     describe("user registration", function () {