Bladeren bron

feat(api): simply accessing RRsets at the zone apex, fixes #136

Peter Thomassen 6 jaren geleden
bovenliggende
commit
f173b33592
6 gewijzigde bestanden met toevoegingen van 173 en 11 verwijderingen
  1. 1 0
      api/api/urls.py
  2. 128 2
      api/desecapi/tests/testrrsets.py
  3. 5 0
      api/desecapi/views.py
  4. 4 2
      docs/endpoint-reference.rst
  5. 31 7
      docs/rrsets.rst
  6. 4 0
      test/e2e/spec/api_spec.js

+ 1 - 0
api/api/urls.py

@@ -13,6 +13,7 @@ apiurls = [
     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]+)/$', 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/$', 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'^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'^tokens/', include(token_urls)),
     url(r'^tokens/', include(token_urls)),
     url(r'^dns$', views.DnsQuery.as_view(), name='dns-query'),
     url(r'^dns$', views.DnsQuery.as_view(), name='dns-query'),
     url(r'^dyndns/update$', views.DynDNS12Update.as_view(), name='dyndns12update'),
     url(r'^dyndns/update$', views.DynDNS12Update.as_view(), name='dyndns12update'),

+ 128 - 2
api/desecapi/tests/testrrsets.py

@@ -214,6 +214,18 @@ class AuthenticatedRRsetTests(APITestCase):
         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):
+        url = reverse('rrsets', args=(self.ownedDomains[1].name,))
+        data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
+        response = self.client.post(url, json.dumps(data), content_type='application/json')
+        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+
+        url = reverse('rrset@', args=(self.ownedDomains[1].name, '@', 'A',))
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, status.HTTP_200_OK)
+        self.assertEqual(response.data['records'][0], '1.2.3.4')
+        self.assertEqual(response.data['ttl'], 60)
+
     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('rrsets', args=(self.ownedDomains[1].name,))
@@ -287,6 +299,32 @@ class AuthenticatedRRsetTests(APITestCase):
         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('rrset', args=(self.ownedDomains[1].name, '', 'A',))
+
+        data = {'records': ['2.2.3.4'], 'ttl': 30}
+        response = self.client.put(url, json.dumps(data), content_type='application/json')
+        self.assertEqual(response.status_code, status.HTTP_200_OK)
+
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, status.HTTP_200_OK)
+        self.assertEqual(response.data['records'][0], '2.2.3.4')
+        self.assertEqual(response.data['ttl'], 30)
+
+        data = {'records': ['3.2.3.4']}
+        response = self.client.put(url, json.dumps(data), content_type='application/json')
+        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
+
+        data = {'ttl': 37}
+        response = self.client.put(url, json.dumps(data), content_type='application/json')
+        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
+
+    def testCanPutOwnRRsetApex(self):
+        url = reverse('rrsets', args=(self.ownedDomains[1].name,))
+        data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
+        response = self.client.post(url, json.dumps(data), content_type='application/json')
+        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+
+        url = reverse('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')
         self.assertEqual(response.status_code, status.HTTP_200_OK)
         self.assertEqual(response.status_code, status.HTTP_200_OK)
@@ -296,12 +334,10 @@ class AuthenticatedRRsetTests(APITestCase):
         self.assertEqual(response.data['records'][0], '2.2.3.4')
         self.assertEqual(response.data['records'][0], '2.2.3.4')
         self.assertEqual(response.data['ttl'], 30)
         self.assertEqual(response.data['ttl'], 30)
 
 
-        url = reverse('rrset', args=(self.ownedDomains[1].name, '', 'A',))
         data = {'records': ['3.2.3.4']}
         data = {'records': ['3.2.3.4']}
         response = self.client.put(url, json.dumps(data), content_type='application/json')
         response = self.client.put(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)
 
 
-        url = reverse('rrset', args=(self.ownedDomains[1].name, '', 'A',))
         data = {'ttl': 37}
         data = {'ttl': 37}
         response = self.client.put(url, json.dumps(data), content_type='application/json')
         response = self.client.put(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)
@@ -344,6 +380,44 @@ class AuthenticatedRRsetTests(APITestCase):
         self.assertEqual(response.data['records'][0], '5.2.3.4')
         self.assertEqual(response.data['records'][0], '5.2.3.4')
         self.assertEqual(response.data['ttl'], 37)
         self.assertEqual(response.data['ttl'], 37)
 
 
+    def testCanPatchOwnRRsetApex(self):
+        url = reverse('rrsets', args=(self.ownedDomains[1].name,))
+        data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
+        response = self.client.post(url, json.dumps(data), content_type='application/json')
+        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+
+        # Change records and TTL
+        url = reverse('rrset@', args=(self.ownedDomains[1].name, '@', 'A',))
+        data = {'records': ['3.2.3.4'], 'ttl': 32}
+        response = self.client.patch(url, json.dumps(data), content_type='application/json')
+        self.assertEqual(response.status_code, status.HTTP_200_OK)
+
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, status.HTTP_200_OK)
+        self.assertEqual(response.data['records'][0], '3.2.3.4')
+        self.assertEqual(response.data['ttl'], 32)
+
+        # Change records alone
+        data = {'records': ['5.2.3.4']}
+        response = self.client.patch(url, json.dumps(data), content_type='application/json')
+        self.assertEqual(response.status_code, status.HTTP_200_OK)
+        self.assertEqual(response.data['records'][0], '5.2.3.4')
+        self.assertEqual(response.data['ttl'], 32)
+
+        # Change TTL alone
+        data = {'ttl': 37}
+        response = self.client.patch(url, json.dumps(data), content_type='application/json')
+        self.assertEqual(response.status_code, status.HTTP_200_OK)
+        self.assertEqual(response.data['records'][0], '5.2.3.4')
+        self.assertEqual(response.data['ttl'], 37)
+
+        # Change nothing
+        data = {}
+        response = self.client.patch(url, json.dumps(data), content_type='application/json')
+        self.assertEqual(response.status_code, status.HTTP_200_OK)
+        self.assertEqual(response.data['records'][0], '5.2.3.4')
+        self.assertEqual(response.data['ttl'], 37)
+
     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('rrsets', args=(self.otherDomains[0].name,))
@@ -361,6 +435,23 @@ class AuthenticatedRRsetTests(APITestCase):
         response = self.client.put(url, json.dumps(data), content_type='application/json')
         response = self.client.put(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 testCantChangeForeignRRsetApex(self):
+        self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.otherToken)
+        url = reverse('rrsets', args=(self.otherDomains[0].name,))
+        data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
+        response = self.client.post(url, json.dumps(data), content_type='application/json')
+        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+
+        self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token)
+        url = reverse('rrset@', args=(self.otherDomains[0].name, '@', 'A',))
+        data = {'records': ['3.2.3.4'], 'ttl': 30, 'type': 'A'}
+
+        response = self.client.patch(url, json.dumps(data), content_type='application/json')
+        self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
+
+        response = self.client.put(url, json.dumps(data), content_type='application/json')
+        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('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'}
@@ -430,6 +521,34 @@ class AuthenticatedRRsetTests(APITestCase):
         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 testCanDeleteOwnRRsetApex(self):
+        # Try PATCH with empty records
+        url = reverse('rrsets', args=(self.ownedDomains[1].name,))
+        data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
+        response = self.client.post(url, json.dumps(data), content_type='application/json')
+        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+
+        url = reverse('rrset@', args=(self.ownedDomains[1].name, '@', 'A',))
+        data = {'records': []}
+        response = self.client.patch(url, json.dumps(data), content_type='application/json')
+        self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
+
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
+
+        # Try DELETE
+        url = reverse('rrsets', args=(self.ownedDomains[1].name,))
+        data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
+        response = self.client.post(url, json.dumps(data), content_type='application/json')
+        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+
+        url = reverse('rrset@', args=(self.ownedDomains[1].name, '@', 'A',))
+        response = self.client.delete(url)
+        self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
+
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
+
     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('rrsets', args=(self.otherDomains[0].name,))
@@ -449,6 +568,13 @@ class AuthenticatedRRsetTests(APITestCase):
         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)
 
 
+        # Make sure it actually is still there
+        self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.otherToken)
+        url = reverse('rrset@', args=(self.otherDomains[0].name, '@', 'A',))
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, status.HTTP_200_OK)
+        self.assertEqual(response.data['records'][0], '1.2.3.4')
+
     def testCantDeleteOwnRRsetWhileAccountIsLocked(self):
     def testCantDeleteOwnRRsetWhileAccountIsLocked(self):
         self.owner.locked = timezone.now()
         self.owner.locked = timezone.now()
         self.owner.save()
         self.owner.save()

+ 5 - 0
api/desecapi/views.py

@@ -186,6 +186,11 @@ class RRsetDetail(generics.RetrieveUpdateDestroyAPIView):
     serializer_class = RRsetSerializer
     serializer_class = RRsetSerializer
     permission_classes = (permissions.IsAuthenticated, IsDomainOwner,)
     permission_classes = (permissions.IsAuthenticated, IsDomainOwner,)
 
 
+    def dispatch(self, request, *args, **kwargs):
+        if kwargs['subname'] == '@':
+            kwargs['subname'] = ''
+        return super().dispatch(request, *args, **kwargs)
+
     def delete(self, request, *args, **kwargs):
     def delete(self, request, *args, **kwargs):
         if request.user.locked:
         if request.user.locked:
             detail = "You cannot delete RRsets while your account is locked."
             detail = "You cannot delete RRsets while your account is locked."

+ 4 - 2
docs/endpoint-reference.rst

@@ -53,11 +53,13 @@ for `Domain Management`_ and `Retrieving and Manipulating DNS Information`_.
 |                                                +------------+---------------------------------------------+
 |                                                +------------+---------------------------------------------+
 |                                                | ``PUT``    | Create, modify or delete one or more RRsets |
 |                                                | ``PUT``    | Create, modify or delete one or more RRsets |
 +------------------------------------------------+------------+---------------------------------------------+
 +------------------------------------------------+------------+---------------------------------------------+
-| ...\ ``/:name/rrsets/:subname.../:type/``      | ``GET``    | Retrieve a specific RRset                   |
-|                                                +------------+---------------------------------------------+
+| ...\ ``/:name/rrsets/:subname/:type/``         | ``GET``    | Retrieve a specific RRset                   |
+| ...\ ``/:name/rrsets/:subname.../:type/``      +------------+---------------------------------------------+
 |                                                | ``PATCH``  | Modify an RRset                             |
 |                                                | ``PATCH``  | Modify an RRset                             |
 |                                                +------------+---------------------------------------------+
 |                                                +------------+---------------------------------------------+
 |                                                | ``PUT``    | Replace an RRset                            |
 |                                                | ``PUT``    | Replace an RRset                            |
 |                                                +------------+---------------------------------------------+
 |                                                +------------+---------------------------------------------+
 |                                                | ``DELETE`` | Delete an RRset                             |
 |                                                | ``DELETE`` | Delete an RRset                             |
 +------------------------------------------------+------------+---------------------------------------------+
 +------------------------------------------------+------------+---------------------------------------------+
+| ...\ ``/:name/rrsets/@/:type/``                |            | Access an RRset at the zone apex            |
++------------------------------------------------+------------+---------------------------------------------+

+ 31 - 7
docs/rrsets.rst

@@ -241,17 +241,35 @@ To retrieve an RRset with a specific name and type from your zone (e.g. the
 like this::
 like this::
 
 
     http GET \
     http GET \
-        https://desec.io/api/v1/domains/:name/rrsets/:subname.../:type/ \
+        https://desec.io/api/v1/domains/:name/rrsets/:subname/:type/ \
         Authorization:"Token {token}"
         Authorization:"Token {token}"
 
 
 This will return only one RRset (i.e., the response is not a JSON array).  The
 This will return only one RRset (i.e., the response is not a JSON array).  The
 response status code is ``200 OK`` if the requested RRset exists, and ``404
 response status code is ``200 OK`` if the requested RRset exists, and ``404
 Not Found`` otherwise.
 Not Found`` otherwise.
 
 
-Note the three dots after ``:subname``.  You can think of them as abbreviating
-the rest of the DNS name.  To retrieve all records associated with the zone
-apex (i.e. ``example.com`` where ``subname`` is empty), simply use
-``rrsets/.../``.
+Accessing the Zone Apex
+```````````````````````
+
+**Note:** The RRset at the zone apex (the domain root with an empty subname)
+*cannot* be queried via ``/api/v1/domains/:name/rrsets//:type/``.  This is due
+to normalization rules of the HTTP specification which cause the double-slash
+``//`` to be replaced with a single slash ``/``, breaking the URL structure.
+
+To access an RRset at the root of your domain, we reserved the special subname
+value ``@``.  This is a common placeholder for this use case (see RFC 1035).
+As an example, you can retrieve the IPv4 address(es) of your domain root by
+querying ``/api/v1/domains/:name/rrsets/@/A/``.
+
+**Pro tip:**: If you like to have the convenience of simple string expansion
+in the URL, you can add three dots after ``:subname``, like so::
+
+    http GET \
+        https://desec.io/api/v1/domains/:name/rrsets/:subname.../:type/ \
+        Authorization:"Token {token}"
+
+With this syntax, the above-mentioned normalization problem does not occur.
+You can think of the three dots as abbreviating the rest of the DNS name.
 
 
 
 
 Modifying an RRset
 Modifying an RRset
@@ -264,11 +282,11 @@ need to be provided, where the ``PUT`` method requires specification of both
 fields.  Examples::
 fields.  Examples::
 
 
     http PUT \
     http PUT \
-        https://desec.io/api/v1/domains/:name/rrsets/:subname.../:type/ \
+        https://desec.io/api/v1/domains/:name/rrsets/:subname/:type/ \
         Authorization:"Token {token}" records:='["127.0.0.1"]' ttl:=3600
         Authorization:"Token {token}" records:='["127.0.0.1"]' ttl:=3600
 
 
     http PATCH \
     http PATCH \
-        https://desec.io/api/v1/domains/:name/rrsets/:subname.../:type/ \
+        https://desec.io/api/v1/domains/:name/rrsets/:subname/:type/ \
         Authorization:"Token {token}" ttl:=86400
         Authorization:"Token {token}" ttl:=86400
 
 
 If the RRset was updated successfully, the API returns ``200 OK`` with the
 If the RRset was updated successfully, the API returns ``200 OK`` with the
@@ -279,6 +297,9 @@ invalid (e.g. when you provide an unknown record type, or an `A` value that is
 not an IPv4 address), ``422 Unprocessable Entity`` is returned.  If the RRset
 not an IPv4 address), ``422 Unprocessable Entity`` is returned.  If the RRset
 does not exist, ``404 Not Found`` is returned.
 does not exist, ``404 Not Found`` is returned.
 
 
+To modify an RRset at the zone apex (empty subname), use the special subname
+value ``@`` (read more about `Accessing the Zone Apex`_).
+
 Bulk Modification of RRsets
 Bulk Modification of RRsets
 ```````````````````````````
 ```````````````````````````
 
 
@@ -340,6 +361,9 @@ and ``PUT`` request methods. You can simply send an array of RRset objects
             '{"subname": "backup", "type": "MX", "records": []},' \
             '{"subname": "backup", "type": "MX", "records": []},' \
             '...]'
             '...]'
 
 
+Note that ``@`` is not accepted here as an alias for the empty subname. For
+context, see `Accessing the Zone Apex`_.
+
 Atomicity
 Atomicity
 `````````
 `````````
 Bulk operations are performed atomically, i.e. either all given RRsets are
 Bulk operations are performed atomically, i.e. either all given RRsets are

+ 4 - 0
test/e2e/spec/api_spec.js

@@ -274,6 +274,10 @@ describe("API", function () {
                         expect(response).to.have.status(200);
                         expect(response).to.have.status(200);
                         expect(response).to.have.schema(schemas.rrset);
                         expect(response).to.have.schema(schemas.rrset);
 
 
+                        response = chakram.get('/domains/' + domain + '/rrsets/@/NS/');
+                        expect(response).to.have.status(200);
+                        expect(response).to.have.schema(schemas.rrset);
+
                         return chakram.wait();
                         return chakram.wait();
                     });
                     });
                 });
                 });