diff --git a/CHANGELOG.md b/CHANGELOG.md index 5526e1b1..62510038 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ This project uses [Semantic Versioning 2.0.0](http://semver.org/), the format is - Added `restoreDomain` to restore a domain. (#267) - Added `getDomainRestore` to retrieve the details of a domain restore. (#267) +- Added `batchChangeZoneRecords` to create, update, and delete records in a zone in one atomic request. (#273) ### Changed diff --git a/src/main/java/com/dnsimple/data/ZoneRecordBatchChange.java b/src/main/java/com/dnsimple/data/ZoneRecordBatchChange.java new file mode 100644 index 00000000..8b25746b --- /dev/null +++ b/src/main/java/com/dnsimple/data/ZoneRecordBatchChange.java @@ -0,0 +1,27 @@ +package com.dnsimple.data; + +import java.util.List; + +public class ZoneRecordBatchChange { + private final List creates; + private final List updates; + private final List deletes; + + public ZoneRecordBatchChange(List creates, List updates, List deletes) { + this.creates = creates; + this.updates = updates; + this.deletes = deletes; + } + + public List getCreates() { + return creates; + } + + public List getUpdates() { + return updates; + } + + public List getDeletes() { + return deletes; + } +} diff --git a/src/main/java/com/dnsimple/data/ZoneRecordBatchDelete.java b/src/main/java/com/dnsimple/data/ZoneRecordBatchDelete.java new file mode 100644 index 00000000..4b1b2a86 --- /dev/null +++ b/src/main/java/com/dnsimple/data/ZoneRecordBatchDelete.java @@ -0,0 +1,13 @@ +package com.dnsimple.data; + +public class ZoneRecordBatchDelete { + private final Long id; + + public ZoneRecordBatchDelete(Long id) { + this.id = id; + } + + public Long getId() { + return id; + } +} diff --git a/src/main/java/com/dnsimple/endpoints/Zones.java b/src/main/java/com/dnsimple/endpoints/Zones.java index b7b7c41f..9f38dcbe 100644 --- a/src/main/java/com/dnsimple/endpoints/Zones.java +++ b/src/main/java/com/dnsimple/endpoints/Zones.java @@ -4,8 +4,10 @@ import com.dnsimple.data.ZoneDistribution; import com.dnsimple.data.ZoneFile; import com.dnsimple.data.ZoneRecord; +import com.dnsimple.data.ZoneRecordBatchChange; import com.dnsimple.http.HttpEndpointClient; import com.dnsimple.request.ListOptions; +import com.dnsimple.request.ZoneRecordBatchChangeOptions; import com.dnsimple.request.ZoneRecordOptions; import com.dnsimple.request.ZoneRecordUpdateOptions; import com.dnsimple.response.EmptyResponse; @@ -187,6 +189,19 @@ public EmptyResponse deleteZoneRecord(Number account, String zone, Number record return client.empty(DELETE, account + "/zones/" + zone + "/records/" + record, ListOptions.empty(), null); } + /** + * Create, update, and delete records in a zone in one atomic request. + * + * @param account The account ID + * @param zone The zone name + * @param options The records to create, update, and delete + * @return The batch change zone records response + * @see https://developer.dnsimple.com/v2/zones/records/#batchChangeZoneRecords + */ + public SimpleResponse batchChangeZoneRecords(Number account, String zone, ZoneRecordBatchChangeOptions options) { + return client.simple(POST, account + "/zones/" + zone + "/batch", ListOptions.empty(), options.asPayload(), ZoneRecordBatchChange.class); + } + /** * Checks if a zone record change is fully distributed to all our nameservers of our regions. * diff --git a/src/main/java/com/dnsimple/request/ZoneRecordBatchChangeOptions.java b/src/main/java/com/dnsimple/request/ZoneRecordBatchChangeOptions.java new file mode 100644 index 00000000..32294a1c --- /dev/null +++ b/src/main/java/com/dnsimple/request/ZoneRecordBatchChangeOptions.java @@ -0,0 +1,67 @@ +package com.dnsimple.request; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static java.util.Collections.emptyList; + +public class ZoneRecordBatchChangeOptions { + private final List> creates; + private final List> updates; + private final List> deletes; + + private ZoneRecordBatchChangeOptions(List> creates, List> updates, List> deletes) { + this.creates = creates; + this.updates = updates; + this.deletes = deletes; + } + + public static ZoneRecordBatchChangeOptions empty() { + return new ZoneRecordBatchChangeOptions(emptyList(), emptyList(), emptyList()); + } + + /** + * Add a record to create + */ + public ZoneRecordBatchChangeOptions create(ZoneRecordOptions record) { + return new ZoneRecordBatchChangeOptions(append(creates, record.asPayload()), updates, deletes); + } + + /** + * Add a record to update + */ + public ZoneRecordBatchChangeOptions update(Number id, ZoneRecordUpdateOptions record) { + var payload = record.asPayload(); + payload.put("id", id); + return new ZoneRecordBatchChangeOptions(creates, append(updates, payload), deletes); + } + + /** + * Add a record to delete + */ + public ZoneRecordBatchChangeOptions delete(Number id) { + return new ZoneRecordBatchChangeOptions(creates, updates, append(deletes, Map.of("id", id))); + } + + /** + * @return a map with this object's attributes and prunes the empty operation lists + */ + public Map asPayload() { + var map = new HashMap(); + if (!creates.isEmpty()) + map.put("creates", creates); + if (!updates.isEmpty()) + map.put("updates", updates); + if (!deletes.isEmpty()) + map.put("deletes", deletes); + return map; + } + + private static List> append(List> list, Map item) { + var result = new ArrayList<>(list); + result.add(item); + return result; + } +} diff --git a/src/test/java/com/dnsimple/endpoints/ZoneRecordsTest.java b/src/test/java/com/dnsimple/endpoints/ZoneRecordsTest.java index dabdaf60..dec81569 100644 --- a/src/test/java/com/dnsimple/endpoints/ZoneRecordsTest.java +++ b/src/test/java/com/dnsimple/endpoints/ZoneRecordsTest.java @@ -1,8 +1,11 @@ package com.dnsimple.endpoints; import com.dnsimple.data.ZoneRecord; +import com.dnsimple.data.ZoneRecordBatchChange; +import com.dnsimple.exception.BadRequestException; import com.dnsimple.exception.ResourceNotFoundException; import com.dnsimple.request.ListOptions; +import com.dnsimple.request.ZoneRecordBatchChangeOptions; import com.dnsimple.request.ZoneRecordOptions; import com.dnsimple.request.ZoneRecordUpdateOptions; import com.dnsimple.response.PaginatedResponse; @@ -17,6 +20,7 @@ import static com.dnsimple.http.HttpMethod.*; import static com.dnsimple.tools.CustomMatchers.number; +import static com.dnsimple.tools.CustomMatchers.property; import static com.dnsimple.tools.CustomMatchers.thrownException; import static java.time.ZoneOffset.UTC; import static org.hamcrest.MatcherAssert.assertThat; @@ -162,4 +166,125 @@ public void testDeleteZoneRecord() { assertThat(server.getRecordedRequest().getMethod(), is(DELETE)); assertThat(server.getRecordedRequest().getPath(), is("/v2/1/zones/example.com/records/2")); } + + @Test + @SuppressWarnings("unchecked") + public void testBatchChangeZoneRecordsSendsCorrectRequest() { + server.stubFixtureAt("batchChangeZoneRecords/success.http"); + var options = ZoneRecordBatchChangeOptions.empty() + .create(ZoneRecordOptions.of("ab", "A", "3.2.3.4")) + .create(ZoneRecordOptions.of("ab", "A", "4.2.3.4").ttl(3600).priority(10).regions("SV1", "IAD")) + .update(67622534, ZoneRecordUpdateOptions.empty().content("3.2.3.40")) + .delete(67622509); + client.zones.batchChangeZoneRecords(1010, "example.com", options); + assertThat(server.getRecordedRequest().getMethod(), is(POST)); + assertThat(server.getRecordedRequest().getPath(), is("/v2/1010/zones/example.com/batch")); + var payload = server.getRecordedRequest().getJsonObjectPayload(); + var creates = (List>) payload.get("creates"); + assertThat(creates, hasSize(2)); + assertThat(creates.get(0), is(Map.of("name", "ab", "type", "A", "content", "3.2.3.4"))); + assertThat(creates.get(1), allOf( + hasEntry("name", "ab"), + hasEntry("type", "A"), + hasEntry("content", "4.2.3.4"), + hasEntry(is("ttl"), number(3600)), + hasEntry(is("priority"), number(10)) + )); + assertThat(creates.get(1).get("regions"), is(List.of("SV1", "IAD"))); + var updates = (List>) payload.get("updates"); + assertThat(updates, hasSize(1)); + assertThat(updates.get(0).keySet(), containsInAnyOrder("id", "content")); + assertThat(updates.get(0), allOf( + hasEntry(is("id"), number(67622534)), + hasEntry("content", "3.2.3.40") + )); + var deletes = (List>) payload.get("deletes"); + assertThat(deletes, hasSize(1)); + assertThat(deletes.get(0).keySet(), contains("id")); + assertThat(deletes.get(0), hasEntry(is("id"), number(67622509))); + } + + @Test + public void testBatchChangeZoneRecordsOmitsEmptyOperations() { + server.stubFixtureAt("batchChangeZoneRecords/success.http"); + var options = ZoneRecordBatchChangeOptions.empty().delete(67622509); + client.zones.batchChangeZoneRecords(1010, "example.com", options); + assertThat(server.getRecordedRequest().getJsonObjectPayload(), allOf( + hasKey("deletes"), + not(hasKey("creates")), + not(hasKey("updates")) + )); + } + + @Test + public void testBatchChangeZoneRecordsProducesBatchChange() { + server.stubFixtureAt("batchChangeZoneRecords/success.http"); + ZoneRecordBatchChange batch = client.zones.batchChangeZoneRecords(1010, "example.com", ZoneRecordBatchChangeOptions.empty()).getData(); + + assertThat(batch.getCreates(), hasSize(2)); + ZoneRecord created = batch.getCreates().get(0); + assertThat(created.getId(), is(67623409L)); + assertThat(created.getZoneId(), is("example.com")); + assertThat(created.getName(), is("ab")); + assertThat(created.getContent(), is("3.2.3.4")); + assertThat(created.getTtl(), is(3600)); + assertThat(created.getPriority(), is(nullValue())); + assertThat(created.getType(), is("A")); + assertThat(created.getRegions(), contains("global")); + assertThat(created.isSystemRecord(), is(false)); + assertThat(created.getCreatedAt(), is(OffsetDateTime.of(2025, 9, 5, 5, 25, 0, 0, UTC))); + assertThat(batch.getCreates().get(1).getId(), is(67623410L)); + + assertThat(batch.getUpdates(), hasSize(2)); + ZoneRecord updated = batch.getUpdates().get(0); + assertThat(updated.getId(), is(67622534L)); + assertThat(updated.getName(), is("update1-1757049890")); + assertThat(updated.getContent(), is("3.2.3.40")); + assertThat(updated.getUpdatedAt(), is(OffsetDateTime.of(2025, 9, 5, 5, 25, 0, 0, UTC))); + assertThat(batch.getUpdates().get(1).getId(), is(67622537L)); + + assertThat(batch.getDeletes(), hasSize(2)); + assertThat(batch.getDeletes().get(0).getId(), is(67622509L)); + assertThat(batch.getDeletes().get(1).getId(), is(67622527L)); + } + + @Test + public void testBatchChangeZoneRecordsWhenCreateValidationFails() { + server.stubFixtureAt("batchChangeZoneRecords/error_400_create_validation_failed.http"); + var options = ZoneRecordBatchChangeOptions.empty().create(ZoneRecordOptions.of("ab", "SPF", "v=spf1 -all")); + assertThat(() -> client.zones.batchChangeZoneRecords(1010, "example.com", options), allOf( + thrownException(is(instanceOf(BadRequestException.class))), + thrownException(property((BadRequestException e) -> e.getBody().get("message"), is("Validation failed"))), + thrownException(property((BadRequestException e) -> e.getAttributeErrors(), hasKey("creates"))) + )); + } + + @Test + public void testBatchChangeZoneRecordsWhenUpdateValidationFails() { + server.stubFixtureAt("batchChangeZoneRecords/error_400_update_validation_failed.http"); + var options = ZoneRecordBatchChangeOptions.empty().update(99999999, ZoneRecordUpdateOptions.empty().content("1.2.3.4")); + assertThat(() -> client.zones.batchChangeZoneRecords(1010, "example.com", options), allOf( + thrownException(is(instanceOf(BadRequestException.class))), + thrownException(property((BadRequestException e) -> e.getBody().get("message"), is("Validation failed"))), + thrownException(property((BadRequestException e) -> e.getAttributeErrors(), hasKey("updates"))) + )); + } + + @Test + public void testBatchChangeZoneRecordsWhenDeleteValidationFails() { + server.stubFixtureAt("batchChangeZoneRecords/error_400_delete_validation_failed.http"); + var options = ZoneRecordBatchChangeOptions.empty().delete(67622509); + assertThat(() -> client.zones.batchChangeZoneRecords(1010, "example.com", options), allOf( + thrownException(is(instanceOf(BadRequestException.class))), + thrownException(property((BadRequestException e) -> e.getBody().get("message"), is("Validation failed"))), + thrownException(property((BadRequestException e) -> e.getAttributeErrors(), hasKey("deletes"))) + )); + } + + @Test + public void testBatchChangeZoneRecordsWhenZoneNotFound() { + server.stubFixtureAt("notfound-zone.http"); + assertThat(() -> client.zones.batchChangeZoneRecords(1010, "example.com", ZoneRecordBatchChangeOptions.empty().delete(1)), + thrownException(is(instanceOf(ResourceNotFoundException.class)))); + } }