Change default behavior of `emptyResultThrowsException` to `false`, update `ResponseValidator` for improved handling of single and multiple results, and enhance test coverage and documentation.

Add Fluent API for DNS operations, update `README`, and refactor `CfDnsClient` for zone-level chaining.

Validate record type in `RecordEntity#build`, throw exception for invalid types, and add test coverage.

Refactor `RecordEntity#getSld` to handle `zoneName` cases, improve null safety, and enhance substring logic.

Refactor `RecordEntity` getter (`getName` → `getSld`) for clarity, improve string handling in batch processing, and enhance type safety in HTTP operations. Update tests and documentation accordingly.

Refactor `deleteRequest` and `patchRequest` to accept `responseType` parameter for improved flexibility. Extract methods for cleaning and processing batch DNS records.

Refactor API method names for consistency (`zoneListAll` → `zoneList`, `zoneInfo` → `zoneGet`, `sldListAll` → `recordList`, `sldInfo` → `recordGet`) and update related documentation and tests.
This commit is contained in:
2026-01-05 17:59:26 +01:00
parent 1bfea09aa9
commit a221de4792
14 changed files with 760 additions and 159 deletions
+263 -46
View File
@@ -50,6 +50,16 @@ The dependency is:
## Changelog
- 0.2.0-beta-SNAPSHOT:
- **New Fluent API**: Added chainable method interface for more readable DNS operations (
`client.zone().record()...`)
- **Breaking Change**: `emptyResultThrowsException` default changed from `true` to `false`. Now applies to both
single and multiple result requests. Empty results will be returned by default without throwing exceptions.
- API method names refactored for consistency: `zoneListAll``zoneList`, `zoneInfo``zoneGet`, `sldListAll`
`recordList`, `sldInfo``recordGet`
- RecordEntity getter methods renamed for clarity: `getName()``getSld()`
- Code quality improvements: eliminated duplication in batch operations, improved type safety in HTTP methods,
optimized string concatenation, removed mutable setters from CfDnsClient
- Enhanced type validation in `RecordEntity.build()` with better error messages
- CfClient#sldInfo must return multiple RecordEntries
- add a missing source jar
- ResponseResultInfo#Errors: wrong object structure
@@ -68,6 +78,11 @@ The methods can be categorized as follows:
- `Zone`: list, info
- `Record`: list, info, create, update, delete
The API provides two styles for working with DNS records:
1. **Traditional API**: Direct method calls with explicit parameters
2. **Fluent API**: Chainable method calls for more readable code
The following text focuses on the basic methods. For further information, take a look at
the [javadoc of the CfDnsClient](https://cloudflaredns-java-f4ee3a.gitlab.io/apidocs/codes/thischwa/cf/CfDnsClient.html).
@@ -79,20 +94,20 @@ CfDnsClient cfDnsClient = new CfDnsClient(
);
```
### `zoneListAll`
### `zoneList`
Retrieve all zones within the Cloudflare account.
- **Returns**: A list of `ZoneEntity` objects.
```java
List<ZoneEntity> zones = cfDnsClient.zoneListAll();
List<ZoneEntity> zones = cfDnsClient.zoneList();
zones.forEach(zone -> System.out.println("Zone: " + zone.getName()));
```
---
### `zoneInfo`
### `zoneGet`
Get detailed information about a specific zone by its name.
@@ -101,13 +116,13 @@ Get detailed information about a specific zone by its name.
- **Returns**: A `ZoneEntity` object.
```java
ZoneEntity zone = cfDnsClient.zoneInfo("example.com");
ZoneEntity zone = cfDnsClient.zoneGet("example.com");
System.out.println("Zone ID: " + zone.getId());
```
---
### `sldListAll`
### `recordList`
Retrieve all records for a specific second-level domain (SLD) under a given zone.
@@ -117,15 +132,17 @@ Retrieve all records for a specific second-level domain (SLD) under a given zone
- **Returns**: A list of `RecordEntity` objects.
```java
List<RecordEntity> records = cfDnsClient.sldListAll(zone, "sld");
records.forEach(record ->
List<RecordEntity> records = cfDnsClient.recordList(zone, "sld");
records.
forEach(record ->
System.out.println("Record Type: " + record.getType() + ", Value: " + record.getContent())
);
```
---
### `sldInfo`
### `recordGet`
Retrieve DNS record details for a specific SLD and zone, optionally filtered by record types.
@@ -138,28 +155,28 @@ Retrieve DNS record details for a specific SLD and zone, optionally filtered by
```java
// Get all records for a specific SLD
List<RecordEntity> allRecords = cfDnsClient.sldInfo(zone, "www");
List<RecordEntity> allRecords = cfDnsClient.recordGet(zone, "www");
allRecords.
forEach(record ->
System.out.
println("Type: "+record.getType() +", Content: "+record.
println("Type: "+record.getType()+", Content: "+record.
getContent())
);
getContent()));
// Get only A records
List<RecordEntity> aRecords = cfDnsClient.sldInfo(zone, "www", RecordType.A);
List<RecordEntity> aRecords = cfDnsClient.recordGet(zone, "www", RecordType.A);
System.out.
println("Found "+aRecords.size() +" A records");
// Get A and AAAA records
List<RecordEntity> ipRecords = cfDnsClient.sldInfo(zone, "www", RecordType.A, RecordType.AAAA);
List<RecordEntity> ipRecords = cfDnsClient.recordGet(zone, "www", RecordType.A, RecordType.AAAA);
ipRecords.
forEach(record ->System.out.
forEach(record ->
System.out.
println("IP Record: "+record.getContent()));
```
@@ -232,20 +249,25 @@ Process multiple DNS record operations (POST, PUT, PATCH, DELETE) in a single ba
- **Parameters**:
- `ZoneEntity zone` - The target zone.
- `List<RecordEntity> postRecords` - Records to create (nullable).
- `List<RecordEntity> putRecords` - Records to fully replace (nullable).
- `List<RecordEntity> patchRecords` - Records to partially update (nullable).
- `List<RecordEntity> deleteRecords` - Records to delete (nullable).
- `List<RecordEntity> postRecords` - Records to create (nullable). Can be built without IDs.
- `List<RecordEntity> putRecords` - Records to fully replace (nullable). **Requires record IDs**.
- `List<RecordEntity> patchRecords` - Records to partially update (nullable). **Requires record IDs**.
- `List<RecordEntity> deleteRecords` - Records to delete (nullable). **Requires record IDs**.
- **Returns**: A `BatchEntry` object containing the processed records.
**Important**: For UPDATE (PATCH), REPLACE (PUT), and DELETE operations, you must first retrieve the existing records to
obtain their IDs.
#### Batch Create (POST)
Create new records, IDs are not required:
```java
List<RecordEntity> newRecords = Arrays.asList(
RecordEntity.build("api." + zone.getName(), RecordType.A, 60, "192.168.1.10"),
RecordEntity.build("cdn." + zone.getName(), RecordType.A, 60, "192.168.1.11"),
RecordEntity.build("mail." + zone.getName(), RecordType.A, 60, "192.168.1.12")
);
BatchEntry result = cfDnsClient.recordBatch(zone, newRecords, null, null, null);
System.out.
@@ -256,18 +278,30 @@ size() +" records.");
#### Batch Update (PATCH)
Partially update existing records. **Record IDs are required** - fetch them first:
```java
// Fetch existing records and modify them
List<RecordEntity> recordsToUpdate = Arrays.asList(
cfDnsClient.sldInfo(zone, "api", RecordType.A),
cfDnsClient.sldInfo(zone, "cdn", RecordType.A)
);
// Step 1: Fetch existing records to get their IDs
List<RecordEntity> recordsToUpdate = new ArrayList<>();
recordsToUpdate.
add(cfDnsClient.recordGet(zone, "api",RecordType.A).
get(0));
recordsToUpdate.
add(cfDnsClient.recordGet(zone, "cdn",RecordType.A).
get(0));
// Step 2: Modify only the fields you want to update
recordsToUpdate.
forEach(record ->record.
setContent("192.168.2.10"));
// Step 3: Send batch update request
BatchEntry result = cfDnsClient.recordBatch(zone, null, null, recordsToUpdate, null);
System.out.
@@ -278,14 +312,25 @@ size() +" records.");
#### Batch Replace (PUT)
Fully replace existing records. **Record IDs are required** - fetch them first:
```java
// Fetch existing records and fully replace them
List<RecordEntity> recordsToReplace = Arrays.asList(
cfDnsClient.sldInfo(zone, "api", RecordType.A),
cfDnsClient.sldInfo(zone, "cdn", RecordType.A)
);
// Step 1: Fetch existing records to get their IDs
List<RecordEntity> recordsToReplace = new ArrayList<>();
recordsToReplace.
add(cfDnsClient.recordGet(zone, "mail",RecordType.A).
get(0));
recordsToReplace.
add(cfDnsClient.recordGet(zone, "cdn",RecordType.A).
get(0));
// Step 2: Modify all fields as needed (full replacement)
recordsToReplace.
get(0).
setContent("192.168.3.10");
@@ -294,7 +339,18 @@ recordsToReplace.
get(0).
setTtl(120);
recordsToReplace.
get(1).
setContent("192.168.3.11");
recordsToReplace.
get(1).
setTtl(120);
// Step 3: Send batch replace request
BatchEntry result = cfDnsClient.recordBatch(zone, null, recordsToReplace, null, null);
System.out.
@@ -305,28 +361,177 @@ size() +" records.");
#### Batch Delete
Delete existing records. **Record IDs are required** - fetch them first:
```java
List<RecordEntity> recordsToDelete = Arrays.asList(
cfDnsClient.sldInfo(zone, "api", RecordType.A),
cfDnsClient.sldInfo(zone, "mail", RecordType.A)
);
// Step 1: Fetch existing records to get their IDs
List<RecordEntity> recordsToDelete = new ArrayList<>();
recordsToDelete.
add(cfDnsClient.recordGet(zone, "cdn",RecordType.A).
get(0));
recordsToDelete.
add(cfDnsClient.recordGet(zone, "mail",RecordType.A).
get(0));
// Step 2: Send batch delete request
BatchEntry result = cfDnsClient.recordBatch(zone, null, null, null, recordsToDelete);
System.out.
println("Deleted records.");
println("Deleted "+recordsToDelete.size() +" records.");
```
#### Combined Batch Operations
Combine multiple operations in a single batch request:
```java
// You can combine multiple operations in a single batch request
BatchEntry result = cfDnsClient.recordBatch(
zone,
newRecords, // POST
putRecords, // PUT
patchRecords, // PATCH
deleteRecords // DELETE
// Create new records (no IDs needed)
List<RecordEntity> newRecords = Arrays.asList(
RecordEntity.build("new-api." + zone.getName(), RecordType.A, 60, "192.168.1.100")
);
// Fetch existing records for update/delete (IDs required)
List<RecordEntity> recordsToUpdate = Arrays.asList(
cfDnsClient.recordGet(zone, "existing-api", RecordType.A).get(0)
);
recordsToUpdate.
get(0).
setContent("192.168.1.200");
List<RecordEntity> recordsToDelete = Arrays.asList(
cfDnsClient.recordGet(zone, "old-api", RecordType.A).get(0)
);
// Execute all operations in a single batch request
BatchEntry result = cfDnsClient.recordBatch(
zone,
newRecords, // POST - create new records
null, // PUT - not used in this example
recordsToUpdate, // PATCH - update existing records
recordsToDelete // DELETE - remove records
);
System.out.
println("Batch completed:");
System.out.
println(" Created: "+result.getPosts().
size());
System.out.
println(" Updated: "+result.getPatches().
size());
System.out.
println(" Deleted: "+result.getDeletes().
size());
```
---
## Fluent API
The fluent API provides a chainable, readable interface for DNS operations. It's an alternative to the traditional API
that reduces verbosity and improves code readability.
### Basic Usage
```java
// Create a DNS record
client.zone("example.com")
.
record("api")
.
create(RecordType.A, "192.168.1.1",60);
// Get DNS records
List<RecordEntity> records = client.zone("example.com")
.record("www", RecordType.A)
.get();
// Update a DNS record
RecordEntity updated = client.zone("example.com")
.record("api", RecordType.A)
.update("192.168.1.2");
// Delete DNS records
client.
zone("example.com")
.
record("old-service")
.
delete(RecordType.A, RecordType.AAAA);
```
### Advantages of Fluent API
- **More Readable**: The chain of method calls reads like natural language
- **Less Verbose**: No need to pass zone objects between method calls
- **Type Safe**: Full compile-time type checking
- **IDE Friendly**: Excellent autocomplete support
### Complete Example
```java
CfDnsClient client = new CfDnsClient("email@example.com", "yourApiKey");
// Create a new record
client.
zone("example.com")
.
record("api")
.
create(RecordType.A, "192.168.100.1",60);
// Retrieve and verify
List<RecordEntity> records = client.zone("example.com")
.record("api", RecordType.A)
.get();
System.out.
println("IP: "+records.get(0).
getContent());
// Update the record
client.
zone("example.com")
.
record("api",RecordType.A)
.
update("192.168.100.2");
// Clean up
client.
zone("example.com")
.
record("api")
.
delete(RecordType.A);
```
---
@@ -335,21 +540,33 @@ BatchEntry result = cfDnsClient.recordBatch(
The `CfDnsClient` provides internal error-handling mechanisms through exceptions. For example:
- `CloudflareApiException` is thrown for errors during API communication or invalid responses.
- `CloudflareNotFoundException` is thrown when the requested single resource is not found, if enabled via the `emptyResultThrowsException` flag during initialization.
- `CloudflareNotFoundException` is thrown when the requested resource (single or multiple) is not found, if enabled via
the `emptyResultThrowsException` flag during initialization. **Default is `false`**, meaning empty results will be
returned without throwing an exception.
To enable exception throwing for empty results:
```java
CfDnsClient client = new CfDnsClient(true, "email@example.com", "yourApiKey");
```
#### Example:
```java
try {
RecordEntity record = cfDnsClient.sldInfo(zone, "www", RecordType.A);
System.out.println("Record IP: " + record.getContent());
List<RecordEntity> records = cfDnsClient.recordGet(zone, "www", RecordType.A);
System.out.
println("Record IP: "+records.get(0).
getContent());
} catch (CloudflareApiException e) {
if (e instanceof CloudflareNotFoundException) {
log.warn("Sld not found: www");
} else {
log.error("Error while getting sld info of www", e);
throw e;
}
}
}
```
@@ -102,10 +102,16 @@ abstract class CfBasicHttpClient {
/**
* Sends a DELETE request to the given endpoint and maps the response.
*
* @param endpoint the API endpoint path
* @param responseType the expected response type class
* @param <T> the response type extending AbstractResponse
* @return the parsed response object
* @throws CloudflareApiException if an error occurs during the request
*/
<T extends AbstractResponse> T deleteRequest(String endpoint) throws CloudflareApiException {
<T extends AbstractResponse> T deleteRequest(String endpoint, Class<T> responseType) throws CloudflareApiException {
HttpDelete request = new HttpDelete(buildUrl(endpoint));
return executeRequest(request, (Class<T>) codes.thischwa.cf.model.RecordSingleResponse.class);
return executeRequest(request, responseType);
}
@@ -135,13 +141,21 @@ abstract class CfBasicHttpClient {
/**
* Sends a PATCH request with a payload to the given endpoint and maps the response.
*
* @param endpoint the API endpoint path
* @param requestPayload the payload to send
* @param responseType the expected response type class
* @param <T> the response type extending AbstractResponse
* @return the parsed response object
* @throws CloudflareApiException if an error occurs during the request
*/
<T extends AbstractResponse> T patchRequest(String endpoint,
Object requestPayload)
Object requestPayload,
Class<T> responseType)
throws CloudflareApiException {
HttpPatch request = new HttpPatch(buildUrl(endpoint));
setRequestPayload(request, requestPayload);
return executeRequest(request, (Class<T>) codes.thischwa.cf.model.RecordSingleResponse.class);
return executeRequest(request, responseType);
}
/**
@@ -1,5 +1,7 @@
package codes.thischwa.cf;
import codes.thischwa.cf.fluent.ZoneOperations;
import codes.thischwa.cf.fluent.ZoneOperationsImpl;
import codes.thischwa.cf.model.AbstractResponse;
import codes.thischwa.cf.model.BatchEntry;
import codes.thischwa.cf.model.BatchResponse;
@@ -12,7 +14,6 @@ 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;
@@ -29,19 +30,18 @@ import org.jetbrains.annotations.Nullable;
* "yourApiKey"
* );
* // Retrieve a zone
* ZoneEntity zone = cfDnsClient.zoneInfo("example.com");
* ZoneEntity zone = cfDnsClient.zoneGet("example.com");
* System.out.println("Zone ID: " + zone.getId());
* // Retrieve records of a subdomain
* List&lt;{@link RecordEntity}&gt; records = cfDnsClient.sldListAll(zone, "sld");
* List&lt;{@link RecordEntity}&gt; records = cfDnsClient.recordGet(zone, "sld");
* records.forEach(record ->
* System.out.println("Record Type: " + record.getType() + ", Value: " + record.getContent())
* );
* // Create a record for the subdomain "api"
* RecordEntity created = client.recordCreateSld(zone, "api", 60, RecordType.A, "192.168.10);
* RecordEntity created = cfDnsClient.recordCreateSld(zone, "api", 60, RecordType.A, "192.168.1.10");
* System.out.println("Created Record ID: " + created.getId());
* </code></pre>
*/
@Setter
@Slf4j
public class CfDnsClient extends CfBasicHttpClient {
private static final String DEFAULT_BASEURL = "https://api.cloudflare.com/client/v4";
@@ -70,15 +70,15 @@ public class CfDnsClient extends CfBasicHttpClient {
* process.
*/
public CfDnsClient(String baseUrl, String authEmail, String authKey) {
this(true, baseUrl, authEmail, authKey);
this(false, baseUrl, authEmail, authKey);
}
/**
* Constructs a new instance of {@code CfDnsClient}.
*
* @param emptyResultThrowsException A boolean value indicating whether an exception should be
* thrown when the result is empty, it's valid for 'list
* requests' only. Default is true.
* thrown when the result is empty. Applies to both single and
* multiple result requests. Default is false.
* @param authEmail The email address associated with the Cloudflare account,
* used for authentication.
* @param authKey The API key of the Cloudflare account, used as part of the
@@ -92,8 +92,8 @@ public class CfDnsClient extends CfBasicHttpClient {
* Constructs a new instance of {@code CfDnsClient}.
*
* @param emptyResultThrowsException A boolean value indicating whether an exception should be
* thrown when the result is empty, it's valid for 'list
* requests' only. Default is true.
* thrown when the result is empty. Applies to both single and
* multiple result requests. Default is false.
* @param baseUrl The base URL for the Cloudflare API endpoint.
* @param authEmail The email associated with the Cloudflare account for
* authentication.
@@ -110,14 +110,35 @@ public class CfDnsClient extends CfBasicHttpClient {
return sld + "." + zone.getName();
}
/**
* Provides fluent API access to operations on a specific zone.
* This method returns a ZoneOperations interface that allows chaining operations
* on DNS records within the specified zone.
*
* <p>Example:
* <pre><code>
* client.zone("example.com")
* .record("api")
* .create(RecordType.A, "192.168.1.1", 60);
* </code></pre>
*
* @param zoneName the name of the DNS zone (e.g., "example.com")
* @return a ZoneOperations instance for chaining operations
* @throws CloudflareApiException if the zone cannot be found or accessed
*/
public ZoneOperations zone(String zoneName) throws CloudflareApiException {
ZoneEntity zoneEntity = zoneGet(zoneName);
return new ZoneOperationsImpl(this, zoneEntity);
}
/**
* Retrieves a list of all zones from the Cloudflare API.
*
* @return A list of ZoneEntity objects representing the zones retrieved from the Cloudflare API.
* @throws CloudflareApiException If an error occurs during the API request or response handling.
*/
public List<ZoneEntity> zoneListAll() throws CloudflareApiException {
return zoneListAll(PagingRequest.defaultPaging());
public List<ZoneEntity> zoneList() throws CloudflareApiException {
return zoneList(PagingRequest.defaultPaging());
}
/**
@@ -129,7 +150,7 @@ public class CfDnsClient extends CfBasicHttpClient {
* @throws CloudflareApiException if there is an error during the API request or response
* processing
*/
public List<ZoneEntity> zoneListAll(PagingRequest pagingRequest) throws CloudflareApiException {
public List<ZoneEntity> zoneList(PagingRequest pagingRequest) throws CloudflareApiException {
String endpoint = pagingRequest.addQueryString(CfRequest.ZONE_LIST.buildPath());
ZoneMultipleResponse response = getRequest(endpoint, ZoneMultipleResponse.class);
checkResponse(response);
@@ -144,7 +165,7 @@ public class CfDnsClient extends CfBasicHttpClient {
* @throws CloudflareApiException If an error occurs while making the API request or processing
* the response.
*/
public ZoneEntity zoneInfo(String name) throws CloudflareApiException {
public ZoneEntity zoneGet(String name) throws CloudflareApiException {
String endpoint = CfRequest.ZONE_INFO.buildPath(name);
ZoneMultipleResponse response = getRequest(endpoint, ZoneMultipleResponse.class);
checkResponse(response, true);
@@ -160,8 +181,8 @@ public class CfDnsClient extends CfBasicHttpClient {
* @return A list of {@code RecordEntity} associated with the desired SLD.
* @throws CloudflareApiException If an error occurs while interacting with the Cloudflare API.
*/
public List<RecordEntity> sldListAll(ZoneEntity zone, String sld) throws CloudflareApiException {
return sldListAll(zone, sld, PagingRequest.defaultPaging());
public List<RecordEntity> recordList(ZoneEntity zone, String sld) throws CloudflareApiException {
return recordList(zone, sld, PagingRequest.defaultPaging());
}
/**
@@ -174,7 +195,7 @@ public class CfDnsClient extends CfBasicHttpClient {
* @return A list of {@code RecordEntity} associated with the desired SLD.
* @throws CloudflareApiException If an error occurs while interacting with the Cloudflare API.
*/
public List<RecordEntity> sldListAll(ZoneEntity zone, String sld, PagingRequest pagingRequest)
public List<RecordEntity> recordList(ZoneEntity zone, String sld, PagingRequest pagingRequest)
throws CloudflareApiException {
String fqdn = buildFqdn(zone, sld);
String endpoint =
@@ -194,12 +215,13 @@ public class CfDnsClient extends CfBasicHttpClient {
* @throws CloudflareNotFoundException if the specified SLD is not found in the zone
* @throws CloudflareApiException if an error occurs while interacting with the Cloudflare API
*/
public List<RecordEntity> sldInfo(ZoneEntity zone, String sld) throws CloudflareApiException {
return sldInfo(zone, sld, (RecordType[]) null);
public List<RecordEntity> recordGet(ZoneEntity zone, String sld) throws CloudflareApiException {
return recordGet(zone, sld, (RecordType[]) null);
}
/**
* Retrieves a list of DNS records for a given second-level domain (SLD) within a specific zone.
* Optionally filters by one or more DNS record types.
*
* @param zone The zone entity containing information about the domain zone.
* @param sld The second-level domain (SLD) for which to retrieve DNS records.
@@ -208,7 +230,7 @@ public class CfDnsClient extends CfBasicHttpClient {
* @throws CloudflareNotFoundException if the specified SLD is not found in the zone
* @throws CloudflareApiException if an error occurs while interacting with the Cloudflare API
*/
public List<RecordEntity> sldInfo(ZoneEntity zone, String sld, @Nullable RecordType... types)
public List<RecordEntity> recordGet(ZoneEntity zone, String sld, @Nullable RecordType... types)
throws CloudflareApiException {
String fqdn = buildFqdn(zone, sld);
String endpoint = buildEndpointWithTypeFilters(zone.getId(), fqdn, types);
@@ -222,12 +244,11 @@ public class CfDnsClient extends CfBasicHttpClient {
if (types == null || types.length == 0) {
return baseEndpoint;
}
StringBuilder queryParams = new StringBuilder();
StringBuilder endpoint = new StringBuilder(baseEndpoint);
for (RecordType type : types) {
queryParams.append("&");
queryParams.append("type=").append(type);
endpoint.append("&type=").append(type);
}
return baseEndpoint + queryParams;
return endpoint.toString();
}
/**
@@ -281,7 +302,7 @@ public class CfDnsClient extends CfBasicHttpClient {
String endpoint = CfRequest.RECORD_CREATE.buildPath(zone.getId());
RecordSingleResponse resp = postRequest(endpoint, rec, RecordSingleResponse.class);
checkResponse(resp);
log.info("Record {} of type {} successful created.", rec.getName(), rec.getType());
log.info("Record {} of type {} successful created.", rec.getSld(), rec.getType());
return resp.getResult();
}
@@ -297,9 +318,9 @@ public class CfDnsClient extends CfBasicHttpClient {
public boolean recordDelete(ZoneEntity zone, RecordEntity rec) throws CloudflareApiException {
boolean changed = recordDelete(zone, rec.getId());
if (changed) {
log.debug("Record {} of the type [{}] successful deleted.", rec.getName(), rec.getType());
log.debug("Record {} of the type [{}] successful deleted.", rec.getSld(), rec.getType());
} else {
log.warn("Record {} of the type [{}] was not deleted.", rec.getName(), rec.getType());
log.warn("Record {} of the type [{}] was not deleted.", rec.getSld(), rec.getType());
}
return changed;
}
@@ -315,7 +336,7 @@ public class CfDnsClient extends CfBasicHttpClient {
*/
public boolean recordDelete(ZoneEntity zone, String id) throws CloudflareApiException {
String endpoint = CfRequest.RECORD_DELETE.buildPath(zone.getId(), id);
RecordSingleResponse resp = deleteRequest(endpoint);
RecordSingleResponse resp = deleteRequest(endpoint, RecordSingleResponse.class);
checkResponse(resp);
log.debug("Record id#{} successful deleted.", id);
return resp.getResult().getId().equals(id);
@@ -336,9 +357,9 @@ public class CfDnsClient extends CfBasicHttpClient {
rec.setModifiedOn(null);
rec.setCreatedOn(null);
String endpoint = CfRequest.RECORD_UPDATE.buildPath(zone.getId(), rec.getId());
RecordSingleResponse resp = patchRequest(endpoint, rec);
RecordSingleResponse resp = patchRequest(endpoint, rec, RecordSingleResponse.class);
checkResponse(resp);
log.info("Record {} of type {} successful updated.", rec.getName(), rec.getType());
log.info("Record {} of type {} successful updated.", rec.getSld(), rec.getType());
return resp.getResult();
}
@@ -356,7 +377,7 @@ public class CfDnsClient extends CfBasicHttpClient {
String fqdn = buildFqdn(zone, sld);
List<RecordEntity> recs;
try {
recs = sldInfo(zone, sld, recordTypes);
recs = recordGet(zone, sld, recordTypes);
} catch (CloudflareNotFoundException e) {
log.trace("No record of type {} found for domain {}.", recordTypes, fqdn);
return;
@@ -390,27 +411,16 @@ public class CfDnsClient extends CfBasicHttpClient {
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);
batchEntry.setPosts(cleanRecordsForPostOrPut(postRecords));
}
if (putRecords != null) {
List<RecordEntity> cleanedPuts = new ArrayList<>();
putRecords.forEach(
rec -> cleanedPuts.add(RecordEntity.build(rec.getId(), rec.getName(), rec.getType(), rec.getTtl(), rec.getContent())));
batchEntry.setPuts(cleanedPuts);
batchEntry.setPuts(cleanRecordsForPostOrPut(putRecords));
}
if (patchRecords != null) {
List<RecordEntity> cleanedPatches = new ArrayList<>();
patchRecords.forEach(rec -> cleanedPatches.add(RecordEntity.build(rec.getId(), rec.getContent())));
batchEntry.setPatches(cleanedPatches);
batchEntry.setPatches(cleanRecordsForPatch(patchRecords));
}
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);
batchEntry.setDeletes(cleanRecordsForDelete(deleteRecords));
}
String endpoint = CfRequest.RECORD_BATCH.buildPath(zone.getId());
@@ -419,16 +429,40 @@ public class CfDnsClient extends CfBasicHttpClient {
// set zone id
BatchEntry result = resp.getResult();
setZoneIdForBatchResults(result, zone.getId());
return result;
}
private List<RecordEntity> cleanRecordsForPostOrPut(List<RecordEntity> records) {
List<RecordEntity> cleaned = new ArrayList<>();
records.forEach(
rec -> cleaned.add(RecordEntity.build(rec.getId(), rec.getSld(), rec.getType(), rec.getTtl(), rec.getContent())));
return cleaned;
}
private List<RecordEntity> cleanRecordsForPatch(List<RecordEntity> records) {
List<RecordEntity> cleaned = new ArrayList<>();
records.forEach(rec -> cleaned.add(RecordEntity.build(rec.getId(), rec.getContent())));
return cleaned;
}
private List<RecordEntity> cleanRecordsForDelete(List<RecordEntity> records) {
List<RecordEntity> cleaned = new ArrayList<>();
records.forEach(
rec -> cleaned.add(RecordEntity.build(rec.getId(), rec.getSld(), rec.getType(), null, rec.getContent())));
return cleaned;
}
private void setZoneIdForBatchResults(BatchEntry result, String zoneId) {
if (result.getPosts() != null) {
result.getPosts().forEach(rec -> rec.setZoneId(zone.getId()));
result.getPosts().forEach(rec -> rec.setZoneId(zoneId));
}
if (result.getPuts() != null) {
result.getPuts().forEach(rec -> rec.setZoneId(zone.getId()));
result.getPuts().forEach(rec -> rec.setZoneId(zoneId));
}
if (result.getPatches() != null) {
result.getPatches().forEach(rec -> rec.setZoneId(zone.getId()));
result.getPatches().forEach(rec -> rec.setZoneId(zoneId));
}
return result;
}
private void checkResponse(AbstractResponse resp) throws CloudflareApiException {
@@ -1,6 +1,7 @@
package codes.thischwa.cf;
import codes.thischwa.cf.model.AbstractResponse;
import codes.thischwa.cf.model.AbstractSingleResponse;
import codes.thischwa.cf.model.RecordMultipleResponse;
import codes.thischwa.cf.model.ResponseResultInfo;
import java.util.stream.Collectors;
@@ -14,9 +15,11 @@ import java.util.stream.Collectors;
* <li>It checks whether the API response was successful by analyzing the associated response
* metadata. If the response indicates failure, an exception is thrown with descriptive error
* messages.
* <li>If a {@link RecordMultipleResponse} is used, it validates the number of results in the API
* response payload to detect unexpected counts. Depending on the parameter
* 'emptyResultThrowsException', an exception will be triggered or an empty result will be returned.
* <li>It validates the number of results in the API response payload to detect unexpected counts.
* For {@link RecordMultipleResponse}, it checks if results are empty or if more than one result
* was returned when a single result was expected. For {@link AbstractSingleResponse}, it checks
* if the result is null. Depending on the parameter 'emptyResultThrowsException', an exception
* will be triggered or an empty/null result will be returned.
* </ul>
*/
class ResponseValidator {
@@ -50,6 +53,10 @@ class ResponseValidator {
if (emptyResultThrowsException && respMulti.getResultInfo().totalCount() == 0) {
throw new CloudflareNotFoundException("No result found");
}
} else if (resp instanceof AbstractSingleResponse<?> respSingle) {
if (emptyResultThrowsException && respSingle.getResult() == null) {
throw new CloudflareNotFoundException("No result found");
}
}
}
@@ -0,0 +1,49 @@
package codes.thischwa.cf.fluent;
import codes.thischwa.cf.CloudflareApiException;
import codes.thischwa.cf.model.RecordEntity;
import codes.thischwa.cf.model.RecordType;
import java.util.List;
/**
* Fluent interface for record-level operations.
* Provides a chainable API for CRUD operations on DNS records.
*/
public interface RecordOperations {
/**
* Retrieves DNS records for the selected subdomain.
*
* @return a list of RecordEntity objects matching the criteria
* @throws CloudflareApiException if an error occurs while retrieving records
*/
List<RecordEntity> get() throws CloudflareApiException;
/**
* Creates a new DNS record with the specified parameters.
*
* @param type the DNS record type (e.g., A, AAAA, CNAME)
* @param content the content of the DNS record (e.g., IP address)
* @param ttl the time-to-live value in seconds
* @return the created RecordEntity
* @throws CloudflareApiException if an error occurs while creating the record
*/
RecordEntity create(RecordType type, String content, int ttl) throws CloudflareApiException;
/**
* Updates an existing DNS record with new content.
*
* @param newContent the new content for the DNS record
* @return the updated RecordEntity
* @throws CloudflareApiException if an error occurs while updating the record
*/
RecordEntity update(String newContent) throws CloudflareApiException;
/**
* Deletes DNS records of the specified types.
*
* @param types the DNS record types to delete
* @throws CloudflareApiException if an error occurs while deleting records
*/
void delete(RecordType... types) throws CloudflareApiException;
}
@@ -0,0 +1,67 @@
package codes.thischwa.cf.fluent;
import codes.thischwa.cf.CfDnsClient;
import codes.thischwa.cf.CloudflareApiException;
import codes.thischwa.cf.model.RecordEntity;
import codes.thischwa.cf.model.RecordType;
import codes.thischwa.cf.model.ZoneEntity;
import java.util.List;
import org.jetbrains.annotations.Nullable;
/**
* Implementation of RecordOperations for fluent API access to record-level operations.
*/
public class RecordOperationsImpl implements RecordOperations {
private final CfDnsClient client;
private final ZoneEntity zone;
private final String sld;
private final RecordType[] types;
/**
* Constructs a RecordOperationsImpl instance.
*
* @param client the CfDnsClient instance to use for operations
* @param zone the ZoneEntity representing the DNS zone
* @param sld the subdomain (second-level domain) name
* @param types optional array of RecordType to filter by
*/
public RecordOperationsImpl(CfDnsClient client, ZoneEntity zone, String sld, @Nullable RecordType[] types) {
this.client = client;
this.zone = zone;
this.sld = sld;
this.types = types;
}
@Override
public List<RecordEntity> get() throws CloudflareApiException {
if (types == null || types.length == 0) {
return client.recordGet(zone, sld);
}
return client.recordGet(zone, sld, types);
}
@Override
public RecordEntity create(RecordType type, String content, int ttl) throws CloudflareApiException {
return client.recordCreateSld(zone, sld, ttl, type, content);
}
@Override
public RecordEntity update(String newContent) throws CloudflareApiException {
List<RecordEntity> records = get();
if (records.isEmpty()) {
throw new CloudflareApiException("No records found to update for subdomain: " + sld);
}
if (records.size() > 1) {
throw new CloudflareApiException("Multiple records found. Please use recordUpdate() directly for precise control.");
}
RecordEntity record = records.get(0);
record.setContent(newContent);
return client.recordUpdate(zone, record);
}
@Override
public void delete(RecordType... types) throws CloudflareApiException {
client.recordDeleteTypeIfExists(zone, sld, types);
}
}
@@ -0,0 +1,31 @@
package codes.thischwa.cf.fluent;
import codes.thischwa.cf.CloudflareApiException;
import codes.thischwa.cf.model.RecordType;
import org.jetbrains.annotations.Nullable;
/**
* Fluent interface for zone-level operations.
* Provides a chainable API for accessing and manipulating DNS records within a specific zone.
*/
public interface ZoneOperations {
/**
* Selects a record (subdomain) within the zone for further operations.
*
* @param sld the second-level domain (subdomain) name
* @return a RecordOperations instance for chaining record-specific operations
* @throws CloudflareApiException if the zone cannot be found or accessed
*/
RecordOperations record(String sld) throws CloudflareApiException;
/**
* Selects a record with specific types within the zone for further operations.
*
* @param sld the second-level domain (subdomain) name
* @param types optional DNS record types to filter by
* @return a RecordOperations instance for chaining record-specific operations
* @throws CloudflareApiException if the zone cannot be found or accessed
*/
RecordOperations record(String sld, @Nullable RecordType... types) throws CloudflareApiException;
}
@@ -0,0 +1,37 @@
package codes.thischwa.cf.fluent;
import codes.thischwa.cf.CfDnsClient;
import codes.thischwa.cf.CloudflareApiException;
import codes.thischwa.cf.model.RecordType;
import codes.thischwa.cf.model.ZoneEntity;
import org.jetbrains.annotations.Nullable;
/**
* Implementation of ZoneOperations for fluent API access to zone-level operations.
*/
public class ZoneOperationsImpl implements ZoneOperations {
private final CfDnsClient client;
private final ZoneEntity zone;
/**
* Constructs a ZoneOperationsImpl instance.
*
* @param client the CfDnsClient instance to use for operations
* @param zone the ZoneEntity representing the DNS zone
*/
public ZoneOperationsImpl(CfDnsClient client, ZoneEntity zone) {
this.client = client;
this.zone = zone;
}
@Override
public RecordOperations record(String sld) throws CloudflareApiException {
return new RecordOperationsImpl(client, zone, sld, null);
}
@Override
public RecordOperations record(String sld, @Nullable RecordType... types) throws CloudflareApiException {
return new RecordOperationsImpl(client, zone, sld, types);
}
}
@@ -0,0 +1,31 @@
/**
* Fluent API interfaces and implementations for chainable DNS operations.
*
* <p>This package provides a fluent, chainable interface for interacting with Cloudflare DNS
* records, making code more readable and concise.
*
* <p>Example usage:
* <pre><code>
* // Create a DNS record
* client.zone("example.com")
* .record("api")
* .create(RecordType.A, "192.168.1.1", 60);
*
* // Get DNS records
* List&lt;RecordEntity&gt; records = client.zone("example.com")
* .record("www", RecordType.A)
* .get();
*
* // Update a DNS record
* client.zone("example.com")
* .record("api", RecordType.A)
* .update("192.168.1.2");
*
* // Delete DNS records
* client.zone("example.com")
* .record("old-service")
* .delete(RecordType.A, RecordType.AAAA);
* </code></pre>
*/
package codes.thischwa.cf.fluent;
@@ -21,6 +21,12 @@ import lombok.Data;
*/
@Data
public class PagingRequest {
/**
* Default page size for retrieving all records in a single request.
* Set to a very high value to effectively disable pagination when fetching all records.
*/
private static final int DEFAULT_ALL_RECORDS_PAGE_SIZE = 5_000_000;
private int page;
private int perPage;
@@ -42,19 +48,19 @@ public class PagingRequest {
/**
* Creates a default {@code PagingRequest} instance with a page number set to 1 and a high number
* of items per page (5,000,000) to accommodate large dataset requests.
* of items per page to accommodate large dataset requests and effectively retrieve all records.
*
* @return a default {@code PagingRequest} instance with predefined pagination parameters
*/
public static PagingRequest defaultPaging() {
return new PagingRequest(1, 5000000);
return new PagingRequest(1, DEFAULT_ALL_RECORDS_PAGE_SIZE);
}
/**
* Retrieves the pagination parameters in a key-value map format.
*
* @return a map containing the pagination parameters, where the key "page" indicates the current
* page number and the key "perPage" indicates the number of items per page.
* page number and the key "perPage" indicates the number of items per page.
*/
public Map<String, String> getPagingParams() {
return Map.of("page", String.valueOf(page), "perPage", String.valueOf(perPage));
@@ -94,27 +94,45 @@ public class RecordEntity extends AbstractEntity {
* @param ttl the time-to-live (TTL) value 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 attributes
* @throws IllegalArgumentException if the type string is not a valid RecordType
*/
public static RecordEntity build(String id, String name, String type, Integer ttl, String content) {
RecordEntity rec = build(name, RecordType.valueOf(type), ttl, content);
RecordType recordType;
try {
recordType = RecordType.valueOf(type);
} catch (IllegalArgumentException e) {
throw new IllegalArgumentException("Invalid record type: " + type + ". Must be one of: "
+ java.util.Arrays.toString(RecordType.values()), e);
}
RecordEntity rec = build(name, recordType, ttl, content);
rec.setId(id);
return rec;
}
/**
* Retrieves the name of the DNS record.
* Retrieves the short name (subdomain) of the DNS record.
* If the name contains a dot ('.'), only the substring before the first dot is returned.
* This is useful for getting the subdomain part of a fully qualified domain name.
*
* @return the name of the DNS record, potentially truncated before the first dot,
* or the full name if no dot is present.
* @return the short name of the DNS record (substring before the first dot),
* or the full name if no dot is present
*/
public String getName() {
if (name != null) {
int pos = name.indexOf('.');
if (pos > 0) {
return name.substring(0, pos);
}
public String getSld() {
if (name == null) {
return null;
}
if (zoneName != null && name.endsWith(zoneName)) {
int zoneNameLength = zoneName.length();
int dotSeparatorLength = 1;
return name.substring(0, name.length() - zoneNameLength - dotSeparatorLength);
}
int firstDotPosition = name.indexOf('.');
if (firstDotPosition > 0) {
return name.substring(0, firstDotPosition);
}
return name;
}
}
}
@@ -1,13 +1,13 @@
package codes.thischwa.cf;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assumptions.assumeTrue;
import codes.thischwa.cf.model.RecordType;
import codes.thischwa.cf.model.ZoneEntity;
import java.util.List;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assumptions.assumeTrue;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
@@ -32,7 +32,7 @@ public class CfClientPenTest {
}
private CfDnsClient newClient() {
return new CfDnsClient(API_EMAIL, API_KEY);
return new CfDnsClient(true, API_EMAIL, API_KEY);
}
@Test
@@ -40,14 +40,14 @@ public class CfClientPenTest {
void testInvalidCredentialsShouldFail() {
// Use syntactically valid but wrong credentials
CfDnsClient badClient = new CfDnsClient("invalid@example.com", UUID.randomUUID().toString());
assertThrows(CloudflareApiException.class, badClient::zoneListAll);
assertThrows(CloudflareApiException.class, badClient::zoneList);
}
@Test
@DisplayName("Malicious SLD inputs must not crash and should throw proper exception type")
void testMaliciousSldPatternsDoNotSucceed() throws Exception {
CfDnsClient client = newClient();
ZoneEntity zone = client.zoneInfo(ZONE_STR);
ZoneEntity zone = client.zoneGet(ZONE_STR);
List<String> syntacticallyInvalidSlds =
List.of("; rm -rf /", "| cat /etc/passwd", "`shutdown -h now`",
@@ -58,11 +58,11 @@ public class CfClientPenTest {
"doesnotexist", "abcdef12345", "unwahrscheinlich-" + System.currentTimeMillis());
for (String sld : syntacticallyInvalidSlds) {
assertThrows(IllegalArgumentException.class, () -> client.sldListAll(zone, sld),
assertThrows(IllegalArgumentException.class, () -> client.recordList(zone, sld),
"Should throw IllegalArgumentException for invalid SLD '" + sld + "'");
}
for (String sld : syntacticallyValidOrNotAllowedFromCloudflare) {
assertThrows(CloudflareNotFoundException.class, () -> client.sldListAll(zone, sld),
assertThrows(CloudflareNotFoundException.class, () -> client.recordList(zone, sld),
"Should throw CloudflareNotFoundException for valid but non-existing SLD '" + sld + "'");
}
}
@@ -71,7 +71,7 @@ public class CfClientPenTest {
@DisplayName("Invalid record content and TTL boundaries must be rejected by API")
void testInvalidRecordCreateInputsRejected() throws Exception {
CfDnsClient client = newClient();
ZoneEntity zone = client.zoneInfo(ZONE_STR);
ZoneEntity zone = client.zoneGet(ZONE_STR);
String sld = "pentest-" + System.currentTimeMillis();
String fqdn = sld + "." + ZONE_STR;
@@ -106,7 +106,7 @@ public class CfClientPenTest {
@DisplayName("recordDeleteTypeIfExists must be safe on non-existing SLD and types")
void testDeleteTypeIfExistsOnNonExistingIsSafe() throws Exception {
CfDnsClient client = newClient();
ZoneEntity zone = client.zoneInfo(ZONE_STR);
ZoneEntity zone = client.zoneGet(ZONE_STR);
String randomSld = "nonexist-" + System.currentTimeMillis();
// Should not throw even if nothing exists
assertDoesNotThrow(
@@ -27,7 +27,7 @@ public class CfClientTest {
private static final String API_EMAIL = System.getenv("API_EMAIL");
private static final String API_KEY = System.getenv("API_KEY");
private final CfDnsClient client = new CfDnsClient(API_EMAIL, API_KEY);
private final CfDnsClient client = new CfDnsClient(true, API_EMAIL, API_KEY);
@BeforeAll
static void checkEnv() {
@@ -37,18 +37,18 @@ public class CfClientTest {
@Test
void testUnknownSld() throws Exception {
ZoneEntity zone = client.zoneInfo(ZONE_STR);
assertThrows(CloudflareNotFoundException.class, () -> client.sldInfo(zone, "unknown", RecordType.A));
ZoneEntity zone = client.zoneGet(ZONE_STR);
assertThrows(CloudflareNotFoundException.class, () -> client.recordGet(zone, "unknown", RecordType.A));
}
@Test
void testAddHost() throws Exception {
ZoneEntity zone = client.zoneInfo(ZONE_STR);
ZoneEntity zone = client.zoneGet(ZONE_STR);
client.recordDeleteTypeIfExists(zone, SLD_STR, RecordType.A, RecordType.AAAA);
RecordEntity record = RecordEntity.build(SLD_STR, RecordType.A, TTL, "127.0.0.1");
RecordEntity createdRecord = client.recordCreate(zone, record);
assertNotNull(createdRecord.getId());
assertEquals(SLD_STR, createdRecord.getName());
assertEquals(SLD_STR, createdRecord.getSld());
assertEquals(RecordType.A.getType(), createdRecord.getType());
assertEquals(TTL, createdRecord.getTtl());
assertEquals("127.0.0.1", createdRecord.getContent());
@@ -56,12 +56,12 @@ public class CfClientTest {
client.recordDeleteTypeIfExists(zone, SLD_STR, RecordType.A);
assertThrows(CloudflareNotFoundException.class,
() -> client.sldInfo(zone, SLD_STR, RecordType.A));
() -> client.recordGet(zone, SLD_STR, RecordType.A));
record = RecordEntity.build(SLD_STR + "." + ZONE_STR, RecordType.A, TTL, "127.1.0.1");
createdRecord = client.recordCreate(zone, record);
assertNotNull(createdRecord.getId());
assertEquals(SLD_STR, createdRecord.getName());
assertEquals(SLD_STR, createdRecord.getSld());
assertEquals(RecordType.A.getType(), createdRecord.getType());
assertEquals(TTL, createdRecord.getTtl());
assertEquals("127.1.0.1", createdRecord.getContent());
@@ -72,25 +72,25 @@ public class CfClientTest {
@Test
void testZoneListAnlFailedSldList() throws Exception {
List<ZoneEntity> zList = client.zoneListAll();
List<ZoneEntity> zList = client.zoneList();
assertEquals(1, zList.size());
assertThrows(CloudflareNotFoundException.class,
() -> client.sldListAll(zList.get(0), "not-existing"));
() -> client.recordList(zList.get(0), "not-existing"));
}
@Test
void testEmptyResultThrowsException() throws Exception {
List<ZoneEntity> zList = client.zoneListAll();
List<ZoneEntity> zList = client.zoneList();
CfDnsClient client = new CfDnsClient(true, API_EMAIL, API_KEY);
assertThrows(CloudflareNotFoundException.class,
() -> client.sldListAll(zList.get(0), "not-existing"));
() -> client.recordList(zList.get(0), "not-existing"));
}
@Test
void testDns() throws Exception {
// starting point: already existing zone 'mein-d-ns.de'
ZoneEntity z = client.zoneInfo(ZONE_STR);
ZoneEntity z = client.zoneGet(ZONE_STR);
assertEquals("0a83dd6e7f8c46039f2517bbded8115e", z.getId());
assertEquals("mein-d-ns.de", z.getName());
assertEquals("active", z.getStatus());
@@ -118,15 +118,15 @@ public class CfClientTest {
createdRe1 =
client.recordCreate(z, RecordEntity.build(domain, RecordType.A, TTL, "130.0.0.3"));
assertNotNull(createdRe1.getId());
assertEquals(randomSld, createdRe1.getName());
assertEquals(randomSld, createdRe1.getSld());
assertEquals(RecordType.A.getType(), createdRe1.getType());
assertEquals(TTL, createdRe1.getTtl());
assertEquals("130.0.0.3", createdRe1.getContent());
assertNotNull(createdRe1.getCreatedOn());
assertNotNull(createdRe1.getModifiedOn());
// verify sldInfo for A
List<RecordEntity> aRecords = client.sldInfo(z, randomSld, RecordType.A);
// verify recordGet for A
List<RecordEntity> aRecords = client.recordGet(z, randomSld, RecordType.A);
assertEquals(1, aRecords.size());
r = aRecords.get(0);
assertEquals("130.0.0.3", r.getContent());
@@ -134,14 +134,14 @@ public class CfClientTest {
// create AAAA record using recordCreateSld
createdRe2 =
client.recordCreateSld(z, randomSld, TTL, RecordType.AAAA, "2a0a:4cc0:c0:2e4::1");
List<RecordEntity> aaaaRecords = client.sldInfo(z, randomSld, RecordType.AAAA);
List<RecordEntity> aaaaRecords = client.recordGet(z, randomSld, RecordType.AAAA);
assertEquals(1, aaaaRecords.size());
r = aaaaRecords.get(0);
assertEquals("2a0a:4cc0:c0:2e4::1", r.getContent());
assertEquals(RecordType.AAAA.getType(), r.getType());
// test sldListAll
List<RecordEntity> rList = client.sldListAll(z, randomSld);
// test recordList
List<RecordEntity> rList = client.recordList(z, randomSld);
assertEquals(2, rList.size());
for (RecordEntity re : rList) {
if (Objects.equals(re.getType(), RecordType.A.getType())) {
@@ -156,13 +156,13 @@ public class CfClientTest {
// update AAAA record
createdRe2.setContent("2a0a:4cc0:c0:2e4::2");
client.recordUpdate(z, createdRe2);
aaaaRecords = client.sldInfo(z, randomSld, RecordType.AAAA);
aaaaRecords = client.recordGet(z, randomSld, RecordType.AAAA);
assertEquals(1, aaaaRecords.size());
r = aaaaRecords.get(0);
assertEquals("2a0a:4cc0:c0:2e4::2", r.getContent());
// verify A record still intact
aRecords = client.sldInfo(z, randomSld, RecordType.A);
aRecords = client.recordGet(z, randomSld, RecordType.A);
assertEquals(1, aRecords.size());
r = aRecords.get(0);
assertEquals("130.0.0.3", r.getContent());
@@ -170,12 +170,12 @@ public class CfClientTest {
// delete AAAA record and verify it's gone
assertTrue(client.recordDelete(z, createdRe2));
assertThrows(CloudflareNotFoundException.class,
() -> client.sldInfo(z, randomSld, RecordType.AAAA));
() -> client.recordGet(z, randomSld, RecordType.AAAA));
// delete A record using helper and verify it's gone
client.recordDeleteTypeIfExists(z, randomSld, RecordType.A);
assertThrows(CloudflareNotFoundException.class,
() -> client.sldInfo(z, randomSld, RecordType.A));
() -> client.recordGet(z, randomSld, RecordType.A));
} finally {
// cleanup in case of failures during test
try {
@@ -195,6 +195,14 @@ public class CfClientTest {
assertThrows(IllegalArgumentException.class, () -> new CfDnsClient("", "key"));
}
@Test
void testRecordEntityInvalidType() {
IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
() -> RecordEntity.build("id123", "example.com", "INVALID_TYPE", 60, "192.168.1.1"));
assertTrue(exception.getMessage().contains("Invalid record type: INVALID_TYPE"));
assertTrue(exception.getMessage().contains("Must be one of:"));
}
private static final String IP_PREFIX = "130.0.0.";
private static final String UPDATED_IP_PREFIX = "130.1.0.";
@@ -202,7 +210,7 @@ public class CfClientTest {
@Test
void testBatch() throws Exception {
// starting point: already existing zone 'mein-d-ns.de'
ZoneEntity zone = client.zoneInfo(ZONE_STR);
ZoneEntity zone = client.zoneGet(ZONE_STR);
List<String> sldNames = createSldNames();
List<RecordEntity> initialRecords = createInitialRecords(sldNames);
@@ -252,7 +260,7 @@ public class CfClientTest {
// Verify only the first 2 records
for (int i = 0; i < 2; i++) {
List<RecordEntity> records1 = client.sldInfo(zone, sldNames.get(i), RecordType.A);
List<RecordEntity> records1 = client.recordGet(zone, sldNames.get(i), RecordType.A);
assertEquals(1, records1.size());
assertEquals(IP_PREFIX + (i + 1), records1.get(0).getContent());
}
@@ -262,7 +270,7 @@ public class CfClientTest {
// Use first 2 records for PATCH
List<RecordEntity> patchRecords = new ArrayList<>();
for (int i = 0; i < 2; i++) {
List<RecordEntity> records = client.sldInfo(zone, sldNames.get(i), RecordType.A);
List<RecordEntity> records = client.recordGet(zone, sldNames.get(i), RecordType.A);
RecordEntity record = records.get(0);
record.setContent(UPDATED_IP_PREFIX + (i + 1));
patchRecords.add(record);
@@ -272,7 +280,7 @@ public class CfClientTest {
// Verify both records were updated
for (int i = 0; i < 2; i++) {
List<RecordEntity> updatedRecords = client.sldInfo(zone, sldNames.get(i), RecordType.A);
List<RecordEntity> updatedRecords = client.recordGet(zone, sldNames.get(i), RecordType.A);
assertEquals(1, updatedRecords.size());
assertEquals(UPDATED_IP_PREFIX + (i + 1), updatedRecords.get(0).getContent());
}
@@ -282,7 +290,7 @@ public class CfClientTest {
// Delete first 2 records
List<RecordEntity> deleteRecords = new ArrayList<>();
for (int i = 0; i < 2; i++) {
List<RecordEntity> records = client.sldInfo(zone, sldNames.get(i), RecordType.A);
List<RecordEntity> records = client.recordGet(zone, sldNames.get(i), RecordType.A);
deleteRecords.add(records.get(0));
}
@@ -292,7 +300,7 @@ public class CfClientTest {
for (int i = 0; i < 2; i++) {
String sldName = sldNames.get(i);
assertThrows(CloudflareNotFoundException.class,
() -> client.sldInfo(zone, sldName, RecordType.A));
() -> client.recordGet(zone, sldName, RecordType.A));
}
}
@@ -308,7 +316,7 @@ public class CfClientTest {
// Now use PUT to replace them
List<RecordEntity> putRecords = new ArrayList<>();
for (int i = 0; i < 2; i++) {
List<RecordEntity> records = client.sldInfo(zone, sldNames.get(i), RecordType.A);
List<RecordEntity> records = client.recordGet(zone, sldNames.get(i), RecordType.A);
RecordEntity record = records.get(0);
record.setContent(UPDATED_IP_PREFIX + (i + 1));
putRecords.add(record);
@@ -317,7 +325,7 @@ public class CfClientTest {
// Verify both records were updated
for (int i = 0; i < 2; i++) {
List<RecordEntity> updatedRecords = client.sldInfo(zone, sldNames.get(i), RecordType.A);
List<RecordEntity> updatedRecords = client.recordGet(zone, sldNames.get(i), RecordType.A);
assertEquals(1, updatedRecords.size());
assertEquals(UPDATED_IP_PREFIX + (i + 1), updatedRecords.get(0).getContent());
}
@@ -325,9 +333,54 @@ public class CfClientTest {
private void assertValidBatchedRecord(RecordEntity batchedRecord, RecordEntity originalRecord) {
assertNotNull(batchedRecord.getId());
assertEquals(originalRecord.getName(), batchedRecord.getName());
assertEquals(originalRecord.getSld(), batchedRecord.getSld());
assertEquals(originalRecord.getType(), batchedRecord.getType());
assertNotNull(batchedRecord.getCreatedOn());
}
@Test
void testFluentApi() throws Exception {
ZoneEntity zone = client.zoneGet(ZONE_STR);
String fluentSld = "fluent-" + System.currentTimeMillis();
try {
// Test fluent create
RecordEntity created = client.zone(ZONE_STR)
.record(fluentSld)
.create(RecordType.A, "192.168.100.1", TTL);
assertNotNull(created.getId());
assertEquals(fluentSld, created.getSld());
assertEquals("192.168.100.1", created.getContent());
// Test fluent get
List<RecordEntity> records = client.zone(ZONE_STR)
.record(fluentSld, RecordType.A)
.get();
assertEquals(1, records.size());
assertEquals("192.168.100.1", records.get(0).getContent());
// Test fluent update
RecordEntity updated = client.zone(ZONE_STR)
.record(fluentSld, RecordType.A)
.update("192.168.100.2");
assertEquals("192.168.100.2", updated.getContent());
// Test fluent delete
client.zone(ZONE_STR)
.record(fluentSld)
.delete(RecordType.A);
assertThrows(CloudflareNotFoundException.class,
() -> client.zone(ZONE_STR).record(fluentSld, RecordType.A).get());
} finally {
try {
client.recordDeleteTypeIfExists(zone, fluentSld, RecordType.A);
} catch (Exception e) { /* ignore */ }
}
}
}
@@ -6,7 +6,9 @@ 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.RecordEntity;
import codes.thischwa.cf.model.RecordMultipleResponse;
import codes.thischwa.cf.model.RecordSingleResponse;
import codes.thischwa.cf.model.ResponseResultInfo;
import codes.thischwa.cf.model.ResultInfo;
import java.util.ArrayList;
@@ -29,6 +31,12 @@ class ResponseValidatorTest {
@Mock
private RecordMultipleResponse mockMultipleResponse;
@Mock
private RecordSingleResponse mockSingleResponse;
@Mock
private RecordEntity mockRecordEntity;
private ResponseValidator validatorWithException;
private ResponseValidator validatorWithoutException;
@@ -93,4 +101,33 @@ class ResponseValidatorTest {
assertDoesNotThrow(() -> validatorWithoutException.validate(mockMultipleResponse, true));
}
@Test
void validateSingleResultWithNullResultAndExceptionEnabled() {
when(mockSingleResponse.getResponseResultInfo()).thenReturn(mockResultInfo);
when(mockResultInfo.isSuccess()).thenReturn(true);
when(mockSingleResponse.getResult()).thenReturn(null);
assertThrows(CloudflareNotFoundException.class,
() -> validatorWithException.validate(mockSingleResponse, false));
}
@Test
void validateSingleResultWithNullResultAndExceptionDisabled() {
when(mockSingleResponse.getResponseResultInfo()).thenReturn(mockResultInfo);
when(mockResultInfo.isSuccess()).thenReturn(true);
// mockSingleResponse.getResult() returns null by default (no stubbing needed)
assertDoesNotThrow(() -> validatorWithoutException.validate(mockSingleResponse, false));
}
@Test
void validateSingleResultWithValidResult() {
when(mockSingleResponse.getResponseResultInfo()).thenReturn(mockResultInfo);
when(mockResultInfo.isSuccess()).thenReturn(true);
when(mockSingleResponse.getResult()).thenReturn(mockRecordEntity);
assertDoesNotThrow(() -> validatorWithException.validate(mockSingleResponse, false));
assertDoesNotThrow(() -> validatorWithoutException.validate(mockSingleResponse, false));
}
}