Skip to content

Commit bf3fd00

Browse files
authored
feat: allow DNS record(s) to be updated (#40)
Allow DNS record(s) to be updated: - Allow a single DNS record to be updated. This will only allow the content of the DNS record to be updated when no other DNS records with the same name, expiry time and type exists. - Allow all DNS records to be replaced at once. Fixes #36 Signed-off-by: Roald Nefs <[email protected]>
1 parent 7dc6dd6 commit bf3fd00

File tree

6 files changed

+314
-35
lines changed

6 files changed

+314
-35
lines changed

CHANGELOG.md

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@ All notable changes in **python-transip** are documented below.
44
## [Unreleased]
55
### Added
66
- This `CHANGELOG.md` file to be able to list all notable changes for each version of **python-transip**.
7-
- The `transip.v6.objects.ApiTestService` service to allow calling the test resource to make sure everything is working.
8-
- The `transip.v6.objects.InvoiceItemService` service to allow listing all invoice items on a `transip.v6.objects.Invoice` object.
9-
- The `transip.mixins.ObjectUpdateMixin` mixin to allow calling `update()` on API object directly.
10-
- Allow an invoice to be written to a PDF file by calling the `pdf()` method on a `transip.v6.objects.Invoice` object.
11-
- The `transip.v6.objects.ProductService` service to allow listing all products as a `transip.v6.objects.Product` object.
7+
- The `transip.TransIP.api_test` service to allow calling the test resource to make sure everything is working.
8+
- The option to list all invoices attached to your TransIP account from the `transip.TransIP.invoices` service.
9+
- The option to save an invoice as PDF file from `transip.v6.objects.Invoice` object.
10+
- The option to list all products available in TransIP from the `transip.TransIP.products` service.
11+
- The option to update a single SSH key from `transip.v6.objects.SshKey` object.
12+
- The option to update the content of a single DNS record from the `transip.v6.objects.Domain.dns` service, as well as from the `transip.v6.objects.DnsEntry` object.
13+
- The option to replace all existing DNS records of a single domain at once from the `transip.v6.objects.Domain.dns` service.
1214

1315
[Unreleased]: https://github.com/roaldnefs/python-transip/compare/v0.3.0...HEAD

README.md

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@
4747
- [The **DnsEntry** class](#the-dnsentry-class)
4848
- [List all DNS entries for a domain](#list-all-dns-entries-for-a-domain)
4949
- [Add a new single DNS entry to a domain](#add-a-new-single-dns-entry-to-a-domain)
50+
- [Update single DNS entry](#update-single-dns-entry)
51+
- [Update all DNS entries for a domain](#update-all-dns-entries-for-a-domain)
5052
- [Remove a DNS entry from a domain](#remove-a-dns-entry-from-a-domain)
5153
- [VPS](#vps)
5254
- [HA-IP](#ha-ip)
@@ -524,6 +526,7 @@ The **DnsEntry** class makes the following attributes available:
524526
The class has the following methods:
525527

526528
- **delete()** will delete the DNS-record from the domain.
529+
- **update()** will send the updated attributes to the TransIP API. This can only be used when updating the **content** attribute of a DnsEntry and when there aren't any other DNS records with the same **name**, **expire** and **type** attributes.
527530

528531
#### List all DNS entries for a domain
529532
Retrieve the DNS records of a single domain registered in your TransIP account by calling **dns.list()** on a **transip.v6.objects.Domain** object. This will return a list of **transip.v6.objects.DnsEntry** objects.
@@ -565,6 +568,55 @@ dns_entry_data = {
565568
domain.delete(dns_entry_data)
566569
```
567570

571+
#### Update single DNS entry
572+
Update a single DNS record of a domain by calling **dns.update(_data_)** on a **transip.v6.objects.Domain** object. The **data** keyword argument a dictionary containing the **name**, **expire**, **type** and **content** attributes.
573+
574+
This can only be used when updating the **content** attribute of a DNS entry and when there aren't any other DNS records with the same **name**, **expire** and **type** attributes.
575+
576+
For example:
577+
```python
578+
import transip
579+
# Initialize a client using the TransIP demo token.
580+
client = transip.TransIP(access_token=transip.v6.DEMO_TOKEN)
581+
582+
# Retrieve a domain by its name.
583+
domain = client.domains.get('transipdemonstratie.nl')
584+
# Dictionary containing the information for a single updated DNS record.
585+
dns_entry_data = {
586+
"name": "www",
587+
"expire": 86400,
588+
"type": "A",
589+
"content": "127.0.0.2" # The update content.
590+
}
591+
# Update the content of a single DNS record.
592+
domain.update(dns_entry_data)
593+
```
594+
595+
#### Update all DNS entries for a domain
596+
Update all DNS records of a single domain registered in your TransIP account at once by calling **dns.replace()** on a **transip.v6.objects.Domain** object.
597+
598+
**Note:** This will wipe all existing DNS records with the provided records.
599+
600+
For example:
601+
```python
602+
import transip
603+
# Initialize a client using the TransIP demo token.
604+
client = transip.TransIP(access_token=transip.v6.DEMO_TOKEN)
605+
606+
# Retrieve a domain by its name.
607+
domain = client.domains.get('transipdemonstratie.nl')
608+
# Retrieve the DNS records of a single domain.
609+
records = domain.dns.list()
610+
611+
for record in records:
612+
# Update the A-record for localhost
613+
if record.name == 'localhost' and record.type == 'A':
614+
record.content = '127.0.0.1'
615+
616+
# Replace all the records with the updated ones
617+
domain.dns.replace(records)
618+
```
619+
568620
#### Remove a DNS entry from a domain
569621
Delete an existing DNS record from a domain by calling **dns.delete(_data_)** on a **transip.v6.objects.Domain** object. The **data** keyword argument a dictionary containing the **name**, **expire**, **type** and **content** attributes.
570622

tests/fixtures/domains.json

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,36 @@
294294
}
295295
}
296296
},
297+
{
298+
"method": "PUT",
299+
"url": "https://api.transip.nl/v6/domains/example.com/dns",
300+
"status": 204,
301+
"content_type": "application/json",
302+
"match_json_params": {
303+
"dnsEntries": [
304+
{
305+
"name": "www",
306+
"expire": 86400,
307+
"type": "A",
308+
"content": "127.0.0.2"
309+
}
310+
]
311+
}
312+
},
313+
{
314+
"method": "PATCH",
315+
"url": "https://api.transip.nl/v6/domains/example.com/dns",
316+
"status": 204,
317+
"content_type": "application/json",
318+
"match_json_params": {
319+
"dnsEntry": {
320+
"name": "www",
321+
"expire": 86400,
322+
"type": "A",
323+
"content": "127.0.0.2"
324+
}
325+
}
326+
},
297327
{
298328
"method": "GET",
299329
"url": "https://api.transip.nl/v6/domains/example.com/nameservers",

tests/services/test_domains.py

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,12 +67,37 @@ def test_nameservers_list(self) -> None:
6767

6868
@responses.activate
6969
def test_dns_list(self) -> None:
70+
"""
71+
Check if the DNS records for a single domain can be listed.
72+
"""
7073
domain: Domain = self.client.domains.get("example.com") # type: ignore
7174
entries: List[DnsEntry] = domain.dns.list() # type: ignore
75+
76+
self.assertEqual(len(entries), 1)
7277
entry: DnsEntry = entries[0]
78+
self.assertEqual(entry.content, "127.0.0.1") # type: ignore
79+
80+
@responses.activate
81+
def test_dns_replace(self) -> None:
82+
"""
83+
Check if the existing DNS records for a single domain can be replaced
84+
at once.
85+
"""
86+
domain: Domain = self.client.domains.get("example.com") # type: ignore
87+
records: List[DnsEntry] = domain.dns.list() # type: ignore
88+
89+
# Ensure we have a single DNS record
90+
self.assertEqual(len(records), 1)
91+
# Update the content of the first DNS record in the list of existing
92+
# records.
93+
records[0].content = '127.0.0.2'
94+
95+
# Replace all existing records.
96+
try:
97+
domain.dns.replace(records) # type: ignore
98+
except Exception as exc:
99+
assert False, f"'transip.v6.objects.Domain.dns.replace' raised an exception {exc}"
73100

74-
assert len(entries) == 1
75-
assert entry.name == "www" # type: ignore
76101

77102
@responses.activate
78103
def test_dns_create(self) -> None:
@@ -87,6 +112,44 @@ def test_dns_create(self) -> None:
87112

88113
assert len(responses.calls) == 2
89114

115+
@responses.activate
116+
def test_dns_update(self) -> None:
117+
"""
118+
Check if a single DNS entry can be updated.
119+
"""
120+
dns_entry_data: Dict[str, Union[str, int]] = {
121+
"name": "www",
122+
"expire": 86400,
123+
"type": "A",
124+
"content": "127.0.0.2" # original content is 127.0.0.1
125+
}
126+
domain: Domain = self.client.domains.get("example.com") # type: ignore
127+
128+
try:
129+
domain.dns.update(dns_entry_data)
130+
except Exception as exc:
131+
assert False, f"'transip.v6.objects.Domain.dns.update' raised an exception {exc}"
132+
133+
@responses.activate
134+
def test_dns_update_object(self) -> None:
135+
"""
136+
Check if a single DNS entry can be updated from the ApiObject itself.
137+
"""
138+
domain: Domain = self.client.domains.get("example.com") # type: ignore
139+
entries: List[DnsEntry] = domain.dns.list() # type: ignore
140+
141+
self.assertEqual(len(entries), 1)
142+
entry: DnsEntry = entries[0]
143+
self.assertEqual(entry.content, "127.0.0.1") # type: ignore
144+
145+
entry.content = '127.0.0.2'
146+
try:
147+
entry.update()
148+
except Exception as exc:
149+
assert False, f"'transip.v6.objects.DnsEntry.update' raised an exception {exc}"
150+
151+
self.assertEqual(entry.content, "127.0.0.2") # type: ignore
152+
90153
@responses.activate
91154
def test_dns_delete(self) -> None:
92155
"""Check if a single DNS entry can be deleted."""

transip/mixins.py

Lines changed: 71 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,12 @@
2323
from transip.base import ApiObject, ApiService
2424

2525

26-
# Typing alias for the _create_attrs attribute in the CreateMixin
27-
CreateAttrsTuple = Tuple[
26+
# Typing alias for the _create_attrs, _update_attrs and _delete_attrs
27+
# attributes in the CreateMixin, UpdateMixin and DeleteMixin
28+
AttrsTuple = Tuple[
2829
Union[Tuple[()], Tuple[str, ...]],
2930
Union[Tuple[()], Tuple[str, ...]]
3031
]
31-
# Typing alias for the _update_attrs attribute in the UpdateMixin
32-
UpdateAttrsTuple = CreateAttrsTuple
3332

3433

3534
class GetMixin:
@@ -127,21 +126,22 @@ def list(self) -> List[Type[ApiObject]]:
127126

128127

129128
class UpdateMixin:
130-
"""Update an ApiObject.
129+
"""
130+
Update an ApiObject.
131131
"""
132132

133133
client: TransIP
134134
path: str
135135

136136
_req_update_attr: Optional[str] = None
137-
_update_attrs: Optional[CreateAttrsTuple] = None
137+
_update_attrs: Optional[AttrsTuple] = None
138138

139139
def get_update_attrs(self) -> Tuple[
140140
Union[Tuple[()], Tuple[str, ...]],
141141
Union[Tuple[()], Tuple[str, ...]]
142142
]:
143143
"""
144-
Return the required and optional attributes for updating a new object.
144+
Return the required and optional attributes for updating an object.
145145
146146
Returns:
147147
tuple: a tuple containing a tuple of required and optional
@@ -182,14 +182,76 @@ def update(
182182
self._check_update_attrs(data)
183183

184184
# Some endpoints require the attributes to be packed in dictionary with
185-
# a specific key while others endpoint do not
185+
# a specific key while others endpoint may not
186186
if self._req_update_attr:
187187
data = {self._req_update_attr: data}
188188

189189
if self.path:
190190
self.client.put(f"{self.path}/{id}", json=data)
191191

192192

193+
class ReplaceMixin:
194+
"""
195+
Replace a list of ApiObject at once by wiping to old objects and replacing
196+
them with the provided list of ApiObjects.
197+
"""
198+
199+
client: TransIP
200+
path: str
201+
202+
_req_replace_attr: Optional[str] = None
203+
_replace_attrs: Optional[AttrsTuple] = None
204+
205+
def get_replace_attrs(self) -> Tuple[
206+
Union[Tuple[()], Tuple[str, ...]],
207+
Union[Tuple[()], Tuple[str, ...]]
208+
]:
209+
"""
210+
Return the required and optional attributes for replacing a object.
211+
212+
Returns:
213+
tuple: a tuple containing a tuple of required and optional
214+
attributes.
215+
"""
216+
if not self._replace_attrs:
217+
return (tuple(), tuple())
218+
else:
219+
return self._replace_attrs
220+
221+
def replace(self, objs: List[Type[ApiObject]]) -> None:
222+
"""
223+
Replace all existing objects with the provided once.
224+
225+
Args:
226+
objs: List of ApiObjects to replace the existing once with.
227+
"""
228+
data = []
229+
230+
required, optional = self.get_replace_attrs()
231+
232+
for obj in objs:
233+
obj_data = {}
234+
235+
# Ensure all required attributes are added
236+
for attr in required:
237+
obj_data[attr] = getattr(obj, attr)
238+
# Ensure all optional attributes are added
239+
for attr in optional:
240+
obj_data[attr] = getattr(obj, attr)
241+
# Overwrite the existing attributes with any updated attributes
242+
obj_data.update(obj._updated_attrs) # type: ignore
243+
244+
data.append(obj_data)
245+
246+
# Some endpoints require the attributes to be packed in dictionary with
247+
# a specific key while others endpoint may not
248+
if self._req_replace_attr:
249+
data = {self._req_replace_attr: data} # type: ignore
250+
251+
if self.path:
252+
self.client.put(self.path, json=data)
253+
254+
193255
class CreateMixin:
194256
"""
195257
Create a new ApiObject.
@@ -198,7 +260,7 @@ class CreateMixin:
198260
path: str
199261

200262
_req_create_attr: Optional[str] = None
201-
_create_attrs: Optional[CreateAttrsTuple] = None
263+
_create_attrs: Optional[AttrsTuple] = None
202264

203265
def _check_create_attrs(self, attrs) -> None:
204266
"""

0 commit comments

Comments
 (0)