issue #6 - ResponseResultInfo#Errors: wrong object structure

issue #5 - Add batch DNS record operations and refactor Cloudflare API client.
This commit is contained in:
2025-12-25 14:15:36 +01:00
parent eb821a2218
commit b4ce867efc
10 changed files with 274 additions and 49 deletions
@@ -1,12 +1,10 @@
package codes.thischwa.cf;
import codes.thischwa.cf.model.AbstractEntity;
import codes.thischwa.cf.model.AbstractResponse;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.nio.charset.StandardCharsets;
import lombok.extern.slf4j.Slf4j;
import org.apache.hc.client5.http.classic.methods.HttpDelete;
import org.apache.hc.client5.http.classic.methods.HttpGet;
import org.apache.hc.client5.http.classic.methods.HttpPatch;
@@ -70,14 +68,14 @@ abstract class CfBasicHttpClient {
EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8)));
T respObj = objectMapper.readValue(result.responseBody, responseType);
if (!respObj.getResponseResultInfo().isSuccess()) {
log.error("API error.");
respObj.getResponseResultInfo().getErrors().forEach(e -> log.error(" - {}", e.toString()));
throw new CloudflareApiException("API error.");
}
logUri = request.getRequestUri();
if (result.statusCode >= 200 && result.statusCode < 300) {
T respObj = objectMapper.readValue(result.responseBody, responseType);
if (!respObj.getResponseResultInfo().isSuccess()) {
log.error("API error.");
respObj.getResponseResultInfo().getErrors().forEach(e -> log.error(" - {}", e));
throw new CloudflareApiException("API error.");
}
return respObj;
} else {
log.error("{} request failed for URL {}: Status {}", request.getMethod(), request.getUri(),
@@ -110,23 +108,25 @@ abstract class CfBasicHttpClient {
return executeRequest(request, (Class<T>) codes.thischwa.cf.model.RecordSingleResponse.class);
}
/**
* Sends a POST request with a payload to the given endpoint and maps the response.
*/
<T extends AbstractResponse, R extends AbstractEntity> T postRequest(String endpoint,
R requestPayload)
<T extends AbstractResponse> T postRequest(String endpoint,
Object requestPayload,
Class<T> responseType)
throws CloudflareApiException {
HttpPost request = new HttpPost(buildUrl(endpoint));
setRequestPayload(request, requestPayload);
return executeRequest(request, (Class<T>) codes.thischwa.cf.model.RecordSingleResponse.class);
return executeRequest(request, responseType);
}
/**
* Sends a PUT request with a payload to the given endpoint and maps the response.
*/
<T extends AbstractResponse, R extends AbstractEntity> T putRequest(String endpoint,
R requestPayload,
Class<T> responseType)
<T extends AbstractResponse> T putRequest(String endpoint,
Object requestPayload,
Class<T> responseType)
throws CloudflareApiException {
HttpPut request = new HttpPut(buildUrl(endpoint));
setRequestPayload(request, requestPayload);
@@ -136,8 +136,8 @@ abstract class CfBasicHttpClient {
/**
* Sends a PATCH request with a payload to the given endpoint and maps the response.
*/
<T extends AbstractResponse, R extends AbstractEntity> T patchRequest(String endpoint,
R requestPayload)
<T extends AbstractResponse> T patchRequest(String endpoint,
Object requestPayload)
throws CloudflareApiException {
HttpPatch request = new HttpPatch(buildUrl(endpoint));
setRequestPayload(request, requestPayload);
@@ -147,11 +147,13 @@ abstract class CfBasicHttpClient {
/**
* Sets the JSON payload for a request.
*/
private <R extends AbstractEntity> void setRequestPayload(BasicClassicHttpRequest request,
R requestPayload)
private void setRequestPayload(BasicClassicHttpRequest request,
Object requestPayload)
throws CloudflareApiException {
try {
request.setEntity(new StringEntity(objectMapper.writeValueAsString(requestPayload),
String jsonPayload = objectMapper.writeValueAsString(requestPayload);
log.debug("Request payload: {}", jsonPayload);
request.setEntity(new StringEntity(jsonPayload,
ContentType.APPLICATION_JSON));
} catch (JsonProcessingException e) {
throw new CloudflareApiException("Error serializing JSON payload", e);
@@ -1,6 +1,8 @@
package codes.thischwa.cf;
import codes.thischwa.cf.model.AbstractResponse;
import codes.thischwa.cf.model.BatchEntry;
import codes.thischwa.cf.model.BatchResponse;
import codes.thischwa.cf.model.PagingRequest;
import codes.thischwa.cf.model.RecordEntity;
import codes.thischwa.cf.model.RecordMultipleResponse;
@@ -8,9 +10,11 @@ import codes.thischwa.cf.model.RecordSingleResponse;
import codes.thischwa.cf.model.RecordType;
import codes.thischwa.cf.model.ZoneEntity;
import codes.thischwa.cf.model.ZoneMultipleResponse;
import java.util.ArrayList;
import java.util.List;
import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
import org.jetbrains.annotations.Nullable;
/**
* CfDnsClient is a client interface to interact with Cloudflare DNS service. It allows managing DNS
@@ -97,7 +101,7 @@ public class CfDnsClient extends CfBasicHttpClient {
* services.
*/
public CfDnsClient(boolean emptyResultThrowsException, String baseUrl, String authEmail,
String authKey) {
String authKey) {
super(baseUrl, authEmail, authKey);
this.responseValidator = new ResponseValidator(emptyResultThrowsException);
}
@@ -191,7 +195,7 @@ public class CfDnsClient extends CfBasicHttpClient {
String fqdn = buildFqdn(zone, sld);
String endpoint = CfRequest.RECORD_INFO_NAME_TYPE.buildPath(zone.getId(), fqdn, type);
RecordMultipleResponse resp = getRequest(endpoint, RecordMultipleResponse.class);
checkResponse(resp, true);
checkResponse(resp, false);
return resp.getResult().get(0);
}
@@ -209,7 +213,7 @@ public class CfDnsClient extends CfBasicHttpClient {
* or creating the record.
*/
public RecordEntity recordCreateSld(ZoneEntity zone, String sld, int ttl, RecordType type,
String content) throws CloudflareApiException {
String content) throws CloudflareApiException {
String fqdn = buildFqdn(zone, sld);
return recordCreate(zone, fqdn, ttl, type, content);
}
@@ -226,7 +230,7 @@ public class CfDnsClient extends CfBasicHttpClient {
* @throws CloudflareApiException if an error occurs while interacting with the Cloudflare API
*/
public RecordEntity recordCreate(ZoneEntity zone, String name, int ttl, RecordType type,
String content) throws CloudflareApiException {
String content) throws CloudflareApiException {
RecordEntity rec = RecordEntity.build(name, type, ttl, content);
return recordCreate(zone, rec);
}
@@ -244,7 +248,7 @@ public class CfDnsClient extends CfBasicHttpClient {
public RecordEntity recordCreate(ZoneEntity zone, RecordEntity rec)
throws CloudflareApiException {
String endpoint = CfRequest.RECORD_CREATE.buildPath(zone.getId());
RecordSingleResponse resp = postRequest(endpoint, rec);
RecordSingleResponse resp = postRequest(endpoint, rec, RecordSingleResponse.class);
checkResponse(resp);
log.info("Record {} of type {} successful created.", rec.getName(), rec.getType());
return resp.getResult();
@@ -262,7 +266,7 @@ public class CfDnsClient extends CfBasicHttpClient {
public boolean recordDelete(ZoneEntity zone, RecordEntity rec) throws CloudflareApiException {
boolean changed = recordDelete(zone, rec.getId());
if (changed) {
log.info("Record {} of the type {} successful deleted.", rec.getName(), rec.getType());
log.debug("Record {} of the type [{}] successful deleted.", rec.getName(), rec.getType());
} else {
log.warn("Record {} of the type {} was not deleted.", rec.getName(), rec.getType());
}
@@ -323,13 +327,55 @@ public class CfDnsClient extends CfBasicHttpClient {
try {
RecordEntity rec = sldInfo(zone, sld, recordType);
recordDelete(zone, rec);
log.info("Record {} of type {} successful deleted.", fqdn, recordTypes);
log.info("Record {} of type [{}] successful deleted.", fqdn, recordTypes);
} catch (CloudflareNotFoundException e) {
log.debug("Record {} of type {} does not exist.", fqdn, recordTypes);
}
}
}
/**
* Records a batch of DNS record operations, including creating, updating, and deleting records
* within a specific zone. This method processes the provided put, patch, and delete operations
* into a clean format before sending a batch request to the Cloudflare API.
*
* @param zone the zone entity representing the DNS zone where the changes will be applied
* @param ttl the time-to-live (TTL) value assigned to the new records being added
* @param postRecords a list of records to be created; each record must contain the necessary
* attributes for creation
* @param patchRecords a list of records to be updated; only specific attributes (e.g., content)
* will be modified
* @param deleteRecords a list of records to be deleted; each record must contain name, type, and content
* @throws CloudflareApiException if there is an error while communicating with the Cloudflare API
*/
public void recordBatch(ZoneEntity zone, int ttl, @Nullable List<RecordEntity> postRecords,
@Nullable List<RecordEntity> patchRecords, @Nullable List<RecordEntity> deleteRecords)
throws CloudflareApiException {
BatchEntry batchEntry = new BatchEntry();
// build 'clean' record entries
if (postRecords != null) {
List<RecordEntity> cleanedPosts = new ArrayList<>();
postRecords.forEach(
rec -> cleanedPosts.add(RecordEntity.build(rec.getId(), rec.getName(), rec.getType(), rec.getTtl(), rec.getContent())));
batchEntry.setPosts(cleanedPosts);
}
if (patchRecords != null) {
List<RecordEntity> cleanedPatches = new ArrayList<>();
patchRecords.forEach(rec -> cleanedPatches.add(RecordEntity.build(rec.getId(), rec.getContent())));
batchEntry.setPatches(cleanedPatches);
}
if (deleteRecords != null) {
List<RecordEntity> cleanedDeletes = new ArrayList<>();
deleteRecords.forEach(
rec -> cleanedDeletes.add(RecordEntity.build(rec.getId(), rec.getName(), rec.getType(), null, rec.getContent())));
batchEntry.setDeletes(cleanedDeletes);
}
String endpoint = CfRequest.RECORD_BATCH.buildPath(zone.getId());
BatchResponse resp = postRequest(endpoint, batchEntry, BatchResponse.class);
checkResponse(resp);
}
private static String buildFqdn(ZoneEntity zone, String sld) {
return sld + "." + zone.getName();
}
@@ -43,6 +43,13 @@ public enum CfRequest {
* identifier, which need to be provided to construct the complete path.
*/
RECORD_UPDATE("/zones/%s/dns_records/%s"),
/**
* Represents the API endpoint path for performing batch operations on DNS records within a specific zone.
* The placeholder "%s" in the path is intended to be replaced by a zone identifier.
* This constant is used to construct the URL for interacting with the batch DNS records API.
*/
RECORD_BATCH("/zones/%s/dns_records/batch"),
/**
* Represents the API endpoint path for deleting an existing DNS record within a specific DNS
* zone. The endpoint path includes placeholders for the zone identifier and the record
@@ -0,0 +1,39 @@
package codes.thischwa.cf.model;
import java.util.List;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* Represents a batch entry containing different types of operations on record entities.
*
* <p>A BatchEntry groups together collections of operations (patches, posts, puts, and deletes)
* intended to be performed as part of a single batch process. Each operation corresponds to a specific
* type of action on DNS record entities.
*
* <ul>
* <li><b>patches</b>: A list of {@link RecordEntity} objects representing partial updates to existing records.
* <li><b>posts</b>: A list of {@link RecordEntity} objects to be created as new DNS records.
* <li><b>puts</b>: A list of {@link RecordEntity} objects representing updates or replacements for existing records.
* <li><b>deletes</b>: A list of {@link RecordEntity} objects with name, type, and content to be removed.
* </ul>
*
* <p>This class is used as both a request body for batch operations and to represent the batch response.
*/
@EqualsAndHashCode(callSuper = true)
@Data
public class BatchEntry extends AbstractEntity {
List<RecordEntity> patches;
List<RecordEntity> posts;
List<RecordEntity> puts;
List<RecordEntity> deletes;
@Override
public String getId() {
return "";
}
}
@@ -0,0 +1,8 @@
package codes.thischwa.cf.model;
public class BatchResponse extends AbstractSingleResponse<BatchEntry> {
BatchResponse() {
super();
}
}
@@ -16,7 +16,7 @@ import org.jetbrains.annotations.Nullable;
* <li>Content of the DNS record, such as an IP address.
* <li>Flags indicating whether the record is proxiable or proxied.
* <li>TTL (Time-To-Live) for the DNS record.
* <li>A locked status to indicate immutability of the record.
* <li>A locked status to indicate the immutability of the record.
* <li>Zone-specific metadata including zone ID and name.
* <li>Timestamps for creation and modification.
* </ul>
@@ -55,15 +55,35 @@ public class RecordEntity extends AbstractEntity {
* @param name the name of the DNS record
* @param type the {@link RecordType} of the DNS record
* @param ttl the time-to-live (TTL) value for the DNS record
* @param ip the content of the DNS record, typically an IP address
* @param content the content of the DNS record, typically an IP address
* @return a {@link RecordEntity} populated with the provided attributes
*/
public static RecordEntity build(String name, RecordType type, Integer ttl, String ip) {
public static RecordEntity build(String name, RecordType type, Integer ttl, String content) {
RecordEntity rec = new RecordEntity();
rec.setName(name);
rec.setType(type.getType());
rec.setTtl(ttl);
rec.setContent(ip);
rec.setContent(content);
return rec;
}
/**
* Builds and returns a {@link RecordEntity} instance with the specified ID and content.
*
* @param id the unique identifier for the DNS record
* @param content the content of the DNS record, typically an IP address or other record data
* @return a {@link RecordEntity} populated with the provided ID and content
*/
public static RecordEntity build(String id, String content) {
RecordEntity rec = new RecordEntity();
rec.setId(id);
rec.setContent(content);
return rec;
}
public static RecordEntity build(String id, String name, String type, Integer ttl, String content) {
RecordEntity rec = build(name, RecordType.valueOf(type), ttl, content);
rec.setId(id);
return rec;
}
}
@@ -6,6 +6,7 @@ import lombok.Data;
/**
* Represents the result of a response with metadata about its success and associated messages or
* errors.
*
* <p>This class provides a structure to capture the outcome of an operation, including:
* <ul>
* <li>Whether the operation was successful.
@@ -17,6 +18,25 @@ import lombok.Data;
@Data
public class ResponseResultInfo {
private boolean success;
private List<String> errors;
private List<Error> errors;
private List<String> messages;
@Data
public static class Error {
private int code;
private String message;
public Error() {
}
public Error(int code, String message) {
this.code = code;
this.message = message;
}
@Override
public String toString() {
return String.format("%d: %s", code, message);
}
}
}
@@ -1,17 +1,17 @@
package codes.thischwa.cf;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assumptions.assumeTrue;
import codes.thischwa.cf.model.RecordEntity;
import codes.thischwa.cf.model.RecordType;
import codes.thischwa.cf.model.ZoneEntity;
import java.util.List;
import java.util.Objects;
import lombok.extern.slf4j.Slf4j;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assumptions.assumeTrue;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
@@ -149,4 +149,48 @@ public class CfClientTest {
assertThrows(IllegalArgumentException.class, () -> new CfDnsClient("email", ""));
assertThrows(IllegalArgumentException.class, () -> new CfDnsClient("", "key"));
}
@Test
void testBatch() throws Exception {
// starting point: already existing zone 'mein-d-ns.de'
ZoneEntity z = client.zoneInfo(ZONE_STR);
String sld1 = SLD_STR + "-1";
String sld2 = SLD_STR + "-2";
String sld3 = SLD_STR + "-3";
RecordEntity r1 = RecordEntity.build(sld1, RecordType.A, TTL, "130.0.0.1");
RecordEntity r2 = RecordEntity.build(sld2, RecordType.A, TTL, "130.0.0.2");
RecordEntity r3 = RecordEntity.build(sld3, RecordType.A, TTL, "130.0.0.3");
// ensure clean state
client.recordDeleteTypeIfExists(z, sld1, RecordType.A);
client.recordDeleteTypeIfExists(z, sld2, RecordType.A);
client.recordDeleteTypeIfExists(z, sld3, RecordType.A);
try {
// test put
client.recordBatch(z, 30, List.of(r1, r2, r3), null, null);
RecordEntity testRec = client.sldInfo(z, sld1, RecordType.A);
assertEquals("130.0.0.1", testRec.getContent());
testRec = client.sldInfo(z, sld2, RecordType.A);
assertEquals("130.0.0.2", testRec.getContent());
testRec = client.sldInfo(z, sld3, RecordType.A);
assertEquals("130.0.0.3", testRec.getContent());
// test patch
r1 = client.sldInfo(z, sld1, RecordType.A);
r1.setContent("130.1.0.1");
client.recordBatch(z, 30, null, List.of(r1), null);
testRec = client.sldInfo(z, sld1, RecordType.A);
assertEquals("130.1.0.1", testRec.getContent());
// test delete
client.recordBatch(z, 30, null, null, List.of(r1));
assertThrows(CloudflareNotFoundException.class,
() -> client.sldInfo(z, sld1, RecordType.A));
} finally {
client.recordDeleteTypeIfExists(z, sld1, RecordType.A);
client.recordDeleteTypeIfExists(z, sld2, RecordType.A);
client.recordDeleteTypeIfExists(z, sld3, RecordType.A);
}
}
}
@@ -1,20 +1,53 @@
package codes.thischwa.cf;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.fail;
import codes.thischwa.cf.model.AbstractResponse;
import codes.thischwa.cf.model.BatchResponse;
import codes.thischwa.cf.model.RecordMultipleResponse;
import codes.thischwa.cf.model.RecordSingleResponse;
import codes.thischwa.cf.model.ResponseResultInfo;
import codes.thischwa.cf.model.ZoneMultipleResponse;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import java.io.InputStream;
import java.util.List;
import org.junit.jupiter.api.Test;
public class ObjectMapperTest {
private final ObjectMapper mapper = JsonConf.initObjectMapper();
@Test
void testObjectMapper() throws IOException {
ObjectMapper mapper = JsonConf.initObjectMapper();
ZoneMultipleResponse resp =
mapper.readValue(this.getClass().getResourceAsStream("/zone-list-response.json"),
ZoneMultipleResponse.class);
mapper.readValue(this.getClass().getResourceAsStream("/zone-list-response.json"),
ZoneMultipleResponse.class);
assertNotNull(resp.getResponseResultInfo());
}
@Test
void testErrorResponse() throws IOException {
List<Class<? extends AbstractResponse>> respClasses =
List.of(RecordSingleResponse.class, RecordMultipleResponse.class, ZoneMultipleResponse.class, BatchResponse.class);
respClasses.forEach(this::assertErrorResponse);
}
private void assertErrorResponse(Class<? extends AbstractResponse> clazz) {
InputStream in = this.getClass().getResourceAsStream("/error-response.json");
try {
AbstractResponse resp = mapper.readValue(in, clazz);
assertNotNull(resp);
assertNotNull(resp.getResponseResultInfo());
ResponseResultInfo resultInfo = resp.getResponseResultInfo();
assertFalse(resultInfo.isSuccess());
assertEquals(1, resultInfo.getErrors().size());
assertEquals(81053, resultInfo.getErrors().get(0).getCode());
} catch (IOException e) {
fail("fail for " + clazz + ": " + e.getMessage());
}
}
}
@@ -1,16 +1,16 @@
package codes.thischwa.cf;
import codes.thischwa.cf.model.AbstractResponse;
import codes.thischwa.cf.model.RecordMultipleResponse;
import codes.thischwa.cf.model.ResponseResultInfo;
import codes.thischwa.cf.model.ResultInfo;
import java.util.Arrays;
import lombok.Getter;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.when;
import codes.thischwa.cf.model.AbstractResponse;
import codes.thischwa.cf.model.RecordMultipleResponse;
import codes.thischwa.cf.model.ResponseResultInfo;
import codes.thischwa.cf.model.ResultInfo;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -48,9 +48,15 @@ class ResponseValidatorTest {
@Test
void validateFailedResponse() {
List<ResponseResultInfo.Error> errors = new ArrayList<>();
ResponseResultInfo.Error error = new ResponseResultInfo.Error(1, "Fehler 1");
errors.add(error);
error = new ResponseResultInfo.Error(2, "Fehler 2");
errors.add(error);
when(mockResponse.getResponseResultInfo()).thenReturn(mockResultInfo);
when(mockResultInfo.isSuccess()).thenReturn(false);
when(mockResultInfo.getErrors()).thenReturn(Arrays.asList("Fehler 1", "Fehler 2"));
when(mockResultInfo.getErrors()).thenReturn(errors);
CloudflareApiException exception = assertThrows(CloudflareApiException.class,
() -> validatorWithException.validate(mockResponse, false));