diff --git a/README.md b/README.md index b6acb21..b765985 100644 --- a/README.md +++ b/README.md @@ -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 zones = cfDnsClient.zoneListAll(); +List 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 records = cfDnsClient.sldListAll(zone, "sld"); -records.forEach(record -> +List 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 allRecords = cfDnsClient.sldInfo(zone, "www"); +List 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 aRecords = cfDnsClient.sldInfo(zone, "www", RecordType.A); +List aRecords = cfDnsClient.recordGet(zone, "www", RecordType.A); System.out. println("Found "+aRecords.size() +" A records"); // Get A and AAAA records -List ipRecords = cfDnsClient.sldInfo(zone, "www", RecordType.A, RecordType.AAAA); +List 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 postRecords` - Records to create (nullable). - - `List putRecords` - Records to fully replace (nullable). - - `List patchRecords` - Records to partially update (nullable). - - `List deleteRecords` - Records to delete (nullable). + - `List postRecords` - Records to create (nullable). Can be built without IDs. + - `List putRecords` - Records to fully replace (nullable). **Requires record IDs**. + - `List patchRecords` - Records to partially update (nullable). **Requires record IDs**. + - `List 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 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 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 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 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 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 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 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 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 recordsToUpdate = Arrays.asList( + cfDnsClient.recordGet(zone, "existing-api", RecordType.A).get(0) +); +recordsToUpdate. + +get(0). + +setContent("192.168.1.200"); + +List 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 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 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 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; - } + } } ``` diff --git a/src/main/java/codes/thischwa/cf/CfBasicHttpClient.java b/src/main/java/codes/thischwa/cf/CfBasicHttpClient.java index 28b3ab6..c6e61bc 100644 --- a/src/main/java/codes/thischwa/cf/CfBasicHttpClient.java +++ b/src/main/java/codes/thischwa/cf/CfBasicHttpClient.java @@ -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 the response type extending AbstractResponse + * @return the parsed response object + * @throws CloudflareApiException if an error occurs during the request */ - T deleteRequest(String endpoint) throws CloudflareApiException { + T deleteRequest(String endpoint, Class responseType) throws CloudflareApiException { HttpDelete request = new HttpDelete(buildUrl(endpoint)); - return executeRequest(request, (Class) 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 the response type extending AbstractResponse + * @return the parsed response object + * @throws CloudflareApiException if an error occurs during the request */ T patchRequest(String endpoint, - Object requestPayload) + Object requestPayload, + Class responseType) throws CloudflareApiException { HttpPatch request = new HttpPatch(buildUrl(endpoint)); setRequestPayload(request, requestPayload); - return executeRequest(request, (Class) codes.thischwa.cf.model.RecordSingleResponse.class); + return executeRequest(request, responseType); } /** diff --git a/src/main/java/codes/thischwa/cf/CfDnsClient.java b/src/main/java/codes/thischwa/cf/CfDnsClient.java index f7a8a5f..6244bc2 100644 --- a/src/main/java/codes/thischwa/cf/CfDnsClient.java +++ b/src/main/java/codes/thischwa/cf/CfDnsClient.java @@ -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<{@link RecordEntity}> records = cfDnsClient.sldListAll(zone, "sld"); + * List<{@link RecordEntity}> 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()); * */ -@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. + * + *

Example: + *


+   * client.zone("example.com")
+   *       .record("api")
+   *       .create(RecordType.A, "192.168.1.1", 60);
+   * 
+ * + * @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 zoneListAll() throws CloudflareApiException { - return zoneListAll(PagingRequest.defaultPaging()); + public List 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 zoneListAll(PagingRequest pagingRequest) throws CloudflareApiException { + public List 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 sldListAll(ZoneEntity zone, String sld) throws CloudflareApiException { - return sldListAll(zone, sld, PagingRequest.defaultPaging()); + public List 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 sldListAll(ZoneEntity zone, String sld, PagingRequest pagingRequest) + public List 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 sldInfo(ZoneEntity zone, String sld) throws CloudflareApiException { - return sldInfo(zone, sld, (RecordType[]) null); + public List 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 sldInfo(ZoneEntity zone, String sld, @Nullable RecordType... types) + public List 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 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 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 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 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 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 cleanRecordsForPostOrPut(List records) { + List cleaned = new ArrayList<>(); + records.forEach( + rec -> cleaned.add(RecordEntity.build(rec.getId(), rec.getSld(), rec.getType(), rec.getTtl(), rec.getContent()))); + return cleaned; + } + + private List cleanRecordsForPatch(List records) { + List cleaned = new ArrayList<>(); + records.forEach(rec -> cleaned.add(RecordEntity.build(rec.getId(), rec.getContent()))); + return cleaned; + } + + private List cleanRecordsForDelete(List records) { + List 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 { diff --git a/src/main/java/codes/thischwa/cf/ResponseValidator.java b/src/main/java/codes/thischwa/cf/ResponseValidator.java index 2651578..e5d6700 100644 --- a/src/main/java/codes/thischwa/cf/ResponseValidator.java +++ b/src/main/java/codes/thischwa/cf/ResponseValidator.java @@ -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; *
  • 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. - *
  • 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. + *
  • 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. * */ 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"); + } } } diff --git a/src/main/java/codes/thischwa/cf/fluent/RecordOperations.java b/src/main/java/codes/thischwa/cf/fluent/RecordOperations.java new file mode 100644 index 0000000..579300b --- /dev/null +++ b/src/main/java/codes/thischwa/cf/fluent/RecordOperations.java @@ -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 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; +} diff --git a/src/main/java/codes/thischwa/cf/fluent/RecordOperationsImpl.java b/src/main/java/codes/thischwa/cf/fluent/RecordOperationsImpl.java new file mode 100644 index 0000000..8021ffd --- /dev/null +++ b/src/main/java/codes/thischwa/cf/fluent/RecordOperationsImpl.java @@ -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 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 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); + } +} diff --git a/src/main/java/codes/thischwa/cf/fluent/ZoneOperations.java b/src/main/java/codes/thischwa/cf/fluent/ZoneOperations.java new file mode 100644 index 0000000..23336c1 --- /dev/null +++ b/src/main/java/codes/thischwa/cf/fluent/ZoneOperations.java @@ -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; +} diff --git a/src/main/java/codes/thischwa/cf/fluent/ZoneOperationsImpl.java b/src/main/java/codes/thischwa/cf/fluent/ZoneOperationsImpl.java new file mode 100644 index 0000000..36ae33b --- /dev/null +++ b/src/main/java/codes/thischwa/cf/fluent/ZoneOperationsImpl.java @@ -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); + } +} diff --git a/src/main/java/codes/thischwa/cf/fluent/package-info.java b/src/main/java/codes/thischwa/cf/fluent/package-info.java new file mode 100644 index 0000000..8478e70 --- /dev/null +++ b/src/main/java/codes/thischwa/cf/fluent/package-info.java @@ -0,0 +1,31 @@ +/** + * Fluent API interfaces and implementations for chainable DNS operations. + * + *

    This package provides a fluent, chainable interface for interacting with Cloudflare DNS + * records, making code more readable and concise. + * + *

    Example usage: + *

    
    + * // 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
    + * 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);
    + * 
    + */ + +package codes.thischwa.cf.fluent; diff --git a/src/main/java/codes/thischwa/cf/model/PagingRequest.java b/src/main/java/codes/thischwa/cf/model/PagingRequest.java index dfb37b2..ac061a3 100644 --- a/src/main/java/codes/thischwa/cf/model/PagingRequest.java +++ b/src/main/java/codes/thischwa/cf/model/PagingRequest.java @@ -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 getPagingParams() { return Map.of("page", String.valueOf(page), "perPage", String.valueOf(perPage)); diff --git a/src/main/java/codes/thischwa/cf/model/RecordEntity.java b/src/main/java/codes/thischwa/cf/model/RecordEntity.java index 71f48d1..b982e29 100644 --- a/src/main/java/codes/thischwa/cf/model/RecordEntity.java +++ b/src/main/java/codes/thischwa/cf/model/RecordEntity.java @@ -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; } -} +} \ No newline at end of file diff --git a/src/test/java/codes/thischwa/cf/CfClientPenTest.java b/src/test/java/codes/thischwa/cf/CfClientPenTest.java index 8df0b34..890de9a 100644 --- a/src/test/java/codes/thischwa/cf/CfClientPenTest.java +++ b/src/test/java/codes/thischwa/cf/CfClientPenTest.java @@ -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 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( diff --git a/src/test/java/codes/thischwa/cf/CfClientTest.java b/src/test/java/codes/thischwa/cf/CfClientTest.java index 5dee4ce..19b70dc 100644 --- a/src/test/java/codes/thischwa/cf/CfClientTest.java +++ b/src/test/java/codes/thischwa/cf/CfClientTest.java @@ -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 zList = client.zoneListAll(); + List 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 zList = client.zoneListAll(); + List 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 aRecords = client.sldInfo(z, randomSld, RecordType.A); + // verify recordGet for A + List 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 aaaaRecords = client.sldInfo(z, randomSld, RecordType.AAAA); + List 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 rList = client.sldListAll(z, randomSld); + // test recordList + List 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 sldNames = createSldNames(); List initialRecords = createInitialRecords(sldNames); @@ -252,7 +260,7 @@ public class CfClientTest { // Verify only the first 2 records for (int i = 0; i < 2; i++) { - List records1 = client.sldInfo(zone, sldNames.get(i), RecordType.A); + List 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 patchRecords = new ArrayList<>(); for (int i = 0; i < 2; i++) { - List records = client.sldInfo(zone, sldNames.get(i), RecordType.A); + List 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 updatedRecords = client.sldInfo(zone, sldNames.get(i), RecordType.A); + List 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 deleteRecords = new ArrayList<>(); for (int i = 0; i < 2; i++) { - List records = client.sldInfo(zone, sldNames.get(i), RecordType.A); + List 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 putRecords = new ArrayList<>(); for (int i = 0; i < 2; i++) { - List records = client.sldInfo(zone, sldNames.get(i), RecordType.A); + List 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 updatedRecords = client.sldInfo(zone, sldNames.get(i), RecordType.A); + List 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 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 */ } + } + } + } diff --git a/src/test/java/codes/thischwa/cf/ResponseValidatorTest.java b/src/test/java/codes/thischwa/cf/ResponseValidatorTest.java index c027dee..aed5672 100644 --- a/src/test/java/codes/thischwa/cf/ResponseValidatorTest.java +++ b/src/test/java/codes/thischwa/cf/ResponseValidatorTest.java @@ -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)); + } } \ No newline at end of file