61 Commits

Author SHA1 Message Date
thischwa 62b86ca22f [maven-release-plugin] prepare release v0.3.0 2026-02-15 17:33:34 +01:00
thischwa f12f5d6db1 Update .gitlab-ci.yml: Adjust apidocs path, and enhance documentation in ResultInfo and CfDnsClientBuilder. 2026-01-25 18:59:02 +01:00
thischwa c435a80966 Update pom.xml: Bump JUnit to 5.14.2, Mockito to 5.21.0, and Maven Release Plugin to 3.3.1. 2026-01-25 18:13:03 +01:00
thischwa 1e8fe53891 Update pom.xml: Bump dependency and plugin versions (Jackson: 2.19.1, HttpClient: 5.5.1, JUnit: 5.12.3, Maven Javadoc Plugin: 3.11.2, Release Plugin: 3.1.2, Jacoco Plugin: 0.8.13). 2026-01-25 17:57:27 +01:00
thischwa 10ad4bd7f3 Update version in pom.xml from 0.2.1-SNAPSHOT to 0.3.0-SNAPSHOT. 2026-01-25 14:39:49 +01:00
thischwa 84a454f656 fixed changelog 2026-01-25 14:30:59 +01:00
thischwa 4a3ec42fa5 reducing code smells 2026-01-23 18:32:54 +01:00
thischwa 9e09b12dc3 Update README.md: Remove duplicate "de-lombok the source jar" entry in changelog. 2026-01-23 17:55:44 +01:00
thischwa d0802fc01d Update pom.xml: Bump Checkstyle to 12.3.0, configure maven-jar-plugin with version 3.4.2, and adjust Checkstyle properties. 2026-01-23 17:40:53 +01:00
thischwa 607b634b35 issue #11 - Refactor CfDnsClient with a simplified CfDnsClientBuilder for authentication and configuration. Update tests and README for new builder. Added authtification with apiToken 2026-01-23 17:26:58 +01:00
thischwa b593ca7311 issue #11 -Implement authentication refactor: Add CfAuth interface, ApiTokenAuth and EmailKeyAuth implementations, and CfAuthBuilder for flexible authentication in CfDnsClient. Update tests and adjust related classes for compatibility. 2026-01-23 15:36:17 +01:00
thischwa 773573bd39 Update README.md: release 0.2.0 details and note de-lomboked source jar. 2026-01-19 18:21:45 +01:00
thischwa b890ae9d17 [maven-release-plugin] prepare for next development iteration 2026-01-19 18:03:50 +01:00
thischwa c80cfc8b66 [maven-release-plugin] prepare release v0.2.0 2026-01-19 18:03:46 +01:00
thischwa b07c818127 Update version in pom.xml from 0.2.0-beta-SNAPSHOT to 0.2.0-SNAPSHOT. 2026-01-19 18:02:10 +01:00
thischwa 249dfd2de5 fix #10 - Add lombok-maven-plugin for delomboking, configure maven-jar-plugin to attach delomboked sources, and update pom.xml with necessary properties and dependencies. 2026-01-18 15:22:32 +01:00
thischwa b241d8cf09 issue #9 - Refactor CfClientTest, CfDnsClient, and CfBasicHttpClient: improve error handling, enable record filtering, refine empty result behavior, and update tests for clarity and robustness. 2026-01-11 18:28:22 +01:00
thischwa b88f4f78ba Fix JavaDoc formatting inconsistencies in CfBasicHttpClient, CfDnsClient, and ZoneOperations to improve readability and maintain standard alignment. 2026-01-11 11:46:13 +01:00
thischwa 9e8ee5bb4a Refactor tests: Replace assertTrue(size >= 1) with assertFalse(isEmpty) for clarity and correctness. 2026-01-09 15:25:33 +01:00
thischwa 34010a4e77 issue #9: Add groupRecordsByFqdn utility to CfDnsClient, set zoneId in DNS record methods, and enhance tests for record validation and grouping. 2026-01-07 17:52:48 +01:00
thischwa 0f1248d08b issue #9: Refactor recordGet to recordList for consistency across API methods, update related documentation, and revise impacted tests. 2026-01-06 20:35:50 +01:00
thischwa acf2a2fc3b issue #9: Add recordList method to CfDnsClient, update Zone-level fluent API, and implement tests for DNS record listing with optional type filters. 2026-01-06 20:14:05 +01:00
thischwa 483b79b372 Simplify README code example by removing unnecessary line breaks and improving method chaining clarity. 2026-01-06 15:27:01 +01:00
thischwa 5a6a17798b Simplify README examples by removing unnecessary line breaks, standardizing method chaining, and improving code readability for batch DNS operations and fluent API usage. 2026-01-06 15:20:12 +01:00
thischwa edf1752e81 Simplify README examples by removing unnecessary line breaks, standardizing method chaining, and improving code readability for batch DNS operations and fluent API usage. 2026-01-06 15:07:26 +01:00
thischwa 25fd480e69 Standardize and simplify README code examples by eliminating unnecessary line continuations, improving readability, and enhancing method chaining for DNS operations. 2026-01-06 14:35:25 +01:00
thischwa 5098e17172 Simplify README examples by combining line continuations, improving method chaining clarity, and standardizing formatting for batch DNS operations. 2026-01-06 13:50:30 +01:00
thischwa a221de4792 issue #9:
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.
2026-01-06 13:19:19 +01:00
thischwa 1bfea09aa9 Refactor sldInfo to improve JavaDoc, split overloaded methods, and enhance exception handling. 2025-12-31 16:45:59 +01:00
thischwa 12c75914b1 Add test for handling unknown SLDs with sldInfo method. 2025-12-31 16:30:49 +01:00
thischwa dd586be9f4 fix formatting in PagingRequest#getPagingParams JavaDoc and reorder RecordEntity#getName method. 2025-12-31 13:38:32 +01:00
thischwa 77a584afb6 fix issue #8: Refactor sldInfo to support filtering by multiple record types. Adjust related methods, tests, and documentation accordingly. 2025-12-31 13:19:56 +01:00
thischwa 6027e66afe Update changelog: CfClient#sldInfo must return multiple RecordEntries 2025-12-30 15:49:41 +01:00
thischwa 351730dc79 Bump version to 0.2.0-beta-SNAPSHOT and update changelog 2025-12-30 15:46:33 +01:00
thischwa c75f9d74ca fix #7: Refactor sldInfo to return a list of records, update related methods and tests accordingly. Improve logging and exception handling for batch DNS operations. 2025-12-30 15:34:55 +01:00
thischwa 1abe888a65 [maven-release-plugin] prepare for next development iteration 2025-12-30 13:01:18 +01:00
thischwa 80670c2a90 [maven-release-plugin] prepare release v0.2.0-beta.2 2025-12-30 13:01:11 +01:00
thischwa ce1f766326 Add maven-source-plugin to generate and attach source JARs, update changelog 2025-12-30 12:58:07 +01:00
thischwa a965f21d6b Add batch DNS operations documentation and refine logging format in CfDnsClient. Extend tests for record creation and deletion. 2025-12-27 16:48:26 +01:00
thischwa a3dc89e2c5 Update changelog with details for v0.2.0-beta.1 changes 2025-12-26 10:15:47 +01:00
thischwa 9c36fa1988 [maven-release-plugin] prepare for next development iteration 2025-12-26 10:00:45 +01:00
thischwa 335c9a6c5e [maven-release-plugin] prepare release v0.2.0-beta.1 2025-12-26 10:00:40 +01:00
thischwa 9dfb5f4c75 Revert pom.xml version to 0.2.0-beta.1-SNAPSHOT. 2025-12-26 09:57:22 +01:00
thischwa e12eaff137 fixed test for put 2025-12-26 09:47:52 +01:00
thischwa 596006276e fixed test for put 2025-12-25 21:18:30 +01:00
thischwa 43ff58febc Update pom.xml version to 0.2.0-beta.2-SNAPSHOT. 2025-12-25 20:52:03 +01:00
thischwa f2f7f662a1 [maven-release-plugin] prepare for next development iteration 2025-12-25 20:48:03 +01:00
thischwa 9a376415b3 [maven-release-plugin] prepare release v0.2.0-beta.1 2025-12-25 20:47:57 +01:00
thischwa 0eaca55e95 issue #5 - Update pom.xml version to 0.2.0-beta.1-SNAPSHOT, enhance logging, and refine DNS batch record processing method. 2025-12-25 20:43:31 +01:00
thischwa deb6fcf445 Reformat pom.xml for improved readability and consistency. 2025-12-25 16:34:47 +01:00
thischwa e18f8d7017 Update SonarQube organization and project key in pom.xml. 2025-12-25 16:33:53 +01:00
thischwa 4df6757ee4 Update SonarQube project key and organization in GitLab CI; add dynamic project key to analysis command. 2025-12-25 16:26:28 +01:00
thischwa 3d026dab1d Use non-interactive mode (-B) for SonarQube analysis in GitLab CI pipeline. 2025-12-25 16:05:17 +01:00
thischwa c20bb6bb1c Fix GitLab CI SonarQube script by removing unnecessary -B flag. 2025-12-25 16:00:51 +01:00
thischwa a5e96f38f8 Revert "Simplify SonarQube analysis command in GitLab CI pipeline."
This reverts commit 7f56c6f453.
2025-12-25 15:40:33 +01:00
thischwa 4cb9b9929e Revert "Add SonarQube Maven plugin to pom.xml configuration."
This reverts commit 67df00c36e.
2025-12-25 15:40:33 +01:00
thischwa 67df00c36e Add SonarQube Maven plugin to pom.xml configuration. 2025-12-25 15:25:59 +01:00
thischwa 7f56c6f453 Simplify SonarQube analysis command in GitLab CI pipeline. 2025-12-25 15:10:45 +01:00
thischwa a5d7992391 Enhance code readability by improving comments, formatting, and aligning annotations across classes. 2025-12-25 14:43:54 +01:00
thischwa b4ce867efc issue #6 - ResponseResultInfo#Errors: wrong object structure
issue #5 - Add batch DNS record operations and refactor Cloudflare API client.
2025-12-25 14:27:18 +01:00
thischwa eb821a2218 [maven-release-plugin] prepare for next development iteration 2025-08-19 12:30:24 +02:00
32 changed files with 2017 additions and 556 deletions
+4 -4
View File
@@ -13,8 +13,8 @@ variables:
GITLAB_USERNAME: $GITLAB_USERNAME GITLAB_USERNAME: $GITLAB_USERNAME
GITLAB_USEREMAIL: GITLAB_USEREMAIL GITLAB_USEREMAIL: GITLAB_USEREMAIL
SONAR_HOST_URL: $SONAR_HOST_URL SONAR_HOST_URL: $SONAR_HOST_URL
SONAR_PROJECT_KEY: "thischwa_CloudflareDNS-java" SONAR_PROJECT_KEY: "th-schwarz_CloudflareDNS-java"
SONAR_ORGANIZATION: "thischwa" SONAR_ORGANIZATION: "th-schwarz"
SONAR_TOKEN: $SONAR_TOKEN SONAR_TOKEN: $SONAR_TOKEN
SONAR_USER_HOME: "${CI_PROJECT_DIR}/.sonar" SONAR_USER_HOME: "${CI_PROJECT_DIR}/.sonar"
GIT_DEPTH: "0" # Tells git to fetch all the branches of the project, required by the analysis task GIT_DEPTH: "0" # Tells git to fetch all the branches of the project, required by the analysis task
@@ -32,7 +32,7 @@ build:
- mkdir public - mkdir public
- cp -rv docs/* public/ - cp -rv docs/* public/
- mkdir public/apidocs - mkdir public/apidocs
- cp -rv target/apidocs public/ - cp -rv target/reports/apidocs public/
artifacts: artifacts:
paths: paths:
- target/surefire-reports/*.xml - target/surefire-reports/*.xml
@@ -68,7 +68,7 @@ sonarcloud_scan:
dependencies: dependencies:
- build - build
script: script:
- mvn -B verify org.sonarsource.scanner.maven:sonar-maven-plugin:sonar - mvn -B verify org.sonarsource.scanner.maven:sonar-maven-plugin:sonar -Dsonar.projectKey=${SONAR_PROJECT_KEY}
only: only:
- merge_requests - merge_requests
- develop - develop
+269 -31
View File
@@ -29,7 +29,7 @@ This guide comes without any warranty. Use at your own risk. The author is not r
The project has its own maven repository. It can be added to the `pom.xml`: The project has its own maven repository. It can be added to the `pom.xml`:
```xml ```xml<p>
<repositories> <repositories>
<repository> <repository>
<id>gitlab-cloudflare</id> <id>gitlab-cloudflare</id>
@@ -49,6 +49,25 @@ The dependency is:
## Changelog ## Changelog
- 0.3.0-SNAPSHOT:
- **Breaking Change**:
- **New Fluent API**: Changed the initialization of the client(`new CfDnsClientBuilder().withApiTokenAuth("your-api-token").build()`)
- Authentication with API token.
- 0.2.0:
- **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`
- RecordEntity getter methods renamed for clarity: `getName()` → `getSld()`
- **New Fluent API**: Changed the initialization of the client(`new CfDnsClientBuilder().withApiTokenAuth("your-api-token").build()`) and added chainable method interface for more readable DNS operations (
`client.zone().record()...`)
- 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#recordList must return multiple RecordEntries
- add a missing source jar
- ResponseResultInfo#Errors: wrong object structure
- changing multiple records with put, post, patch and delete for dns-records
- 0.1.0: - 0.1.0:
- refactored / extended tests - refactored / extended tests
- 0.1.0-beta.3: - 0.1.0-beta.3:
@@ -63,31 +82,44 @@ The methods can be categorized as follows:
- `Zone`: list, info - `Zone`: list, info
- `Record`: list, info, create, update, delete - `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 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). the [javadoc of the CfDnsClient](https://cloudflaredns-java-f4ee3a.gitlab.io/apidocs/codes/thischwa/cf/CfDnsClient.html).
### Instantiation of `CfDnsClient` ### Instantiation of `CfDnsClient`
#### With API Token (recommended):
```java ```java
CfDnsClient cfDnsClient = new CfDnsClient( CfDnsClient cfDnsClient = new CfDnsClientBuilder()
"email@example.com", "yourApiKey" .withApiTokenAuth("your-api-token")
); .build();
``` ```
### `zoneListAll` #### With Email/Key (legacy):
```java
CfDnsClient cfDnsClient = new CfDnsClientBuilder()
.withEmailKeyAuth("email@example.com", "yourApiKey")
.build();
```
### `zoneList`
Retrieve all zones within the Cloudflare account. Retrieve all zones within the Cloudflare account.
- **Returns**: A list of `ZoneEntity` objects. - **Returns**: A list of `ZoneEntity` objects.
```java ```java
List<ZoneEntity> zones = cfDnsClient.zoneListAll(); List<ZoneEntity> zones = cfDnsClient.zoneList();
zones.forEach(zone -> System.out.println("Zone: " + zone.getName())); zones.forEach(zone -> System.out.println("Zone: " + zone.getName()));
``` ```
--- ---
### `zoneInfo` ### `zoneGet`
Get detailed information about a specific zone by its name. Get detailed information about a specific zone by its name.
@@ -96,14 +128,17 @@ Get detailed information about a specific zone by its name.
- **Returns**: A `ZoneEntity` object. - **Returns**: A `ZoneEntity` object.
```java ```java
ZoneEntity zone = cfDnsClient.zoneInfo("example.com"); ZoneEntity zone = cfDnsClient.zoneGet("example.com");
System.out.println("Zone ID: " + zone.getId()); System.out.println("Zone ID: " + zone.getId());
``` ```
--- ---
### `sldListAll` ### `recordList`
Retrieve records for a given zone. There are two variants:
#### List by SLD
Retrieve all records for a specific second-level domain (SLD) under a given zone. Retrieve all records for a specific second-level domain (SLD) under a given zone.
- **Parameters**: - **Parameters**:
@@ -112,28 +147,35 @@ Retrieve all records for a specific second-level domain (SLD) under a given zone
- **Returns**: A list of `RecordEntity` objects. - **Returns**: A list of `RecordEntity` objects.
```java ```java
List<RecordEntity> records = cfDnsClient.sldListAll(zone, "sld"); List<RecordEntity> records = cfDnsClient.recordList(zone, "sld");
records.forEach(record -> records.forEach(record ->
System.out.println("Record Type: " + record.getType() + ", Value: " + record.getContent()) System.out.println("Record Type: "+record.getType()
+", Value: "+record.getContent())
); );
``` ```
--- #### List by Zone (optional filtering)
Retrieve DNS record details for a zone or a specific SLD, optionally filtered by record types.
### `sldInfo`
Retrieve DNS record details for a specific SLD, zone, and record type.
- **Parameters**: - **Parameters**:
- `ZoneEntity zone` - The zone object. - `ZoneEntity zone` - The zone object.
- `String sld` - The second-level domain. - `String sld` - (Optional) The second-level domain.
- `RecordType type` - Record type (e.g., A, CNAME). - `RecordType... types` - Optional record types to filter by (e.g., A, CNAME). If not specified, returns all record types.
- **Returns**: A list of `RecordEntity` objects matching the criteria.
```java ```java
RecordEntity record = cfDnsClient.sldInfo(zone, "www", RecordType.A); // Get all records for a specific SLD
System.out.println("Record IP: " + record.getContent()); List<RecordEntity> allSldRecords = cfDnsClient.recordList(zone, "www");
```
// Get only A records for a specific SLD
List<RecordEntity> aSldRecords = cfDnsClient.recordList(zone, "www", RecordType.A);
// Get all records of a zone
List<RecordEntity> allZoneRecords = cfDnsClient.recordList(zone);
// Get only A and AAAA records of a zone
List<RecordEntity> ipZoneRecords = cfDnsClient.recordList(zone, RecordType.A, RecordType.AAAA);
```
--- ---
### `recordCreate` ### `recordCreate`
@@ -197,33 +239,229 @@ cfDnsClient.recordDeleteTypeIfExists(zone, "api", RecordType.A);
System.out.println("Deletion attempt completed."); System.out.println("Deletion attempt completed.");
``` ```
### Batch Operations
Process multiple DNS record operations (POST, PUT, PATCH, DELETE) in a single batch request.
- **Parameters**:
- `ZoneEntity zone` - The target zone.
- `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("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.println("Created "+result.getPosts().size() +" records.");
```
#### Batch Update (PATCH)
Partially update existing records. **Record IDs are required** - fetch them first:
```java
// Step 1: Fetch existing records to get their IDs
List<RecordEntity> recordsToUpdate = new ArrayList<>();
recordsToUpdate.add(cfDnsClient.recordList(zone, "api",RecordType.A).get(0));
recordsToUpdate.add(cfDnsClient.recordList(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.println("Updated "+result.getPatches().size() +" records.");
```
#### Batch Replace (PUT)
Fully replace existing records. **Record IDs are required** - fetch them first:
```java
// Step 1: Fetch existing records to get their IDs
List<RecordEntity> recordsToReplace = new ArrayList<>();
recordsToReplace.add(cfDnsClient.recordList(zone, "mail",RecordType.A).get(0));
recordsToReplace.add(cfDnsClient.recordList(zone, "cdn",RecordType.A).get(0));
// Step 2: Modify all fields as needed (full replacement)
recordsToReplace.get(0).setContent("192.168.3.10");
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.println("Replaced "+result.getPuts().size() +" records.");
```
#### Batch Delete
Delete existing records. **Record IDs are required** - fetch them first:
```java
// Step 1: Fetch existing records to get their IDs
List<RecordEntity> recordsToDelete = new ArrayList<>();
recordsToDelete.add(cfDnsClient.recordList(zone, "cdn",RecordType.A).get(0));
recordsToDelete.add(cfDnsClient.recordList(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 "+recordsToDelete.size() +" records.");
```
#### Combined Batch Operations
Combine multiple operations in a single batch request:
```java
// 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.recordList(zone, "existing-api", RecordType.A).get(0)
);
recordsToUpdate.get(0).setContent("192.168.1.200");
List<RecordEntity> recordsToDelete = Arrays.asList(
cfDnsClient.recordList(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());
```
--- ---
### Notes on Error Handling ## 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 of a subdomain
List<RecordEntity> records = client.zone("example.com")
.record("www", RecordType.A)
.get();
// Get all DNS records of a zone
List<RecordEntity> zoneRecords = client.zone("example.com")
.list(RecordType.A, RecordType.AAAA);
// 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 CfDnsClientBuilder()
.withApiTokenAuth("your-api-token")
.build();
// 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);
```
---
# Notes on Error Handling
The `CfDnsClient` provides internal error-handling mechanisms through exceptions. For example: The `CfDnsClient` provides internal error-handling mechanisms through exceptions. For example:
- `CloudflareApiException` is thrown for errors during API communication or invalid responses. - `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.
#### Example: To enable exception throwing for empty results:
```java
CfDnsClient client = new CfDnsClientBuilder()
.withApiTokenAuth("your-api-token")
.withEmptyResultThrowsException(true)
.build();
```
## Example:
```java ```java
try { try {
RecordEntity record = cfDnsClient.sldInfo(zone, "www", RecordType.A); List<RecordEntity> records = cfDnsClient.recordList(zone, "www", RecordType.A);
System.out.println("Record IP: " + record.getContent()); System.out.println("Record IP: "+records.get(0).getContent());
} catch (CloudflareApiException e) { } catch (CloudflareApiException e) {
if (e instanceof CloudflareNotFoundException) { if (e instanceof CloudflareNotFoundException) {
log.warn("Sld not found: www"); log.warn("Sld not found: www");
} else { } else {
log.error("Error while getting sld info of www", e); log.error("Error while getting sld info of www", e);
throw e; throw e;
} }
} }
``` ```
--- ---
### Summary # Summary
`CfDnsClient` offers a simple interface for managing DNS entries via Cloudflare's public API, allowing seamless CRUD `CfDnsClient` offers a simple interface for managing DNS entries via Cloudflare's public API, allowing seamless CRUD operations and automation-friendly workflows.
operations and automation-friendly workflows.
+273 -222
View File
@@ -1,233 +1,284 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion> <modelVersion>4.0.0</modelVersion>
<groupId>codes.thischwa</groupId> <groupId>codes.thischwa</groupId>
<artifactId>cloudflaredns</artifactId> <artifactId>cloudflaredns</artifactId>
<version>0.1.0</version> <version>0.3.0</version>
<name>CloudflareDNS-java</name> <name>CloudflareDNS-java</name>
<inceptionYear>2025</inceptionYear> <inceptionYear>2025</inceptionYear>
<packaging>jar</packaging> <packaging>jar</packaging>
<issueManagement> <issueManagement>
<url>https://gitlab.com/th-schwarz/CloudflareDNS-java/-/issues</url> <url>https://gitlab.com/th-schwarz/CloudflareDNS-java/-/issues</url>
<system>GitLab Issues</system> <system>GitLab Issues</system>
</issueManagement> </issueManagement>
<properties> <properties>
<java.version>17</java.version> <java.version>17</java.version>
<file.encoding>UTF-8</file.encoding> <file.encoding>UTF-8</file.encoding>
<project.build.sourceEncoding>${file.encoding}</project.build.sourceEncoding> <project.build.sourceEncoding>${file.encoding}</project.build.sourceEncoding>
<project.reporting.outputEncoding>${file.encoding}</project.reporting.outputEncoding> <project.reporting.outputEncoding>${file.encoding}</project.reporting.outputEncoding>
<!-- checkstyle -->
<checkstyle.version>10.21.3</checkstyle.version>
<checkstyle.plugin.version>3.6.0</checkstyle.plugin.version>
<checkstyle.config.location>${project.basedir}/src/checkstyle/google_custom_checks.xml
</checkstyle.config.location>
<checkstyle.includeTestResources>false</checkstyle.includeTestResources>
<checkstyle.violationSeverity>warning</checkstyle.violationSeverity>
<checkstyle.failOnViolation>false</checkstyle.failOnViolation>
<checkstyle.consoleOutput>true</checkstyle.consoleOutput>
<linkX-Ref>false</linkX-Ref>
<!-- 3rd party dependencies -->
<jackson.version>2.18.2</jackson.version>
<httpclient5.version>5.4.3</httpclient5.version>
<lombok.version>1.18.36</lombok.version>
<slf4j.version>2.0.17</slf4j.version>
<logback-classic.version>1.5.18</logback-classic.version>
<junit5.version>5.12.2</junit5.version>
<mockito-junit5.version>5.17.0</mockito-junit5.version>
<!-- sonarqube -->
<sonar.organization>thischwa</sonar.organization>
<sonar.host.url>https://sonarcloud.io</sonar.host.url>
<sonar.sourceEncoding>${file.encoding}</sonar.sourceEncoding>
<sonar.projectKey>thischwa_CloudflareDNS-java</sonar.projectKey>
<sonar.projectName>CloudflareDNS-java</sonar.projectName>
<sonar.branch.name>develop</sonar.branch.name>
<sonar.test.exclusions>src/test/java/**/*</sonar.test.exclusions>
</properties>
<scm>
<developerConnection>scm:git:git@gitlab.com:th-schwarz/CloudflareDNS-java.git</developerConnection>
<connection>scm:git:git@gitlab.com:th-schwarz/CloudflareDNS-java.git</connection>
<url>https://gitlab.com/th-schwarz/CloudflareDNS-java</url>
<tag>v0.1.0</tag>
</scm>
<distributionManagement>
<repository>
<id>gitlab-cloudflaredns</id>
<name>GitLab Maven Packages</name>
<url>https://gitlab.com/api/v4/projects/68509751/packages/maven</url>
<releases>
<enabled>true</enabled>
</releases>
<snapshots>
<enabled>false</enabled>
</snapshots>
</repository>
</distributionManagement>
<dependencies>
<dependency>
<groupId>org.apache.httpcomponents.client5</groupId>
<artifactId>httpclient5</artifactId>
<version>${httpclient5.version}</version>
<exclusions>
<exclusion>
<groupId>org.slf4j</groupId>
<artifactId>*</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<version>${slf4j.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>${jackson.version}</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId>
<version>${jackson.version}</version>
</dependency>
<dependency>
<groupId>org.jetbrains</groupId>
<artifactId>annotations</artifactId>
<version>24.0.0</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>${junit5.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-junit-jupiter</artifactId>
<version>${mockito-junit5.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
<version>${logback-classic.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
<configuration>
<source>${java.version}</source>
<target>${java.version}</target>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-javadoc-plugin</artifactId>
<version>3.6.3</version>
<configuration>
<failOnError>false</failOnError>
<locale>en</locale>
<source>${java.version}</source>
</configuration>
<executions>
<execution>
<id>build javadoc jar</id>
<goals>
<goal>jar</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-release-plugin</artifactId>
<version>3.1.1</version>
<configuration>
<tagNameFormat>v@{project.version}</tagNameFormat>
<arguments>-DskipTests</arguments>
</configuration>
</plugin>
<plugin>
<!-- checkstyle --> <!-- checkstyle -->
<groupId>org.apache.maven.plugins</groupId> <checkstyle.version>12.3.0</checkstyle.version>
<artifactId>maven-checkstyle-plugin</artifactId> <checkstyle.plugin.version>3.6.0</checkstyle.plugin.version>
<version>${checkstyle.plugin.version}</version> <checkstyle.config.location>${project.basedir}/src/checkstyle/google_custom_checks.xml
<executions> </checkstyle.config.location>
<execution> <checkstyle.includeTestResources>false</checkstyle.includeTestResources>
<id>checkstyle-validate</id> <checkstyle.violationSeverity>warning</checkstyle.violationSeverity>
<phase>validate</phase> <checkstyle.failOnViolation>false</checkstyle.failOnViolation>
<goals> <checkstyle.propertyExpansion>config_loc=${basedir}
<goal>check</goal> </checkstyle.propertyExpansion>
</goals> <checkstyle.consoleOutput>true</checkstyle.consoleOutput>
</execution> <linkXRef>false</linkXRef>
</executions>
<dependencies>
<dependency>
<groupId>com.puppycrawl.tools</groupId>
<artifactId>checkstyle</artifactId>
<version>${checkstyle.version}</version>
</dependency>
</dependencies>
</plugin>
<plugin> <!-- 3rd party dependencies -->
<!-- generates the code coverage report for sonar cube --> <jackson.version>2.19.1</jackson.version>
<groupId>org.jacoco</groupId> <httpclient5.version>5.5.1</httpclient5.version>
<artifactId>jacoco-maven-plugin</artifactId> <lombok.version>1.18.36</lombok.version>
<version>0.8.12</version> <slf4j.version>2.0.17</slf4j.version>
<executions> <logback-classic.version>1.5.18</logback-classic.version>
<execution> <junit5.version>5.14.2</junit5.version>
<id>prepare-agent</id> <mockito-junit5.version>5.21.0</mockito-junit5.version>
<goals>
<goal>prepare-agent</goal>
</goals>
</execution>
<execution>
<id>report-code-coverage</id>
<phase>prepare-package</phase>
<goals>
<goal>report</goal>
</goals>
<configuration>
<formats>XML</formats>
</configuration>
</execution>
</executions>
</plugin>
<plugin> <!-- sonarqube -->
<!-- to export test summary --> <sonar.organization>th-schwarz</sonar.organization>
<groupId>org.apache.maven.plugins</groupId> <sonar.host.url>https://sonarcloud.io</sonar.host.url>
<artifactId>maven-surefire-plugin</artifactId> <sonar.sourceEncoding>${file.encoding}</sonar.sourceEncoding>
<version>3.5.3</version> <sonar.projectKey>th-schwarz_CloudflareDNS-java</sonar.projectKey>
</plugin> <sonar.projectName>CloudflareDNS-java</sonar.projectName>
<sonar.branch.name>develop</sonar.branch.name>
<sonar.test.exclusions>src/test/java/**/*</sonar.test.exclusions>
</plugins> <lombok-maven-plugin.version>1.18.20.0</lombok-maven-plugin.version>
</build> </properties>
<scm>
<developerConnection>scm:git:git@gitlab.com:th-schwarz/CloudflareDNS-java.git</developerConnection>
<connection>scm:git:git@gitlab.com:th-schwarz/CloudflareDNS-java.git</connection>
<url>https://gitlab.com/th-schwarz/CloudflareDNS-java</url>
<tag>v0.3.0</tag>
</scm>
<distributionManagement>
<repository>
<id>gitlab-cloudflaredns</id>
<name>GitLab Maven Packages</name>
<url>https://gitlab.com/api/v4/projects/68509751/packages/maven</url>
<releases>
<enabled>true</enabled>
</releases>
<snapshots>
<enabled>false</enabled>
</snapshots>
</repository>
</distributionManagement>
<dependencies>
<dependency>
<groupId>org.apache.httpcomponents.client5</groupId>
<artifactId>httpclient5</artifactId>
<version>${httpclient5.version}</version>
<exclusions>
<exclusion>
<groupId>org.slf4j</groupId>
<artifactId>*</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<version>${slf4j.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>${jackson.version}</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId>
<version>${jackson.version}</version>
</dependency>
<dependency>
<groupId>org.jetbrains</groupId>
<artifactId>annotations</artifactId>
<version>24.0.0</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>${junit5.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-junit-jupiter</artifactId>
<version>${mockito-junit5.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
<version>${logback-classic.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
<configuration>
<source>${java.version}</source>
<target>${java.version}</target>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-javadoc-plugin</artifactId>
<version>3.11.2</version>
<configuration>
<failOnError>false</failOnError>
<locale>en</locale>
<source>${java.version}</source>
</configuration>
<executions>
<execution>
<id>build javadoc jar</id>
<goals>
<goal>jar</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-release-plugin</artifactId>
<version>3.3.1</version>
<configuration>
<tagNameFormat>v@{project.version}</tagNameFormat>
<arguments>-DskipTests</arguments>
</configuration>
</plugin>
<plugin>
<!-- checkstyle -->
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-checkstyle-plugin</artifactId>
<version>${checkstyle.plugin.version}</version>
<executions>
<execution>
<id>checkstyle-validate</id>
<phase>validate</phase>
<goals>
<goal>check</goal>
</goals>
</execution>
</executions>
<dependencies>
<dependency>
<groupId>com.puppycrawl.tools</groupId>
<artifactId>checkstyle</artifactId>
<version>${checkstyle.version}</version>
</dependency>
</dependencies>
</plugin>
<plugin>
<!-- generates the code coverage report for sonar cube -->
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.13</version>
<executions>
<execution>
<id>prepare-agent</id>
<goals>
<goal>prepare-agent</goal>
</goals>
</execution>
<execution>
<id>report-code-coverage</id>
<phase>prepare-package</phase>
<goals>
<goal>report</goal>
</goals>
<configuration>
<formats>XML</formats>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<!-- to export test summary -->
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.5.3</version>
</plugin>
<plugin>
<!-- delombok -->
<groupId>org.projectlombok</groupId>
<artifactId>lombok-maven-plugin</artifactId>
<version>${lombok-maven-plugin.version}</version>
<executions>
<execution>
<id>delombok-sources</id>
<phase>generate-sources</phase>
<goals>
<goal>delombok</goal>
</goals>
</execution>
</executions>
<configuration>
<sourceDirectory>src/main/java</sourceDirectory>
<outputDirectory>${project.build.directory}/delombok</outputDirectory>
<addOutputDirectory>false</addOutputDirectory>
</configuration>
<dependencies>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
</dependency>
</dependencies>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>3.4.2</version>
<executions>
<execution>
<id>attach-delomboked-sources</id>
<phase>package</phase>
<goals>
<goal>jar</goal>
</goals>
<configuration>
<classesDirectory>${project.build.directory}/delombok</classesDirectory>
<classifier>sources</classifier>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project> </project>
@@ -1,12 +1,10 @@
package codes.thischwa.cf; package codes.thischwa.cf;
import codes.thischwa.cf.model.AbstractEntity;
import codes.thischwa.cf.model.AbstractResponse; import codes.thischwa.cf.model.AbstractResponse;
import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.apache.hc.client5.http.classic.methods.HttpDelete; import org.apache.hc.client5.http.classic.methods.HttpDelete;
import org.apache.hc.client5.http.classic.methods.HttpGet; import org.apache.hc.client5.http.classic.methods.HttpGet;
import org.apache.hc.client5.http.classic.methods.HttpPatch; import org.apache.hc.client5.http.classic.methods.HttpPatch;
@@ -21,6 +19,7 @@ import org.apache.hc.core5.http.HttpHeaders;
import org.apache.hc.core5.http.io.entity.EntityUtils; import org.apache.hc.core5.http.io.entity.EntityUtils;
import org.apache.hc.core5.http.io.entity.StringEntity; import org.apache.hc.core5.http.io.entity.StringEntity;
import org.apache.hc.core5.http.message.BasicClassicHttpRequest; import org.apache.hc.core5.http.message.BasicClassicHttpRequest;
import org.jetbrains.annotations.NotNull;
/** /**
* Abstract base class for creating HTTP clients to interact with the Cloudflare API. Provides * Abstract base class for creating HTTP clients to interact with the Cloudflare API. Provides
@@ -29,23 +28,20 @@ import org.apache.hc.core5.http.message.BasicClassicHttpRequest;
*/ */
@Slf4j @Slf4j
abstract class CfBasicHttpClient { abstract class CfBasicHttpClient {
private final String baseUrl;
private final String authEmail;
private final String authKey;
private final String baseUrl;
private final CfDnsClientBuilder.CfAuth auth;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
CfBasicHttpClient(String baseUrl, String authEmail, String authKey) /**
throws IllegalArgumentException { * Creates a new Cloudflare HTTP client with the specified base URL and authentication.
if (authEmail == null || authEmail.isBlank()) { *
throw new IllegalArgumentException("Authentication email must not be null or blank!"); * @param baseUrl the base URL for the Cloudflare API
} * @param auth the authentication mechanism to use
if (authKey == null || authKey.isBlank()) { */
throw new IllegalArgumentException("Authentication key must not be null or blank!"); CfBasicHttpClient(@NotNull String baseUrl, @NotNull CfDnsClientBuilder.CfAuth auth) {
}
this.baseUrl = baseUrl; this.baseUrl = baseUrl;
this.authEmail = authEmail; this.auth = auth;
this.authKey = authKey;
this.objectMapper = JsonConf.initObjectMapper(); this.objectMapper = JsonConf.initObjectMapper();
} }
@@ -55,35 +51,47 @@ abstract class CfBasicHttpClient {
request.addHeader(HttpHeaders.ACCEPT_ENCODING, "gzip"); request.addHeader(HttpHeaders.ACCEPT_ENCODING, "gzip");
request.addHeader(HttpHeaders.ACCEPT, ContentType.APPLICATION_JSON.getMimeType()); request.addHeader(HttpHeaders.ACCEPT, ContentType.APPLICATION_JSON.getMimeType());
request.addHeader(HttpHeaders.CONTENT_TYPE, ContentType.APPLICATION_JSON.getMimeType()); request.addHeader(HttpHeaders.CONTENT_TYPE, ContentType.APPLICATION_JSON.getMimeType());
request.addHeader("X-Auth-Email", authEmail); if (request instanceof ClassicHttpRequest classicRequest) {
request.addHeader("X-Auth-Key", authKey); auth.applyAuth(classicRequest);
}
}).build(); }).build();
} }
private <T extends AbstractResponse> T executeRequest(ClassicHttpRequest request, private <T extends AbstractResponse> T executeRequest(ClassicHttpRequest request,
Class<T> responseType) Class<T> responseType)
throws CloudflareApiException { throws CloudflareApiException {
String logUri = null; String logUri = null;
try (CloseableHttpClient client = createHttpClient()) { try (CloseableHttpClient client = createHttpClient()) {
ResultWrapper result = client.execute(request, ResultWrapper result = client.execute(request,
(ClassicHttpResponse response) -> new ResultWrapper(response.getCode(), (ClassicHttpResponse response) -> new ResultWrapper(response.getCode(),
EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8))); EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8)));
T respObj = objectMapper.readValue(result.responseBody, responseType);
if (!respObj.getResponseResultInfo().isSuccess()) {
log.error("API error.");
StringBuilder errorMessage = new StringBuilder("API error");
if (!respObj.getResponseResultInfo().getErrors().isEmpty()) {
errorMessage.append(": ");
respObj.getResponseResultInfo().getErrors().forEach(e -> {
log.error(" - {}", e.toString());
errorMessage.append(e).append("; ");
});
// Remove trailing "; "
if (errorMessage.toString().endsWith("; ")) {
errorMessage.setLength(errorMessage.length() - 2);
}
}
throw new CloudflareApiException(errorMessage.toString());
}
logUri = request.getRequestUri(); logUri = request.getRequestUri();
if (result.statusCode >= 200 && result.statusCode < 300) { if (result.statusCode >= 200 && result.statusCode < 300) {
T respObj = objectMapper.readValue(result.responseBody, responseType);
if (!respObj.getResponseResultInfo().isSuccess()) {
log.error("API error.");
respObj.getResponseResultInfo().getErrors().forEach(e -> log.error(" - {}", e));
throw new CloudflareApiException("API error.");
}
return respObj; return respObj;
} else { } else {
log.error("{} request failed for URL {}: Status {}", request.getMethod(), request.getUri(), log.error("{} request failed for URL {}: Status {}", request.getMethod(), request.getUri(),
result.statusCode); result.statusCode);
throw new CloudflareApiException( throw new CloudflareApiException(
request.getMethod() + " request failed with status code: " + result.statusCode); request.getMethod() + " request failed with status code: " + result.statusCode);
} }
} catch (JsonProcessingException e) { } catch (JsonProcessingException e) {
log.error("JSON parsing error for request to {}", logUri, e); log.error("JSON parsing error for request to {}", logUri, e);
@@ -97,37 +105,45 @@ abstract class CfBasicHttpClient {
* Sends a GET request to the given endpoint and maps the response. * Sends a GET request to the given endpoint and maps the response.
*/ */
<T extends AbstractResponse> T getRequest(String endpoint, Class<T> responseType) <T extends AbstractResponse> T getRequest(String endpoint, Class<T> responseType)
throws CloudflareApiException { throws CloudflareApiException {
HttpGet request = new HttpGet(buildUrl(endpoint)); HttpGet request = new HttpGet(buildUrl(endpoint));
return executeRequest(request, responseType); return executeRequest(request, responseType);
} }
/** /**
* Sends a DELETE request to the given endpoint and maps the response. * 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)); HttpDelete request = new HttpDelete(buildUrl(endpoint));
return executeRequest(request, (Class<T>) codes.thischwa.cf.model.RecordSingleResponse.class); return executeRequest(request, responseType);
} }
/** /**
* Sends a POST request with a payload to the given endpoint and maps the response. * Sends a POST request with a payload to the given endpoint and maps the response.
*/ */
<T extends AbstractResponse, R extends AbstractEntity> T postRequest(String endpoint, <T extends AbstractResponse> T postRequest(String endpoint,
R requestPayload) Object requestPayload,
throws CloudflareApiException { Class<T> responseType)
throws CloudflareApiException {
HttpPost request = new HttpPost(buildUrl(endpoint)); HttpPost request = new HttpPost(buildUrl(endpoint));
setRequestPayload(request, requestPayload); setRequestPayload(request, requestPayload);
return executeRequest(request, (Class<T>) codes.thischwa.cf.model.RecordSingleResponse.class); return executeRequest(request, responseType);
} }
/** /**
* Sends a PUT request with a payload to the given endpoint and maps the response. * Sends a PUT request with a payload to the given endpoint and maps the response.
*/ */
<T extends AbstractResponse, R extends AbstractEntity> T putRequest(String endpoint, <T extends AbstractResponse> T putRequest(String endpoint,
R requestPayload, Object requestPayload,
Class<T> responseType) Class<T> responseType)
throws CloudflareApiException { throws CloudflareApiException {
HttpPut request = new HttpPut(buildUrl(endpoint)); HttpPut request = new HttpPut(buildUrl(endpoint));
setRequestPayload(request, requestPayload); setRequestPayload(request, requestPayload);
return executeRequest(request, responseType); return executeRequest(request, responseType);
@@ -135,24 +151,34 @@ abstract class CfBasicHttpClient {
/** /**
* Sends a PATCH request with a payload to the given endpoint and maps the response. * 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, R extends AbstractEntity> T patchRequest(String endpoint, <T extends AbstractResponse> T patchRequest(String endpoint,
R requestPayload) Object requestPayload,
throws CloudflareApiException { Class<T> responseType)
throws CloudflareApiException {
HttpPatch request = new HttpPatch(buildUrl(endpoint)); HttpPatch request = new HttpPatch(buildUrl(endpoint));
setRequestPayload(request, requestPayload); setRequestPayload(request, requestPayload);
return executeRequest(request, (Class<T>) codes.thischwa.cf.model.RecordSingleResponse.class); return executeRequest(request, responseType);
} }
/** /**
* Sets the JSON payload for a request. * Sets the JSON payload for a request.
*/ */
private <R extends AbstractEntity> void setRequestPayload(BasicClassicHttpRequest request, private void setRequestPayload(BasicClassicHttpRequest request,
R requestPayload) Object requestPayload)
throws CloudflareApiException { throws CloudflareApiException {
try { try {
request.setEntity(new StringEntity(objectMapper.writeValueAsString(requestPayload), String jsonPayload = objectMapper.writeValueAsString(requestPayload);
ContentType.APPLICATION_JSON)); log.trace("Request methode [{}] payload: {}", request.getMethod(), jsonPayload);
request.setEntity(new StringEntity(jsonPayload,
ContentType.APPLICATION_JSON));
} catch (JsonProcessingException e) { } catch (JsonProcessingException e) {
throw new CloudflareApiException("Error serializing JSON payload", e); throw new CloudflareApiException("Error serializing JSON payload", e);
} }
+255 -104
View File
@@ -1,6 +1,10 @@
package codes.thischwa.cf; 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.AbstractResponse;
import codes.thischwa.cf.model.BatchEntry;
import codes.thischwa.cf.model.BatchResponse;
import codes.thischwa.cf.model.PagingRequest; import codes.thischwa.cf.model.PagingRequest;
import codes.thischwa.cf.model.RecordEntity; import codes.thischwa.cf.model.RecordEntity;
import codes.thischwa.cf.model.RecordMultipleResponse; import codes.thischwa.cf.model.RecordMultipleResponse;
@@ -8,98 +12,128 @@ import codes.thischwa.cf.model.RecordSingleResponse;
import codes.thischwa.cf.model.RecordType; import codes.thischwa.cf.model.RecordType;
import codes.thischwa.cf.model.ZoneEntity; import codes.thischwa.cf.model.ZoneEntity;
import codes.thischwa.cf.model.ZoneMultipleResponse; import codes.thischwa.cf.model.ZoneMultipleResponse;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List; import java.util.List;
import lombok.Setter; import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.jetbrains.annotations.Nullable;
/** /**
* CfDnsClient is a client interface to interact with Cloudflare DNS service. It allows managing DNS * CfDnsClient is a client interface to interact with Cloudflare DNS service. It allows managing DNS
* records and zones within the Cloudflare system, including creating, updating, retrieving, and * records and zones within the Cloudflare system, including creating, updating, retrieving, and
* deleting DNS records. * deleting DNS records.
* *
* <p>Example: * <p>Example with API token authentication (recommended):
* <pre><code> * <pre><code>
* // Create a new CfDnsClient instance * // Create a new CfDnsClient instance with API token
* CfDnsClient cfDnsClient = new CfDnsClient( * CfDnsClient cfDnsClient = new CfDnsClientBuilder()
* "email@example.com", * .withApiTokenAuth("your-api-token")
* "yourApiKey" * .build();
* ); *
* // Retrieve a zone * // Retrieve a zone
* ZoneEntity zone = cfDnsClient.zoneInfo("example.com"); * ZoneEntity zone = cfDnsClient.zoneGet("example.com");
* System.out.println("Zone ID: " + zone.getId()); * System.out.println("Zone ID: " + zone.getId());
*
* // Retrieve records of a subdomain * // Retrieve records of a subdomain
* List&lt;{@link RecordEntity}&gt; records = cfDnsClient.sldListAll(zone, "sld"); * List&lt;RecordEntity&gt; records = cfDnsClient.recordList(zone, "sld");
* records.forEach(record -> * records.forEach(record ->
* System.out.println("Record Type: " + record.getType() + ", Value: " + record.getContent()) * System.out.println("Record Type: " + record.getType() + ", Value: " + record.getContent())
* ); * );
*
* // Create a record for the subdomain "api" * // 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()); * System.out.println("Created Record ID: " + created.getId());
* </code></pre> * </code></pre>
*
* <p>Example with email/key authentication (legacy):
* <pre><code>
* CfDnsClient cfDnsClient = new CfDnsClientBuilder()
* .withEmailKeyAuth("email@example.com", "your-api-key")
* .build();
* </code></pre>
*
* <p>Example with exception throwing enabled:
* <pre><code>
* // Throws exception when results are empty
* CfDnsClient cfDnsClient = new CfDnsClientBuilder()
* .withApiTokenAuth("your-api-token")
* .withEmptyResultThrowsException(true)
* .build();
* </code></pre>
*
* <p>Example with custom base URL:
* <pre><code>
* CfDnsClient cfDnsClient = new CfDnsClientBuilder()
* .withApiTokenAuth("your-api-token")
* .withBaseUrl("https://custom-api.example.com")
* .build();
* </code></pre>
*/ */
@Setter
@Slf4j @Slf4j
public class CfDnsClient extends CfBasicHttpClient { public class CfDnsClient extends CfBasicHttpClient {
private static final String DEFAULT_BASEURL = "https://api.cloudflare.com/client/v4";
private final ResponseValidator responseValidator; private final ResponseValidator responseValidator;
/** private final boolean emptyResultThrowsException;
* Constructs a new instance of {@code CfDnsClient}.
*
* @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 authentication
* process.
*/
public CfDnsClient(String authEmail, String authKey) {
this(DEFAULT_BASEURL, authEmail, authKey);
}
/**
* Constructs a new instance of {@code CfDnsClient}.
*
* @param baseUrl The base URL of the Cloudflare API to be used for requests.
* @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 authentication
* process.
*/
public CfDnsClient(String baseUrl, String authEmail, String authKey) {
this(true, baseUrl, authEmail, authKey);
}
/** /**
* Constructs a new instance of {@code CfDnsClient}. * Constructs a new instance of {@code CfDnsClient}.
* *
* @param emptyResultThrowsException A boolean value indicating whether an exception should be * @param emptyResultThrowsException A boolean value indicating whether an exception should be
* thrown when the result is empty, it's valid for 'list * thrown when the result is empty. Applies to both single and
* requests' only. Default is true. * 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
* authentication process.
*/
public CfDnsClient(boolean emptyResultThrowsException, String authEmail, String authKey) {
this(emptyResultThrowsException, DEFAULT_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.
* @param baseUrl The base URL for the Cloudflare API endpoint. * @param baseUrl The base URL for the Cloudflare API endpoint.
* @param authEmail The email associated with the Cloudflare account for * @param auth The authentication mechanism to use (ApiTokenAuth or EmailKeyAuth)
* authentication.
* @param authKey The API key for authenticating the client with Cloudflare
* services.
*/ */
public CfDnsClient(boolean emptyResultThrowsException, String baseUrl, String authEmail, CfDnsClient(boolean emptyResultThrowsException, String baseUrl, CfDnsClientBuilder.CfAuth auth) {
String authKey) { super(baseUrl, auth);
super(baseUrl, authEmail, authKey);
this.responseValidator = new ResponseValidator(emptyResultThrowsException); this.responseValidator = new ResponseValidator(emptyResultThrowsException);
this.emptyResultThrowsException = emptyResultThrowsException;
}
private static String buildFqdn(ZoneEntity zone, String sld) {
return sld + "." + zone.getName();
}
/**
* Groups a list of DNS records by their fully qualified domain name (FQDN).
*
* @param records A list of {@link RecordEntity} objects to be grouped by FQDN.
* @return A map where the key is the FQDN (name field) and the value is a list of {@link RecordEntity}
* objects that share that FQDN.
*/
public static Map<String, List<RecordEntity>> groupRecordsByFqdn(List<RecordEntity> records) {
if (records == null) {
return new HashMap<>();
}
return records.stream()
.collect(Collectors.groupingBy(RecordEntity::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);
} }
/** /**
@@ -108,8 +142,8 @@ public class CfDnsClient extends CfBasicHttpClient {
* @return A list of ZoneEntity objects representing the zones retrieved 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. * @throws CloudflareApiException If an error occurs during the API request or response handling.
*/ */
public List<ZoneEntity> zoneListAll() throws CloudflareApiException { public List<ZoneEntity> zoneList() throws CloudflareApiException {
return zoneListAll(PagingRequest.defaultPaging()); return zoneList(PagingRequest.defaultPaging());
} }
/** /**
@@ -121,7 +155,7 @@ public class CfDnsClient extends CfBasicHttpClient {
* @throws CloudflareApiException if there is an error during the API request or response * @throws CloudflareApiException if there is an error during the API request or response
* processing * 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()); String endpoint = pagingRequest.addQueryString(CfRequest.ZONE_LIST.buildPath());
ZoneMultipleResponse response = getRequest(endpoint, ZoneMultipleResponse.class); ZoneMultipleResponse response = getRequest(endpoint, ZoneMultipleResponse.class);
checkResponse(response); checkResponse(response);
@@ -136,7 +170,7 @@ public class CfDnsClient extends CfBasicHttpClient {
* @throws CloudflareApiException If an error occurs while making the API request or processing * @throws CloudflareApiException If an error occurs while making the API request or processing
* the response. * the response.
*/ */
public ZoneEntity zoneInfo(String name) throws CloudflareApiException { public ZoneEntity zoneGet(String name) throws CloudflareApiException {
String endpoint = CfRequest.ZONE_INFO.buildPath(name); String endpoint = CfRequest.ZONE_INFO.buildPath(name);
ZoneMultipleResponse response = getRequest(endpoint, ZoneMultipleResponse.class); ZoneMultipleResponse response = getRequest(endpoint, ZoneMultipleResponse.class);
checkResponse(response, true); checkResponse(response, true);
@@ -144,21 +178,40 @@ public class CfDnsClient extends CfBasicHttpClient {
} }
/** /**
* Retrieves all record entities for a specific second-level domain (SLD) within a given DNS * Retrieves DNS records for the specified second-level domain (SLD) within a zone.
* zone.
* *
* @param zone The DNS zone entity for which the SLD records are to be fetched. * @param zone the zone entity representing the DNS zone to query
* @param sld The second-level domain name for which the records are retrieved. * @param sld the second-level domain (SLD) to filter the records
* @return A list of {@code RecordEntity} associated with the desired SLD. * @return a list of RecordEntity objects that match the specified SLD within the zone
* @throws CloudflareApiException If an error occurs while interacting with the Cloudflare API. * @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> sldListAll(ZoneEntity zone, String sld) throws CloudflareApiException { public List<RecordEntity> recordList(ZoneEntity zone, String sld) throws CloudflareApiException {
return sldListAll(zone, sld, PagingRequest.defaultPaging()); return recordList(zone, sld, (RecordType[]) null);
} }
/**
* Retrieves DNS records for the specified second-level domain (SLD) within a 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.
* @param types Optional parameter specifying one or more DNS record types to filter the results.
* @return A list of {@code RecordEntity} objects representing the DNS records for the specified domain.
* @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> recordList(ZoneEntity zone, String sld, @Nullable RecordType... types)
throws CloudflareApiException {
PagingRequest pagingRequest = PagingRequest.defaultPaging();
List<RecordEntity> recs = recordList(zone, sld, pagingRequest);
return filterAndSetZoneRecords(zone, types, recs);
}
/** /**
* Retrieves all record entities for a specific second-level domain (SLD) within a given DNS * Retrieves all record entities for a specific second-level domain (SLD) within a given DNS
* zone. * zone using the provided paging request parameters.
* *
* @param zone The DNS zone entity for which the SLD records are to be fetched. * @param zone The DNS zone entity for which the SLD records are to be fetched.
* @param sld The second-level domain name for which the records are retrieved. * @param sld The second-level domain name for which the records are retrieved.
@@ -166,7 +219,7 @@ public class CfDnsClient extends CfBasicHttpClient {
* @return A list of {@code RecordEntity} associated with the desired SLD. * @return A list of {@code RecordEntity} associated with the desired SLD.
* @throws CloudflareApiException If an error occurs while interacting with the Cloudflare API. * @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 { throws CloudflareApiException {
String fqdn = buildFqdn(zone, sld); String fqdn = buildFqdn(zone, sld);
String endpoint = String endpoint =
@@ -177,22 +230,21 @@ public class CfDnsClient extends CfBasicHttpClient {
} }
/** /**
* Retrieves detailed information about a specific second-level domain (SLD) record for a given * Retrieves a list of all DNS records for a given zone.
* zone and record type from the Cloudflare API. * Optionally filters by one or more DNS record types.
* *
* @param zone the zone entity that contains information about the DNS zone * @param zone The zone entity containing information about the domain zone.
* @param sld the second-level domain (SLD) for which the record information is requested * @param types Optional parameter specifying one or more DNS record types to filter the results.
* @param type the type of DNS record (e.g., A, AAAA, CNAME) being queried * @return A list of {@code RecordEntity} objects representing the DNS records for the specified zone.
* @return the {@link RecordEntity} of the requested SLD and record type * @throws CloudflareApiException if an error occurs while interacting with the Cloudflare API
* @throws CloudflareApiException if an error occurs during interaction with the Cloudflare API
*/ */
public RecordEntity sldInfo(ZoneEntity zone, String sld, RecordType type) public List<RecordEntity> recordList(ZoneEntity zone, RecordType... types)
throws CloudflareApiException { throws CloudflareApiException {
String fqdn = buildFqdn(zone, sld); String endpoint = CfRequest.RECORD_LIST.buildPath(zone.getId());
String endpoint = CfRequest.RECORD_INFO_NAME_TYPE.buildPath(zone.getId(), fqdn, type);
RecordMultipleResponse resp = getRequest(endpoint, RecordMultipleResponse.class); RecordMultipleResponse resp = getRequest(endpoint, RecordMultipleResponse.class);
checkResponse(resp, true); checkResponse(resp, false);
return resp.getResult().get(0); List<RecordEntity> recs = resp.getResult();
return filterAndSetZoneRecords(zone, types, recs);
} }
/** /**
@@ -209,7 +261,7 @@ public class CfDnsClient extends CfBasicHttpClient {
* or creating the record. * or creating the record.
*/ */
public RecordEntity recordCreateSld(ZoneEntity zone, String sld, int ttl, RecordType type, public RecordEntity recordCreateSld(ZoneEntity zone, String sld, int ttl, RecordType type,
String content) throws CloudflareApiException { String content) throws CloudflareApiException {
String fqdn = buildFqdn(zone, sld); String fqdn = buildFqdn(zone, sld);
return recordCreate(zone, fqdn, ttl, type, content); return recordCreate(zone, fqdn, ttl, type, content);
} }
@@ -226,7 +278,7 @@ public class CfDnsClient extends CfBasicHttpClient {
* @throws CloudflareApiException if an error occurs while interacting with the Cloudflare API * @throws CloudflareApiException if an error occurs while interacting with the Cloudflare API
*/ */
public RecordEntity recordCreate(ZoneEntity zone, String name, int ttl, RecordType type, public RecordEntity recordCreate(ZoneEntity zone, String name, int ttl, RecordType type,
String content) throws CloudflareApiException { String content) throws CloudflareApiException {
RecordEntity rec = RecordEntity.build(name, type, ttl, content); RecordEntity rec = RecordEntity.build(name, type, ttl, content);
return recordCreate(zone, rec); return recordCreate(zone, rec);
} }
@@ -244,10 +296,12 @@ public class CfDnsClient extends CfBasicHttpClient {
public RecordEntity recordCreate(ZoneEntity zone, RecordEntity rec) public RecordEntity recordCreate(ZoneEntity zone, RecordEntity rec)
throws CloudflareApiException { throws CloudflareApiException {
String endpoint = CfRequest.RECORD_CREATE.buildPath(zone.getId()); String endpoint = CfRequest.RECORD_CREATE.buildPath(zone.getId());
RecordSingleResponse resp = postRequest(endpoint, rec); RecordSingleResponse resp = postRequest(endpoint, rec, RecordSingleResponse.class);
checkResponse(resp); 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(); RecordEntity retRec = resp.getResult();
retRec.setZoneId(zone.getId());
return retRec;
} }
/** /**
@@ -262,9 +316,9 @@ public class CfDnsClient extends CfBasicHttpClient {
public boolean recordDelete(ZoneEntity zone, RecordEntity rec) throws CloudflareApiException { public boolean recordDelete(ZoneEntity zone, RecordEntity rec) throws CloudflareApiException {
boolean changed = recordDelete(zone, rec.getId()); boolean changed = recordDelete(zone, rec.getId());
if (changed) { if (changed) {
log.info("Record {} of the type {} successful deleted.", rec.getName(), rec.getType()); log.debug("Record {} of the type [{}] successful deleted.", rec.getSld(), rec.getType());
} else { } 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; return changed;
} }
@@ -280,9 +334,9 @@ public class CfDnsClient extends CfBasicHttpClient {
*/ */
public boolean recordDelete(ZoneEntity zone, String id) throws CloudflareApiException { public boolean recordDelete(ZoneEntity zone, String id) throws CloudflareApiException {
String endpoint = CfRequest.RECORD_DELETE.buildPath(zone.getId(), id); String endpoint = CfRequest.RECORD_DELETE.buildPath(zone.getId(), id);
RecordSingleResponse resp = deleteRequest(endpoint); RecordSingleResponse resp = deleteRequest(endpoint, RecordSingleResponse.class);
checkResponse(resp); checkResponse(resp);
log.debug("Record {} successful deleted.", id); log.debug("Record id#{} successful deleted.", id);
return resp.getResult().getId().equals(id); return resp.getResult().getId().equals(id);
} }
@@ -301,9 +355,9 @@ public class CfDnsClient extends CfBasicHttpClient {
rec.setModifiedOn(null); rec.setModifiedOn(null);
rec.setCreatedOn(null); rec.setCreatedOn(null);
String endpoint = CfRequest.RECORD_UPDATE.buildPath(zone.getId(), rec.getId()); String endpoint = CfRequest.RECORD_UPDATE.buildPath(zone.getId(), rec.getId());
RecordSingleResponse resp = patchRequest(endpoint, rec); RecordSingleResponse resp = patchRequest(endpoint, rec, RecordSingleResponse.class);
checkResponse(resp); 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(); return resp.getResult();
} }
@@ -313,25 +367,121 @@ public class CfDnsClient extends CfBasicHttpClient {
* *
* @param zone The DNS zone entity in which the record exists. * @param zone The DNS zone entity in which the record exists.
* @param sld The second-level domain for which the record is being checked. * @param sld The second-level domain for which the record is being checked.
* @param recordTypes The types of DNS records that should be deleted, if they exist. * @param recordTypes The types of DNS records that should be deleted if they exist.
* @throws CloudflareApiException If an error occurs during API communication. * @throws CloudflareApiException If an error occurs during API communication.
*/ */
public void recordDeleteTypeIfExists(ZoneEntity zone, String sld, RecordType... recordTypes) public void recordDeleteTypeIfExists(ZoneEntity zone, String sld, RecordType... recordTypes)
throws CloudflareApiException { throws CloudflareApiException {
String fqdn = buildFqdn(zone, sld); String fqdn = buildFqdn(zone, sld);
for (RecordType recordType : recordTypes) { List<RecordEntity> recs;
try {
recs = recordList(zone, sld, recordTypes);
} catch (CloudflareNotFoundException e) {
log.trace("No record of type {} found for domain {}.", recordTypes, fqdn);
return;
}
for (RecordEntity rec : recs) {
try { try {
RecordEntity rec = sldInfo(zone, sld, recordType);
recordDelete(zone, rec); recordDelete(zone, rec);
log.info("Record {} of type {} successful deleted.", fqdn, recordTypes); log.info("Record {} of type {} successful deleted.", fqdn, recordTypes);
} catch (CloudflareNotFoundException e) { } catch (CloudflareApiException e) {
log.debug("Record {} of type {} does not exist.", fqdn, recordTypes); log.error("Failed to delete record {} of type {} for zone {}: {}", fqdn, recordTypes, zone.getName(), e.getMessage());
} }
} }
} }
private static String buildFqdn(ZoneEntity zone, String sld) { /**
return sld + "." + zone.getName(); * Processes a batch of DNS record operations (POST, PUT, PATCH, DELETE) for a specified zone.
* This method builds and cleans the input records, sends the batch request to the Cloudflare API,
* and returns a result containing processed batch entries.
*
* @param zone The zone entity to which the records belong.
* @param postRecords A list of DNS records to be created (POST). This parameter is nullable.
* @param putRecords A list of DNS records to be fully replaced (PUT). This parameter is nullable.
* @param patchRecords A list of DNS records to be partially updated (PATCH). This parameter is nullable.
* @param deleteRecords A list of DNS records to be deleted (DELETE). This parameter is nullable.
* @return The resulting {@link BatchEntry} containing the processed records after the batch operation.
* @throws CloudflareApiException If an error occurs while communicating with the Cloudflare API.
*/
public BatchEntry recordBatch(ZoneEntity zone, @Nullable List<RecordEntity> postRecords, @Nullable List<RecordEntity> putRecords,
@Nullable List<RecordEntity> patchRecords, @Nullable List<RecordEntity> deleteRecords)
throws CloudflareApiException {
BatchEntry batchEntry = new BatchEntry();
// build 'clean' record entries
if (postRecords != null) {
batchEntry.setPosts(cleanRecordsForPostOrPut(postRecords));
}
if (putRecords != null) {
batchEntry.setPuts(cleanRecordsForPostOrPut(putRecords));
}
if (patchRecords != null) {
batchEntry.setPatches(cleanRecordsForPatch(patchRecords));
}
if (deleteRecords != null) {
batchEntry.setDeletes(cleanRecordsForDelete(deleteRecords));
}
String endpoint = CfRequest.RECORD_BATCH.buildPath(zone.getId());
BatchResponse resp = postRequest(endpoint, batchEntry, BatchResponse.class);
checkResponse(resp);
// set zone id
BatchEntry result = resp.getResult();
setZoneIdForBatchResults(result, zone.getId());
return result;
}
private List<RecordEntity> filterAndSetZoneRecords(ZoneEntity zone, @Nullable RecordType[] types, List<RecordEntity> recs)
throws CloudflareNotFoundException {
List<RecordEntity> filtered;
if (types != null && types.length > 0) {
Set<RecordType> allowedTypes = new HashSet<>(Arrays.asList(types));
filtered = recs.stream()
.filter(rec -> allowedTypes.contains(RecordType.valueOf(rec.getType())))
.collect(Collectors.toList());;
} else {
filtered = new ArrayList<>(recs);
}
filtered.forEach(rec -> rec.setZoneId(zone.getId()));
// special exception for an empty result, normally it's done in the RecordValidator
if (filtered.isEmpty() && emptyResultThrowsException) {
throw new CloudflareNotFoundException("No records exist after filtering zone: " + zone.getName());
}
return filtered;
}
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(zoneId));
}
if (result.getPuts() != null) {
result.getPuts().forEach(rec -> rec.setZoneId(zoneId));
}
if (result.getPatches() != null) {
result.getPatches().forEach(rec -> rec.setZoneId(zoneId));
}
} }
private void checkResponse(AbstractResponse resp) throws CloudflareApiException { private void checkResponse(AbstractResponse resp) throws CloudflareApiException {
@@ -342,4 +492,5 @@ public class CfDnsClient extends CfBasicHttpClient {
throws CloudflareApiException { throws CloudflareApiException {
responseValidator.validate(resp, singleResultExpected); responseValidator.validate(resp, singleResultExpected);
} }
} }
@@ -0,0 +1,190 @@
package codes.thischwa.cf;
import org.apache.hc.core5.http.ClassicHttpRequest;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
/**
* Builder class for configuring and creating instances of {@link CfDnsClient}.
* This class provides a fluent API for customizing the client settings,
* such as the base URL and authentication mechanism.
*/
public class CfDnsClientBuilder {
/**
* The default base URL for the Cloudflare v4 API requests made by the {@code CfDnsClient}.
*/
public static final String DEFAULT_BASEURL = "https://api.cloudflare.com/client/v4";
private boolean emptyResultThrowsException;
private CfAuth auth;
@Nullable
private String baseUrl;
/**
* Constructs a new instance of `CfDnsClientBuilder`.
*
* <p>This class serves as a builder for creating and configuring instances of a CfDnsClient. It provides
* a fluent API to set various optional configurations, such as API authentication methods and base
* URL, before constructing the client.
*
* <p>By using this constructor, you can initiate the building process with default settings, which can
* later be overridden using the provided builder methods.
*/
public CfDnsClientBuilder() {
}
/**
* Configures whether an exception should be thrown when an empty result is encountered
* during operations performed by the `CfDnsClient`.
*
* @param emptyResultThrowsException a boolean flag indicating if an exception should be thrown
* when an empty result is returned. If set to `true`, operations
* that result in an empty response will throw an exception;
* otherwise, they will not.
* @return the current instance of {@code CfDnsClientBuilder}, allowing for method chaining
* to further configure the builder.
*/
public CfDnsClientBuilder withEmptyResultThrowsException(boolean emptyResultThrowsException) {
this.emptyResultThrowsException = emptyResultThrowsException;
return this;
}
/**
* Sets the base URL to be used by the {@code CfDnsClient}.
* This method allows configuring the base URL for API requests, overriding any default value.
*
* @param baseUrl the base URL to be used for API requests
* @return the current instance of {@code CfDnsClientBuilder}, enabling method chaining
*/
public CfDnsClientBuilder withBaseUrl(String baseUrl) {
this.baseUrl = baseUrl;
return this;
}
/**
* Configures the authentication method for the {@code CfDnsClient} to use an API token.
* This is the recommended way to authenticate with the Cloudflare API, as it provides
* enhanced security and ease of use compared to other authentication mechanisms.
*
* @param apiToken the Cloudflare API token. This token is required for authenticating
* API requests and must not be null or blank.
* @return the current instance of {@code CfDnsClientBuilder}, allowing for method chaining
* to further configure the builder.
* @throws IllegalArgumentException if the {@code apiToken} is null or blank.
*/
public CfDnsClientBuilder withApiTokenAuth(String apiToken) {
this.auth = new ApiTokenAuth(apiToken);
return this;
}
/**
* Configures the authentication method for the {@code CfDnsClient} to use an email and API key.
* This approach uses the legacy authentication mechanism provided by Cloudflare, where requests
* are authenticated with a combination of an account's email address and API key.
*
* @param authEmail the email address associated with the Cloudflare account. This must not be null or blank.
* @param authKey the API key of the Cloudflare account. This must not be null or blank.
* @return the current instance of {@code CfDnsClientBuilder}, allowing for method chaining
* to further configure the builder.
* @throws IllegalArgumentException if {@code authEmail} or {@code authKey} is null or blank.
*/
public CfDnsClientBuilder withEmailKeyAuth(String authEmail, String authKey) {
this.auth = new EmailKeyAuth(authEmail, authKey);
return this;
}
/**
* Builds and returns a configured instance of {@code CfDnsClient}.
*
* <p>The method constructs a new {@code CfDnsClient} object based on the
* options set in the {@code CfDnsClientBuilder}. If no base URL has been
* explicitly configured, a default base URL will be used.
*
* @return a new instance of {@code CfDnsClient} configured with the
* specified options such as base URL, authentication details,
* and the exception-handling policy for empty results.
*/
public CfDnsClient build() {
String url = baseUrl == null ? DEFAULT_BASEURL : baseUrl;
return new CfDnsClient(emptyResultThrowsException, url, auth);
}
/**
* Interface for Cloudflare authentication mechanisms.
* Implementations of this interface provide different methods of authentication
* with the Cloudflare API (e.g., API token, email/key combination).
*/
interface CfAuth {
/**
* Applies authentication headers to the given HTTP request.
*
* @param request the HTTP request to authenticate
*/
void applyAuth(ClassicHttpRequest request);
}
/**
* Authentication mechanism using Cloudflare API token.
* This is the recommended authentication method for the Cloudflare API.
*/
static class ApiTokenAuth implements CfAuth {
private final String apiToken;
/**
* Creates a new API token authentication object.
*
* @param apiToken the Cloudflare API token
* @throws IllegalArgumentException if the API token is null or blank
*/
ApiTokenAuth(@NotNull String apiToken) {
if (apiToken.isBlank()) {
throw new IllegalArgumentException("API token must not be null or blank!");
}
this.apiToken = apiToken;
}
@Override
public void applyAuth(ClassicHttpRequest request) {
request.addHeader("Authorization", "Bearer " + apiToken);
}
}
/**
* Authentication mechanism using Cloudflare account email and API key.
* This is the legacy authentication method for the Cloudflare API.
*/
static class EmailKeyAuth implements CfAuth {
private final String authEmail;
private final String authKey;
/**
* Creates a new email/key authentication object.
*
* @param authEmail the email address associated with the Cloudflare account
* @param authKey the API key of the Cloudflare account
* @throws IllegalArgumentException if email or key is null or blank
*/
EmailKeyAuth(@NotNull String authEmail, @NotNull String authKey) {
if (authEmail.isBlank()) {
throw new IllegalArgumentException("Authentication email must not be null or blank!");
}
if (authKey.isBlank()) {
throw new IllegalArgumentException("Authentication key must not be null or blank!");
}
this.authEmail = authEmail;
this.authKey = authKey;
}
@Override
public void applyAuth(ClassicHttpRequest request) {
request.addHeader("X-Auth-Email", authEmail);
request.addHeader("X-Auth-Key", authKey);
}
}
}
+17 -9
View File
@@ -9,7 +9,9 @@ import lombok.Getter;
@Getter @Getter
public enum CfRequest { public enum CfRequest {
/** Represents the API endpoint path for retrieving the list of DNS zones. */ /**
* Represents the API endpoint path for retrieving the list of DNS zones.
*/
ZONE_LIST("/zones"), ZONE_LIST("/zones"),
/** /**
* Represents the API endpoint path for retrieving information about a specific DNS zone by its * Represents the API endpoint path for retrieving information about a specific DNS zone by its
@@ -18,6 +20,12 @@ public enum CfRequest {
*/ */
ZONE_INFO("/zones?name=%s"), ZONE_INFO("/zones?name=%s"),
/**
* Represents the API endpoint path for retrieving information about DNS records within a
* specific DNS zone. The endpoint path includes a placeholder for the zone identifier, which
* needs to be provided to construct the complete path.
*/
RECORD_LIST("/zones/%s/dns_records"),
/** /**
* Represents the API endpoint path for creating a new DNS record within a specific DNS zone. The * Represents the API endpoint path for creating a new DNS record within a specific DNS zone. The
* endpoint path includes a placeholder for the zone identifier, which needs to be provided to * endpoint path includes a placeholder for the zone identifier, which needs to be provided to
@@ -30,19 +38,19 @@ public enum CfRequest {
* and the record name, which need to be provided to construct the complete path. * and the record name, which need to be provided to construct the complete path.
*/ */
RECORD_INFO_NAME("/zones/%s/dns_records?name=%s"), RECORD_INFO_NAME("/zones/%s/dns_records?name=%s"),
/**
* Represents the API endpoint path for retrieving information about a DNS record within a
* specific DNS zone by its name and type. The endpoint path includes placeholders for the zone
* identifier, record name, and record type, which need to be provided to construct the complete
* path.
*/
RECORD_INFO_NAME_TYPE("/zones/%s/dns_records?name=%s&type=%s"),
/** /**
* Represents the API endpoint path for updating an existing DNS record within a specific DNS * Represents the API endpoint path for updating an existing DNS record within a specific DNS
* zone. The endpoint path includes placeholders for the zone identifier and the record * zone. The endpoint path includes placeholders for the zone identifier and the record
* identifier, which need to be provided to construct the complete path. * identifier, which need to be provided to construct the complete path.
*/ */
RECORD_UPDATE("/zones/%s/dns_records/%s"), RECORD_UPDATE("/zones/%s/dns_records/%s"),
/**
* Represents the API endpoint path for performing batch operations on DNS records within a specific zone.
* The placeholder "%s" in the path is intended to be replaced by a zone identifier.
* This constant is used to construct the URL for interacting with the batch DNS records API.
*/
RECORD_BATCH("/zones/%s/dns_records/batch"),
/** /**
* Represents the API endpoint path for deleting an existing DNS record within a specific DNS * Represents the API endpoint path for deleting an existing DNS record within a specific DNS
* zone. The endpoint path includes placeholders for the zone identifier and the record * zone. The endpoint path includes placeholders for the zone identifier and the record
@@ -62,7 +70,7 @@ public enum CfRequest {
* arguments. * arguments.
* *
* @param vars the arguments to format the path string with; these are typically specific * @param vars the arguments to format the path string with; these are typically specific
* identifiers or parameters required by the API endpoint. * identifiers or parameters required by the API endpoint.
* @return the fully constructed API endpoint path as a string. * @return the fully constructed API endpoint path as a string.
*/ */
String buildPath(Object... vars) { String buildPath(Object... vars) {
@@ -7,7 +7,8 @@ import java.io.Serial;
*/ */
public class CloudflareApiException extends Exception { public class CloudflareApiException extends Exception {
@Serial private static final long serialVersionUID = 1L; @Serial
private static final long serialVersionUID = 1L;
/** /**
* Constructs a new CloudflareApiException with the specified detail message. * Constructs a new CloudflareApiException with the specified detail message.
@@ -22,9 +23,9 @@ public class CloudflareApiException extends Exception {
* Constructs a new CloudflareApiException with the specified detail message and cause. * Constructs a new CloudflareApiException with the specified detail message and cause.
* *
* @param message the detail message, which provides additional context or information about the * @param message the detail message, which provides additional context or information about the
* exception. * exception.
* @param cause the cause of this exception, which is the underlying throwable that triggered this * @param cause the cause of this exception, which is the underlying throwable that triggered this
* exception. * exception.
*/ */
public CloudflareApiException(String message, Throwable cause) { public CloudflareApiException(String message, Throwable cause) {
super(message, cause); super(message, cause);
@@ -13,7 +13,7 @@ public class CloudflareNotFoundException extends CloudflareApiException {
* Constructs a new CloudflareNotFoundException with the specified detail message. * Constructs a new CloudflareNotFoundException with the specified detail message.
* *
* @param message the detail message, which provides additional context about the "not found" * @param message the detail message, which provides additional context about the "not found"
* error encountered during interaction with the Cloudflare API. * error encountered during interaction with the Cloudflare API.
*/ */
public CloudflareNotFoundException(String message) { public CloudflareNotFoundException(String message) {
super(message); super(message);
@@ -23,9 +23,9 @@ public class CloudflareNotFoundException extends CloudflareApiException {
* Constructs a new CloudflareNotFoundException with the specified detail message and cause. * Constructs a new CloudflareNotFoundException with the specified detail message and cause.
* *
* @param message the detail message, which provides additional context about the "not found" * @param message the detail message, which provides additional context about the "not found"
* error encountered during interaction with the Cloudflare API. * error encountered during interaction with the Cloudflare API.
* @param cause the cause of this exception, which is the underlying throwable that triggered this * @param cause the cause of this exception, which is the underlying throwable that triggered this
* exception. * exception.
*/ */
public CloudflareNotFoundException(String message, Throwable cause) { public CloudflareNotFoundException(String message, Throwable cause) {
super(message, cause); super(message, cause);
@@ -1,6 +1,7 @@
package codes.thischwa.cf; package codes.thischwa.cf;
import codes.thischwa.cf.model.AbstractResponse; import codes.thischwa.cf.model.AbstractResponse;
import codes.thischwa.cf.model.AbstractSingleResponse;
import codes.thischwa.cf.model.RecordMultipleResponse; import codes.thischwa.cf.model.RecordMultipleResponse;
import codes.thischwa.cf.model.ResponseResultInfo; import codes.thischwa.cf.model.ResponseResultInfo;
import java.util.stream.Collectors; 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 * <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 * metadata. If the response indicates failure, an exception is thrown with descriptive error
* messages. * messages.
* <li>If a {@link RecordMultipleResponse} is used, it validates the number of results in the API * <li>It validates the number of results in the API response payload to detect unexpected counts.
* response payload to detect unexpected counts. Depending on the parameter * For {@link RecordMultipleResponse}, it checks if results are empty or if more than one result
* 'emptyResultThrowsException', an exception will be triggered or an empty result will be returned. * 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> * </ul>
*/ */
class ResponseValidator { class ResponseValidator {
@@ -43,13 +46,27 @@ class ResponseValidator {
private void validateResultCount(AbstractResponse resp, boolean singleResultExpected) private void validateResultCount(AbstractResponse resp, boolean singleResultExpected)
throws CloudflareApiException { throws CloudflareApiException {
if (resp instanceof RecordMultipleResponse respMulti) { if (resp instanceof RecordMultipleResponse respMulti) {
if (singleResultExpected && respMulti.getResultInfo().totalCount() > 1) { validateMultipleResponse(respMulti, singleResultExpected);
throw new CloudflareApiException( } else if (resp instanceof AbstractSingleResponse<?> respSingle) {
"Unexpected result count: " + respMulti.getResultInfo().totalCount()); validateSingleResponse(respSingle);
} }
if (emptyResultThrowsException && respMulti.getResultInfo().totalCount() == 0) { }
throw new CloudflareNotFoundException("No result found");
} private void validateMultipleResponse(RecordMultipleResponse response, boolean singleResultExpected)
throws CloudflareApiException {
int totalCount = response.getResultInfo().totalCount();
if (singleResultExpected && totalCount > 1) {
throw new CloudflareApiException("Unexpected result count: " + totalCount);
}
if (emptyResultThrowsException && totalCount == 0) {
throw new CloudflareNotFoundException("No result found");
}
}
private void validateSingleResponse(AbstractSingleResponse<?> response)
throws CloudflareNotFoundException {
if (emptyResultThrowsException && response.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,64 @@
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 {
return client.recordList(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> recs = get();
if (recs.isEmpty()) {
throw new CloudflareApiException("No recs found to update for subdomain: " + sld);
}
if (recs.size() > 1) {
throw new CloudflareApiException("Multiple recs found. Please use recordUpdate() directly for precise control.");
}
RecordEntity rec = recs.get(0);
rec.setContent(newContent);
return client.recordUpdate(zone, rec);
}
@Override
public void delete(RecordType... types) throws CloudflareApiException {
client.recordDeleteTypeIfExists(zone, sld, types);
}
}
@@ -0,0 +1,40 @@
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;
/**
* Lists all DNS records within the zone, optionally filtered by types.
*
* @param types optional DNS record types to filter by
* @return a list of RecordEntity objects matching the criteria
* @throws CloudflareApiException if an error occurs while retrieving records
*/
java.util.List<codes.thischwa.cf.model.RecordEntity> list(@Nullable RecordType... types) throws CloudflareApiException;
}
@@ -0,0 +1,44 @@
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 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);
}
@Override
public List<RecordEntity> list(@Nullable RecordType... types) throws CloudflareApiException {
return client.recordList(zone, 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;
@@ -24,7 +24,7 @@ import lombok.EqualsAndHashCode;
* <p>Subclasses can be created by specifying the entity type that the response should handle. * <p>Subclasses can be created by specifying the entity type that the response should handle.
* *
* @param <T> Represents the type of entities contained within the response. For this class, it is * @param <T> Represents the type of entities contained within the response. For this class, it is
* expected to be {@code ResponseEntity}. * expected to be {@code ResponseEntity}.
*/ */
@EqualsAndHashCode(callSuper = true) @EqualsAndHashCode(callSuper = true)
@Data @Data
@@ -0,0 +1,39 @@
package codes.thischwa.cf.model;
import java.util.List;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* Represents a batch entry containing different types of operations on record entities.
*
* <p>A BatchEntry groups together collections of operations (patches, posts, puts, and deletes)
* intended to be performed as part of a single batch process. Each operation corresponds to a specific
* type of action on DNS record entities.
*
* <ul>
* <li><b>patches</b>: A list of {@link RecordEntity} objects representing partial updates to existing records.
* <li><b>posts</b>: A list of {@link RecordEntity} objects to be created as new DNS records.
* <li><b>puts</b>: A list of {@link RecordEntity} objects representing updates or replacements for existing records.
* <li><b>deletes</b>: A list of {@link RecordEntity} objects with name, type, and content to be removed.
* </ul>
*
* <p>This class is used as both a request body for batch operations and to represent the batch response.
*/
@EqualsAndHashCode(callSuper = true)
@Data
public class BatchEntry extends AbstractEntity {
List<RecordEntity> patches;
List<RecordEntity> posts;
List<RecordEntity> puts;
List<RecordEntity> deletes;
@Override
public String getId() {
return "";
}
}
@@ -0,0 +1,18 @@
package codes.thischwa.cf.model;
/**
* Represents a response that contains a single {@link BatchEntry} as the result.
*
* <p>This class is used for API responses where the primary result is a batch entry,
* which includes collections of operations such as patches, posts, puts, and deletes
* performed on DNS record entities.
*
* <p>Extends {@code AbstractSingleResponse} with {@code BatchEntry} as the generic type,
* ensuring that the response result is a batch of operations.
*/
public class BatchResponse extends AbstractSingleResponse<BatchEntry> {
BatchResponse() {
super();
}
}
@@ -21,6 +21,12 @@ import lombok.Data;
*/ */
@Data @Data
public class PagingRequest { 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 page;
private int perPage; private int perPage;
@@ -32,7 +38,7 @@ public class PagingRequest {
/** /**
* Creates a new {@code PagingRequest} instance with the specified page number and items per page. * Creates a new {@code PagingRequest} instance with the specified page number and items per page.
* *
* @param page the page number to be requested * @param page the page number to be requested
* @param perPage the number of items to be included per page * @param perPage the number of items to be included per page
* @return a new {@code PagingRequest} instance with the provided parameters * @return a new {@code PagingRequest} instance with the provided parameters
*/ */
@@ -42,12 +48,12 @@ public class PagingRequest {
/** /**
* Creates a default {@code PagingRequest} instance with a page number set to 1 and a high number * 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 * @return a default {@code PagingRequest} instance with predefined pagination parameters
*/ */
public static PagingRequest defaultPaging() { public static PagingRequest defaultPaging() {
return new PagingRequest(1, 5000000); return new PagingRequest(1, DEFAULT_ALL_RECORDS_PAGE_SIZE);
} }
/** /**
@@ -16,7 +16,7 @@ import org.jetbrains.annotations.Nullable;
* <li>Content of the DNS record, such as an IP address. * <li>Content of the DNS record, such as an IP address.
* <li>Flags indicating whether the record is proxiable or proxied. * <li>Flags indicating whether the record is proxiable or proxied.
* <li>TTL (Time-To-Live) for the DNS record. * <li>TTL (Time-To-Live) for the DNS record.
* <li>A locked status to indicate immutability of the record. * <li>A locked status to indicate the immutability of the record.
* <li>Zone-specific metadata including zone ID and name. * <li>Zone-specific metadata including zone ID and name.
* <li>Timestamps for creation and modification. * <li>Timestamps for creation and modification.
* </ul> * </ul>
@@ -34,10 +34,14 @@ public class RecordEntity extends AbstractEntity {
private Boolean proxied; private Boolean proxied;
private Integer ttl; private Integer ttl;
private Boolean locked; private Boolean locked;
@Nullable private String zoneId; @Nullable
@Nullable private String zoneName; private String zoneId;
@Nullable private LocalDateTime modifiedOn; @Nullable
@Nullable private LocalDateTime createdOn; private String zoneName;
@Nullable
private LocalDateTime modifiedOn;
@Nullable
private LocalDateTime createdOn;
/** /**
* Initializes a new instance of the RecordEntity class and invokes the parent constructor from * Initializes a new instance of the RecordEntity class and invokes the parent constructor from
@@ -52,18 +56,87 @@ public class RecordEntity extends AbstractEntity {
/** /**
* Builds and returns a {@link RecordEntity} instance with the specified attributes. * Builds and returns a {@link RecordEntity} instance with the specified attributes.
* *
* @param name the name of the DNS record * @param name the name of the DNS record
* @param type the {@link RecordType} of the DNS record * @param type the {@link RecordType} of the DNS record
* @param ttl the time-to-live (TTL) value for the DNS record * @param ttl the time-to-live (TTL) value for the DNS record
* @param ip the content of the DNS record, typically an IP address * @param content the content of the DNS record, typically an IP address
* @return a {@link RecordEntity} populated with the provided attributes * @return a {@link RecordEntity} populated with the provided attributes
*/ */
public static RecordEntity build(String name, RecordType type, Integer ttl, String ip) { public static RecordEntity build(String name, RecordType type, Integer ttl, String content) {
RecordEntity rec = new RecordEntity(); RecordEntity rec = new RecordEntity();
rec.setName(name); rec.name = name;
rec.setType(type.getType()); rec.type = type.getType();
rec.setTtl(ttl); rec.ttl = ttl;
rec.setContent(ip); rec.content = content;
return rec; return rec;
} }
}
/**
* Builds and returns a {@link RecordEntity} instance with the specified ID and content.
*
* @param id the unique identifier for the DNS record
* @param content the content of the DNS record, typically an IP address or other record data
* @return a {@link RecordEntity} populated with the provided ID and content
*/
public static RecordEntity build(String id, String content) {
RecordEntity rec = new RecordEntity();
rec.setId(id);
rec.content = content;
return rec;
}
/**
* Builds and returns a {@link RecordEntity} instance with the specified attributes.
*
* @param id the unique identifier for the DNS record
* @param name the name of the DNS record
* @param type the type of the DNS record, represented as a string (e.g., "A", "CNAME")
* @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) {
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 = new RecordEntity();
rec.setId(id);
rec.name = name;
rec.type = recordType.getType();
rec.ttl = ttl;
rec.content = content;
return rec;
}
/**
* 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 short name of the DNS record (substring before the first dot),
* or the full name if no dot is present
*/
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;
}
}
@@ -199,6 +199,6 @@ public enum RecordType {
@Override @Override
public String toString() { public String toString() {
return getType(); return type;
} }
} }
@@ -6,6 +6,7 @@ import lombok.Data;
/** /**
* Represents the result of a response with metadata about its success and associated messages or * Represents the result of a response with metadata about its success and associated messages or
* errors. * errors.
*
* <p>This class provides a structure to capture the outcome of an operation, including: * <p>This class provides a structure to capture the outcome of an operation, including:
* <ul> * <ul>
* <li>Whether the operation was successful. * <li>Whether the operation was successful.
@@ -17,6 +18,45 @@ import lombok.Data;
@Data @Data
public class ResponseResultInfo { public class ResponseResultInfo {
private boolean success; private boolean success;
private List<String> errors; private List<Error> errors;
private List<String> messages; private List<String> messages;
/**
* Represents an error with a specific code and message.
*
* <p>This class is used to encapsulate error information, including a numerical error code
* and a corresponding descriptive message. It is often used as part of a collection of errors
* to provide detailed diagnostics for failed operations or processes.
*/
@Data
public static class Error {
private int code;
private String message;
/**
* Constructs a new instance of the {@code Error} class with default values for its properties.
*
* <p>This no-argument constructor initializes an {@code Error} object without setting
* specific values for the error code or message. It is primarily used when an error needs to
* be created and set up later, or when default values are acceptable.
*/
public Error() {
}
/**
* Constructs an instance of the {@code Error} class with a specified error code and message.
*
* @param code the numerical code representing the error
* @param message the descriptive message providing details about the error
*/
public Error(int code, String message) {
this.code = code;
this.message = message;
}
@Override
public String toString() {
return String.format("%d: %s", code, message);
}
}
} }
@@ -6,15 +6,12 @@ package codes.thischwa.cf.model;
* <p>This class contains information about the current page, page size, total pages, and result * <p>This class contains information about the current page, page size, total pages, and result
* counts, which can be utilized in managing and navigating through paginated data. * counts, which can be utilized in managing and navigating through paginated data.
* *
* <ul> * @param page The current page number.
* <li><b>page:</b> The current page number. * @param perPage The number of results per page.
* <li><b>perPage:</b> The number of results per page. * @param totalPages The total number of pages available.
* <li><b>totalPages:</b> The total number of pages available. * @param count The number of results on the current page.
* <li><b>count:</b> The number of results on the current page. * @param totalCount The total number of results across all pages.
* <li><b>totalCount:</b> The total number of results across all pages.
* </ul>
*/ */
public record ResultInfo(int page, int perPage, int totalPages, int count, int totalCount) { public record ResultInfo(int page, int perPage, int totalPages, int count, int totalCount) {
/** /**
@@ -1,6 +1,8 @@
package codes.thischwa.cf.model; package codes.thischwa.cf.model;
/** Represents a response model that contains multiple {@link ZoneEntity} instances. */ /**
* Represents a response model that contains multiple {@link ZoneEntity} instances.
*/
public class ZoneMultipleResponse extends AbstractMultipleResponse<ZoneEntity> { public class ZoneMultipleResponse extends AbstractMultipleResponse<ZoneEntity> {
/** /**
@@ -1,2 +1,5 @@
/** The model of CloudflareDNS-java. */ /**
* The model of CloudflareDNS-java.
*/
package codes.thischwa.cf.model; package codes.thischwa.cf.model;
@@ -1,2 +1,5 @@
/** The base package of CloudflareDNS-java. */ /**
* The base package of CloudflareDNS-java.
*/
package codes.thischwa.cf; package codes.thischwa.cf;
@@ -1,13 +1,13 @@
package codes.thischwa.cf; 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.RecordType;
import codes.thischwa.cf.model.ZoneEntity; import codes.thischwa.cf.model.ZoneEntity;
import java.util.List; import java.util.List;
import java.util.UUID; 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.BeforeAll;
import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@@ -22,32 +22,30 @@ public class CfClientPenTest {
private static final String ZONE_STR = "mein-d-ns.de"; // existing baseline zone private static final String ZONE_STR = "mein-d-ns.de"; // existing baseline zone
private static final String API_EMAIL = System.getenv("API_EMAIL"); private static final String API_TOKEN = System.getenv("API_TOKEN");
private static final String API_KEY = System.getenv("API_KEY");
@BeforeAll @BeforeAll
static void checkEnv() { static void checkEnv() {
assumeTrue(API_EMAIL != null && !API_EMAIL.isBlank(), "API_EMAIL not set; skipping pen tests"); assumeTrue(API_TOKEN != null && !API_TOKEN.isBlank(), "API_TOKEN not set; skipping pen tests");
assumeTrue(API_KEY != null && !API_KEY.isBlank(), "API_KEY not set; skipping pen tests");
} }
private CfDnsClient newClient() { private CfDnsClient newClient() {
return new CfDnsClient(API_EMAIL, API_KEY); return new CfDnsClientBuilder().withEmptyResultThrowsException(true).withApiTokenAuth(API_TOKEN).build();
} }
@Test @Test
@DisplayName("Invalid credentials should not authenticate and must throw CloudflareApiException") @DisplayName("Invalid credentials should not authenticate and must throw CloudflareApiException")
void testInvalidCredentialsShouldFail() { void testInvalidCredentialsShouldFail() {
// Use syntactically valid but wrong credentials // Use syntactically valid but wrong credentials
CfDnsClient badClient = new CfDnsClient("invalid@example.com", UUID.randomUUID().toString()); CfDnsClient badClient = new CfDnsClientBuilder().withEmailKeyAuth("invalid@example.com", UUID.randomUUID().toString()).build();
assertThrows(CloudflareApiException.class, badClient::zoneListAll); assertThrows(CloudflareApiException.class, badClient::zoneList);
} }
@Test @Test
@DisplayName("Malicious SLD inputs must not crash and should throw proper exception type") @DisplayName("Malicious SLD inputs must not crash and should throw proper exception type")
void testMaliciousSldPatternsDoNotSucceed() throws Exception { void testMaliciousSldPatternsDoNotSucceed() throws Exception {
CfDnsClient client = newClient(); CfDnsClient client = newClient();
ZoneEntity zone = client.zoneInfo(ZONE_STR); ZoneEntity zone = client.zoneGet(ZONE_STR);
List<String> syntacticallyInvalidSlds = List<String> syntacticallyInvalidSlds =
List.of("; rm -rf /", "| cat /etc/passwd", "`shutdown -h now`", List.of("; rm -rf /", "| cat /etc/passwd", "`shutdown -h now`",
@@ -58,11 +56,11 @@ public class CfClientPenTest {
"doesnotexist", "abcdef12345", "unwahrscheinlich-" + System.currentTimeMillis()); "doesnotexist", "abcdef12345", "unwahrscheinlich-" + System.currentTimeMillis());
for (String sld : syntacticallyInvalidSlds) { 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 + "'"); "Should throw IllegalArgumentException for invalid SLD '" + sld + "'");
} }
for (String sld : syntacticallyValidOrNotAllowedFromCloudflare) { 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 + "'"); "Should throw CloudflareNotFoundException for valid but non-existing SLD '" + sld + "'");
} }
} }
@@ -71,7 +69,7 @@ public class CfClientPenTest {
@DisplayName("Invalid record content and TTL boundaries must be rejected by API") @DisplayName("Invalid record content and TTL boundaries must be rejected by API")
void testInvalidRecordCreateInputsRejected() throws Exception { void testInvalidRecordCreateInputsRejected() throws Exception {
CfDnsClient client = newClient(); CfDnsClient client = newClient();
ZoneEntity zone = client.zoneInfo(ZONE_STR); ZoneEntity zone = client.zoneGet(ZONE_STR);
String sld = "pentest-" + System.currentTimeMillis(); String sld = "pentest-" + System.currentTimeMillis();
String fqdn = sld + "." + ZONE_STR; String fqdn = sld + "." + ZONE_STR;
@@ -106,7 +104,7 @@ public class CfClientPenTest {
@DisplayName("recordDeleteTypeIfExists must be safe on non-existing SLD and types") @DisplayName("recordDeleteTypeIfExists must be safe on non-existing SLD and types")
void testDeleteTypeIfExistsOnNonExistingIsSafe() throws Exception { void testDeleteTypeIfExistsOnNonExistingIsSafe() throws Exception {
CfDnsClient client = newClient(); CfDnsClient client = newClient();
ZoneEntity zone = client.zoneInfo(ZONE_STR); ZoneEntity zone = client.zoneGet(ZONE_STR);
String randomSld = "nonexist-" + System.currentTimeMillis(); String randomSld = "nonexist-" + System.currentTimeMillis();
// Should not throw even if nothing exists // Should not throw even if nothing exists
assertDoesNotThrow( assertDoesNotThrow(
+342 -46
View File
@@ -1,17 +1,23 @@
package codes.thischwa.cf; package codes.thischwa.cf;
import codes.thischwa.cf.model.RecordEntity;
import codes.thischwa.cf.model.RecordType;
import codes.thischwa.cf.model.ZoneEntity;
import java.util.List;
import java.util.Objects;
import lombok.extern.slf4j.Slf4j;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.fail;
import static org.junit.jupiter.api.Assumptions.assumeTrue; import static org.junit.jupiter.api.Assumptions.assumeTrue;
import codes.thischwa.cf.model.BatchEntry;
import codes.thischwa.cf.model.RecordEntity;
import codes.thischwa.cf.model.RecordType;
import codes.thischwa.cf.model.ZoneEntity;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@@ -22,45 +28,89 @@ public class CfClientTest {
private static final String SLD_STR = "devsld"; private static final String SLD_STR = "devsld";
private static final int TTL = 60; private static final int TTL = 60;
private static final String API_EMAIL = System.getenv("API_EMAIL"); private static final String API_TOKEN = System.getenv("API_TOKEN");
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 CfDnsClientBuilder().withEmptyResultThrowsException(true).withApiTokenAuth(API_TOKEN).build();
@BeforeAll @BeforeAll
static void checkEnv() { static void checkEnv() {
assumeTrue(API_EMAIL != null && !API_EMAIL.isBlank(), "API_EMAIL not set; skipping pen tests"); assumeTrue(API_TOKEN != null && !API_TOKEN.isBlank(), "API_TOKEN not set; skipping client tests");
assumeTrue(API_KEY != null && !API_KEY.isBlank(), "API_KEY not set; skipping pen tests"); }
@Test
void testUnknownSld() throws Exception {
ZoneEntity zone = client.zoneGet(ZONE_STR);
assertThrows(CloudflareNotFoundException.class, () -> client.recordList(zone, "unknown", RecordType.A));
}
@Test
void testAddHost() throws Exception {
ZoneEntity zone = client.zoneGet(ZONE_STR);
try {
// clean-up
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.getSld());
assertEquals(RecordType.A.getType(), createdRecord.getType());
assertEquals(TTL, createdRecord.getTtl());
assertEquals("127.0.0.1", createdRecord.getContent());
assertNotNull(createdRecord.getCreatedOn());
List<RecordEntity> records = client.recordList(zone, SLD_STR, RecordType.A);
assertEquals(1, records.size());
RecordEntity fetchedRecord = records.get(0);
assertEquals(createdRecord.getId(), fetchedRecord.getId());
assertEquals(createdRecord.getContent(), fetchedRecord.getContent());
assertEquals(createdRecord.getType(), fetchedRecord.getType());
client.recordDelete(zone, createdRecord);
// test A and AAAA records for the same SLD
RecordEntity recordA = RecordEntity.build(SLD_STR, RecordType.A, TTL, "127.0.0.2");
RecordEntity recordAAAA = RecordEntity.build(SLD_STR, RecordType.AAAA, TTL, "2001:db8::1");
RecordEntity createdRecordA = client.recordCreate(zone, recordA);
RecordEntity createdRecordAAAA = client.recordCreate(zone, recordAAAA);
assertNotNull(createdRecordA.getId());
assertNotNull(createdRecordAAAA.getId());
assertEquals(SLD_STR, createdRecordA.getSld());
assertEquals(SLD_STR, createdRecordAAAA.getSld());
assertEquals(RecordType.A.getType(), createdRecordA.getType());
assertEquals(RecordType.AAAA.getType(), createdRecordAAAA.getType());
client.recordDeleteTypeIfExists(zone, SLD_STR, RecordType.A, RecordType.AAAA);
assertThrows(CloudflareNotFoundException.class,
() -> client.recordList(zone, SLD_STR, RecordType.A, RecordType.AAAA));
} finally {
// cleanup in case of failures during test
try {
client.recordDeleteTypeIfExists(zone, SLD_STR, RecordType.A, RecordType.AAAA);
} catch (Exception e) { /* ignore */ }
}
} }
@Test @Test
void testZoneListAnlFailedSldList() throws Exception { void testZoneListAnlFailedSldList() throws Exception {
List<ZoneEntity> zList = client.zoneListAll(); List<ZoneEntity> zList = client.zoneList();
assertEquals(1, zList.size()); assertEquals(1, zList.size());
assertThrows(CloudflareNotFoundException.class, 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();
CfDnsClient client = new CfDnsClient(true, API_EMAIL, API_KEY);
assertThrows(CloudflareNotFoundException.class,
() -> client.sldListAll(zList.get(0), "not-existing"));
} }
@Test @Test
void testDns() throws Exception { void testDns() throws Exception {
// starting point: already existing zone 'mein-d-ns.de' // 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("cf9d8b12f61423f280e0a3ea2a96d921", z.getId());
assertEquals("mein-d-ns.de", z.getName()); assertEquals("mein-d-ns.de", z.getName());
assertEquals("active", z.getStatus()); assertEquals("active", z.getStatus());
assertEquals(2, z.getNameServers().size()); assertEquals(2, z.getNameServers().size());
assertTrue(z.getNameServers().contains("sergi.ns.cloudflare.com")); assertTrue(z.getNameServers().contains("rafe.ns.cloudflare.com"));
assertEquals(4, z.getOriginalNameServers().size()); assertTrue(z.getOriginalNameServers().size() >= 2);
assertTrue(z.getOriginalNameServers().contains("a.ns14.net")); assertTrue(z.getOriginalNameServers().contains("blair.ns.cloudflare.com"));
assertNotNull(z.getActivatedOn()); assertNotNull(z.getActivatedOn());
assertNotNull(z.getModifiedOn()); assertNotNull(z.getModifiedOn());
assertNotNull(z.getCreatedOn()); assertNotNull(z.getCreatedOn());
@@ -81,72 +131,318 @@ public class CfClientTest {
createdRe1 = createdRe1 =
client.recordCreate(z, RecordEntity.build(domain, RecordType.A, TTL, "130.0.0.3")); client.recordCreate(z, RecordEntity.build(domain, RecordType.A, TTL, "130.0.0.3"));
assertNotNull(createdRe1.getId()); assertNotNull(createdRe1.getId());
assertEquals(domain, createdRe1.getName()); assertEquals(randomSld + "." + ZONE_STR, createdRe1.getName());
assertEquals(randomSld, createdRe1.getSld());
assertEquals(RecordType.A.getType(), createdRe1.getType()); assertEquals(RecordType.A.getType(), createdRe1.getType());
assertEquals(z.getId(), createdRe1.getZoneId());
assertEquals(TTL, createdRe1.getTtl()); assertEquals(TTL, createdRe1.getTtl());
assertEquals("130.0.0.3", createdRe1.getContent()); assertEquals("130.0.0.3", createdRe1.getContent());
assertNotNull(createdRe1.getCreatedOn()); assertNotNull(createdRe1.getCreatedOn());
assertNotNull(createdRe1.getModifiedOn()); assertNotNull(createdRe1.getModifiedOn());
// verify sldInfo for A // verify recordList for A
r = client.sldInfo(z, randomSld, RecordType.A); List<RecordEntity> aRecords = client.recordList(z, randomSld, RecordType.A);
assertEquals(1, aRecords.size());
r = aRecords.get(0);
assertEquals("130.0.0.3", r.getContent()); assertEquals("130.0.0.3", r.getContent());
// create AAAA record using recordCreateSld // create AAAA record using recordCreateSld
createdRe2 = createdRe2 =
client.recordCreateSld(z, randomSld, TTL, RecordType.AAAA, "2a0a:4cc0:c0:2e4::1"); client.recordCreateSld(z, randomSld, TTL, RecordType.AAAA, "2a0a:4cc0:c0:2e4::1");
r = client.sldInfo(z, randomSld, RecordType.AAAA); List<RecordEntity> aaaaRecords = client.recordList(z, randomSld, RecordType.AAAA);
assertEquals(1, aaaaRecords.size());
r = aaaaRecords.get(0);
assertEquals(z.getId(), r.getZoneId());
assertEquals("2a0a:4cc0:c0:2e4::1", r.getContent()); assertEquals("2a0a:4cc0:c0:2e4::1", r.getContent());
assertEquals(RecordType.AAAA.getType(), r.getType()); assertEquals(RecordType.AAAA.getType(), r.getType());
// test sldListAll // test recordList
List<RecordEntity> rList = client.sldListAll(z, randomSld); List<RecordEntity> rList = client.recordList(z, randomSld);
assertEquals(2, rList.size()); assertEquals(2, rList.size());
for (RecordEntity re : rList) { for (RecordEntity re : rList) {
assertEquals(z.getId(), re.getZoneId());
if (Objects.equals(re.getType(), RecordType.A.getType())) { if (Objects.equals(re.getType(), RecordType.A.getType())) {
assertEquals("130.0.0.3", re.getContent()); assertEquals("130.0.0.3", re.getContent());
} else if (Objects.equals(re.getType(), RecordType.AAAA.getType())) { } else if (Objects.equals(re.getType(), RecordType.AAAA.getType())) {
assertEquals("2a0a:4cc0:c0:2e4::1", re.getContent()); assertEquals("2a0a:4cc0:c0:2e4::1", re.getContent());
} else { } else {
throw new IllegalStateException("Unexpected record type: " + re.getType()); fail(String.format("Unexpected record type: %s", re.getType()));
} }
} }
// test recordList without SLD
List<RecordEntity> fullList = client.recordList(z);
assertTrue(fullList.size() >= 2);
assertTrue(fullList.stream().anyMatch(re -> re.getId().equals(createdRe1.getId())));
assertTrue(fullList.stream().anyMatch(re -> re.getId().equals(createdRe2.getId())));
assertTrue(
fullList.stream().allMatch(re -> re.getType().equals(RecordType.A.getType()) || re.getType().equals(RecordType.AAAA.getType())));
// test recordList with types without SLD
List<RecordEntity> aList = client.recordList(z, RecordType.A);
assertFalse(aList.isEmpty());
assertTrue(aList.size() >= 1);
assertTrue(aList.stream().anyMatch(re -> re.getId().equals(createdRe1.getId())));
assertTrue(aList.stream().noneMatch(re -> re.getId().equals(createdRe2.getId())));
assertTrue(aList.stream().allMatch(re -> re.getType().equals(RecordType.A.getType())));
// test fluent api list
List<RecordEntity> fluentList = client.zone(ZONE_STR).list(RecordType.A);
assertFalse(fluentList.isEmpty());
assertTrue(fluentList.stream().anyMatch(re -> re.getId().equals(createdRe1.getId())));
// update AAAA record // update AAAA record
createdRe2.setContent("2a0a:4cc0:c0:2e4::2"); createdRe2.setContent("2a0a:4cc0:c0:2e4::2");
client.recordUpdate(z, createdRe2); client.recordUpdate(z, createdRe2);
r = client.sldInfo(z, randomSld, RecordType.AAAA); aaaaRecords = client.recordList(z, randomSld, RecordType.AAAA);
assertEquals(1, aaaaRecords.size());
r = aaaaRecords.get(0);
assertEquals("2a0a:4cc0:c0:2e4::2", r.getContent()); assertEquals("2a0a:4cc0:c0:2e4::2", r.getContent());
// verify A record still intact // verify A record still intact
r = client.sldInfo(z, randomSld, RecordType.A); aRecords = client.recordList(z, randomSld, RecordType.A);
assertEquals(1, aRecords.size());
r = aRecords.get(0);
assertEquals("130.0.0.3", r.getContent()); assertEquals("130.0.0.3", r.getContent());
// delete AAAA record and verify it's gone // delete AAAA record and verify it's gone
assertTrue(client.recordDelete(z, createdRe2)); assertTrue(client.recordDelete(z, createdRe2));
assertThrows(CloudflareNotFoundException.class, assertThrows(CloudflareNotFoundException.class,
() -> client.sldInfo(z, randomSld, RecordType.AAAA)); () -> client.recordList(z, randomSld, RecordType.AAAA));
// delete A record using helper and verify it's gone // delete A record using helper and verify it's gone
client.recordDeleteTypeIfExists(z, randomSld, RecordType.A); client.recordDeleteTypeIfExists(z, randomSld, RecordType.A);
assertThrows(CloudflareNotFoundException.class, assertThrows(CloudflareNotFoundException.class,
() -> client.sldInfo(z, randomSld, RecordType.A)); () -> client.recordList(z, randomSld, RecordType.A));
} finally { } finally {
// cleanup in case of failures during test // cleanup in case of failures during test
try { try {
client.recordDeleteTypeIfExists(z, randomSld, RecordType.AAAA); client.recordDeleteTypeIfExists(z, randomSld, RecordType.A, RecordType.AAAA);
} catch (Exception e) { /* ignore */ }
try {
client.recordDeleteTypeIfExists(z, randomSld, RecordType.A);
} catch (Exception e) { /* ignore */ } } catch (Exception e) { /* ignore */ }
} }
} }
@Test @Test
void testException() { void testRecordEntityInvalidType() {
assertThrows(IllegalArgumentException.class, () -> new CfDnsClient(null, "key")); IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
assertThrows(IllegalArgumentException.class, () -> new CfDnsClient("email", null)); () -> RecordEntity.build("id123", "example.com", "INVALID_TYPE", 60, "192.168.1.1"));
assertThrows(IllegalArgumentException.class, () -> new CfDnsClient("email", "")); assertTrue(exception.getMessage().contains("Invalid record type: INVALID_TYPE"));
assertThrows(IllegalArgumentException.class, () -> new CfDnsClient("", "key")); 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.";
@Test
void testBatch() throws Exception {
// starting point: already existing zone 'mein-d-ns.de'
ZoneEntity zone = client.zoneGet(ZONE_STR);
List<String> sldNames = createSldNames();
List<RecordEntity> initialRecords = createInitialRecords(sldNames);
cleanupRecords(zone, sldNames);
try {
testBatchPost(zone, initialRecords, sldNames);
testBatchPatch(zone, sldNames);
testBatchDelete(zone, sldNames);
testBatchPut(zone, sldNames);
} finally {
cleanupRecords(zone, sldNames);
}
}
private List<String> createSldNames() {
return List.of(SLD_STR + "-1", SLD_STR + "-2", SLD_STR + "-3");
}
private List<RecordEntity> createInitialRecords(List<String> sldNames) {
List<RecordEntity> records = new ArrayList<>();
for (int i = 0; i < sldNames.size(); i++) {
records.add(RecordEntity.build(sldNames.get(i), RecordType.A, TTL, IP_PREFIX + (i + 1)));
}
return records;
}
private void cleanupRecords(ZoneEntity zone, List<String> sldNames) {
sldNames.forEach(sld -> {
try {
client.recordDeleteTypeIfExists(zone, sld, RecordType.A);
} catch (CloudflareApiException e) {
throw new RuntimeException(e);
}
});
}
private void testBatchPost(ZoneEntity zone, List<RecordEntity> records, List<String> sldNames) throws Exception {
// Use only first 2 records for POST
List<RecordEntity> postRecords = records.subList(0, 2);
BatchEntry batchEntry = client.recordBatch(zone, postRecords, null, null, null);
assertEquals(2, batchEntry.getPosts().size());
RecordEntity batchedRecord = batchEntry.getPosts().get(0);
assertValidBatchedRecord(batchedRecord, postRecords.get(0));
// Verify only the first 2 records
for (int i = 0; i < 2; i++) {
List<RecordEntity> records1 = client.recordList(zone, sldNames.get(i), RecordType.A);
assertEquals(1, records1.size());
assertEquals(IP_PREFIX + (i + 1), records1.get(0).getContent());
}
}
private void testBatchPatch(ZoneEntity zone, List<String> sldNames) throws Exception {
// Use first 2 records for PATCH
List<RecordEntity> patchRecords = new ArrayList<>();
for (int i = 0; i < 2; i++) {
List<RecordEntity> records = client.recordList(zone, sldNames.get(i), RecordType.A);
RecordEntity record = records.get(0);
record.setContent(UPDATED_IP_PREFIX + (i + 1));
patchRecords.add(record);
}
client.recordBatch(zone, null, null, patchRecords, null);
// Verify both records were updated
for (int i = 0; i < 2; i++) {
List<RecordEntity> updatedRecords = client.recordList(zone, sldNames.get(i), RecordType.A);
assertEquals(1, updatedRecords.size());
assertEquals(UPDATED_IP_PREFIX + (i + 1), updatedRecords.get(0).getContent());
}
}
private void testBatchDelete(ZoneEntity zone, List<String> sldNames) throws Exception {
// Delete first 2 records
List<RecordEntity> deleteRecords = new ArrayList<>();
for (int i = 0; i < 2; i++) {
List<RecordEntity> records = client.recordList(zone, sldNames.get(i), RecordType.A);
deleteRecords.add(records.get(0));
}
client.recordBatch(zone, null, null, null, deleteRecords);
// Verify both records are deleted
for (int i = 0; i < 2; i++) {
String sldName = sldNames.get(i);
assertThrows(CloudflareNotFoundException.class,
() -> client.recordList(zone, sldName, RecordType.A));
}
}
private void testBatchPut(ZoneEntity zone, List<String> sldNames) throws Exception {
// Create 2 new records first for PUT test
List<RecordEntity> newRecords = new ArrayList<>();
for (int i = 0; i < 2; i++) {
RecordEntity record = RecordEntity.build(sldNames.get(i), RecordType.A, TTL, IP_PREFIX + (i + 1));
newRecords.add(record);
}
client.recordBatch(zone, newRecords, null, null, null);
// Now use PUT to replace them
List<RecordEntity> putRecords = new ArrayList<>();
for (int i = 0; i < 2; i++) {
List<RecordEntity> records = client.recordList(zone, sldNames.get(i), RecordType.A);
RecordEntity record = records.get(0);
record.setContent(UPDATED_IP_PREFIX + (i + 1));
putRecords.add(record);
}
client.recordBatch(zone, null, putRecords, null, null);
// Verify both records were updated
for (int i = 0; i < 2; i++) {
List<RecordEntity> updatedRecords = client.recordList(zone, sldNames.get(i), RecordType.A);
assertEquals(1, updatedRecords.size());
assertEquals(UPDATED_IP_PREFIX + (i + 1), updatedRecords.get(0).getContent());
}
}
private void assertValidBatchedRecord(RecordEntity batchedRecord, RecordEntity originalRecord) {
assertNotNull(batchedRecord.getId());
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 */ }
}
}
@Test
void testGroupRecordsByFqdn_withValidRecords() {
// Arrange
RecordEntity rec1 = RecordEntity.build("1", "example.com.", "A", 300, "192.168.1.1");
RecordEntity rec2 = RecordEntity.build("2", "example.com.", "AAAA", 300, "::1");
RecordEntity rec3 = RecordEntity.build("3", "sub.example.com.", "CNAME", 300, "example.com.");
List<RecordEntity> records = Arrays.asList(rec1, rec2, rec3);
// Act
Map<String, List<RecordEntity>> groupedRecords = CfDnsClient.groupRecordsByFqdn(records);
// Assert
assertNotNull(groupedRecords, "Resulting map should not be null.");
assertEquals(2, groupedRecords.size(), "The grouping should result in 2 FQDN keys.");
assertEquals(2, groupedRecords.get("example.com.").size(), "The key 'example.com.' should have 2 records.");
assertEquals(1, groupedRecords.get("sub.example.com.").size(), "The key 'sub.example.com.' should have 1 record.");
}
@Test
void testGroupRecordsByFqdn_withMultipleRecordsSameFqdn() {
// Arrange
RecordEntity rec1 = RecordEntity.build("1", "example.com.", "A", 300, "192.168.1.1");
RecordEntity rec2 = RecordEntity.build("2", "example.com.", "AAAA", 300, "::1");
List<RecordEntity> records = Arrays.asList(rec1, rec2);
// Act
Map<String, List<RecordEntity>> groupedRecords = CfDnsClient.groupRecordsByFqdn(records);
// Assert
assertNotNull(groupedRecords, "Resulting map should not be null.");
assertEquals(1, groupedRecords.size(), "The grouping should result in 1 FQDN key.");
assertEquals(2, groupedRecords.get("example.com.").size(), "The key 'example.com.' should have 2 records.");
}
} }
@@ -3,7 +3,6 @@ package codes.thischwa.cf;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertThrows;
import codes.thischwa.cf.model.RecordType;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
public class CfRequestTest { public class CfRequestTest {
@@ -46,14 +45,14 @@ public class CfRequestTest {
@Test @Test
public void testBuildRecordInfo() { public void testBuildRecordInfo() {
String result = CfRequest.RECORD_INFO_NAME_TYPE.buildPath("zone123", "sld.domain.com", RecordType.A); String result = CfRequest.RECORD_INFO_NAME.buildPath("zone123", "sld.domain.com");
assertEquals("/zones/zone123/dns_records?name=sld.domain.com&type=A", result); assertEquals("/zones/zone123/dns_records?name=sld.domain.com", result);
} }
@Test @Test
public void testBuildPathInvalidArguments() { public void testBuildPathInvalidArguments() {
assertThrows( assertThrows(
IllegalArgumentException.class, IllegalArgumentException.class,
() -> CfRequest.RECORD_INFO_NAME_TYPE.buildPath("zone123", "sld.domain.com")); () -> CfRequest.RECORD_UPDATE.buildPath("zone123"));
} }
} }
@@ -1,20 +1,53 @@
package codes.thischwa.cf; package codes.thischwa.cf;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.fail;
import codes.thischwa.cf.model.AbstractResponse;
import codes.thischwa.cf.model.BatchResponse;
import codes.thischwa.cf.model.RecordMultipleResponse;
import codes.thischwa.cf.model.RecordSingleResponse;
import codes.thischwa.cf.model.ResponseResultInfo;
import codes.thischwa.cf.model.ZoneMultipleResponse; import codes.thischwa.cf.model.ZoneMultipleResponse;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException; import java.io.IOException;
import static org.junit.jupiter.api.Assertions.assertNotNull; import java.io.InputStream;
import java.util.List;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
public class ObjectMapperTest { public class ObjectMapperTest {
private final ObjectMapper mapper = JsonConf.initObjectMapper();
@Test @Test
void testObjectMapper() throws IOException { void testObjectMapper() throws IOException {
ObjectMapper mapper = JsonConf.initObjectMapper();
ZoneMultipleResponse resp = ZoneMultipleResponse resp =
mapper.readValue(this.getClass().getResourceAsStream("/zone-list-response.json"), mapper.readValue(this.getClass().getResourceAsStream("/zone-list-response.json"),
ZoneMultipleResponse.class); ZoneMultipleResponse.class);
assertNotNull(resp.getResponseResultInfo()); assertNotNull(resp.getResponseResultInfo());
} }
@Test
void testErrorResponse() throws IOException {
List<Class<? extends AbstractResponse>> respClasses =
List.of(RecordSingleResponse.class, RecordMultipleResponse.class, ZoneMultipleResponse.class, BatchResponse.class);
respClasses.forEach(this::assertErrorResponse);
}
private void assertErrorResponse(Class<? extends AbstractResponse> clazz) {
InputStream in = this.getClass().getResourceAsStream("/error-response.json");
try {
AbstractResponse resp = mapper.readValue(in, clazz);
assertNotNull(resp);
assertNotNull(resp.getResponseResultInfo());
ResponseResultInfo resultInfo = resp.getResponseResultInfo();
assertFalse(resultInfo.isSuccess());
assertEquals(1, resultInfo.getErrors().size());
assertEquals(81053, resultInfo.getErrors().get(0).getCode());
} catch (IOException e) {
fail("fail for " + clazz + ": " + e.getMessage());
}
}
} }
@@ -1,16 +1,18 @@
package codes.thischwa.cf; package codes.thischwa.cf;
import codes.thischwa.cf.model.AbstractResponse;
import codes.thischwa.cf.model.RecordMultipleResponse;
import codes.thischwa.cf.model.ResponseResultInfo;
import codes.thischwa.cf.model.ResultInfo;
import java.util.Arrays;
import lombok.Getter;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.when; 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;
import java.util.List;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
@@ -29,6 +31,12 @@ class ResponseValidatorTest {
@Mock @Mock
private RecordMultipleResponse mockMultipleResponse; private RecordMultipleResponse mockMultipleResponse;
@Mock
private RecordSingleResponse mockSingleResponse;
@Mock
private RecordEntity mockRecordEntity;
private ResponseValidator validatorWithException; private ResponseValidator validatorWithException;
private ResponseValidator validatorWithoutException; private ResponseValidator validatorWithoutException;
@@ -48,9 +56,15 @@ class ResponseValidatorTest {
@Test @Test
void validateFailedResponse() { void validateFailedResponse() {
List<ResponseResultInfo.Error> errors = new ArrayList<>();
ResponseResultInfo.Error error = new ResponseResultInfo.Error(1, "Fehler 1");
errors.add(error);
error = new ResponseResultInfo.Error(2, "Fehler 2");
errors.add(error);
when(mockResponse.getResponseResultInfo()).thenReturn(mockResultInfo); when(mockResponse.getResponseResultInfo()).thenReturn(mockResultInfo);
when(mockResultInfo.isSuccess()).thenReturn(false); when(mockResultInfo.isSuccess()).thenReturn(false);
when(mockResultInfo.getErrors()).thenReturn(Arrays.asList("Fehler 1", "Fehler 2")); when(mockResultInfo.getErrors()).thenReturn(errors);
CloudflareApiException exception = assertThrows(CloudflareApiException.class, CloudflareApiException exception = assertThrows(CloudflareApiException.class,
() -> validatorWithException.validate(mockResponse, false)); () -> validatorWithException.validate(mockResponse, false));
@@ -87,4 +101,33 @@ class ResponseValidatorTest {
assertDoesNotThrow(() -> validatorWithoutException.validate(mockMultipleResponse, true)); 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));
}
} }
+11 -10
View File
@@ -1,16 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<configuration> <configuration>
<appender name="current" <appender name="current"
class="ch.qos.logback.core.ConsoleAppender"> class="ch.qos.logback.core.ConsoleAppender">
<encoder> <encoder>
<pattern>%d{HH:mm:ss.SSS} [%t] %-5level %logger{50} - %msg%n</pattern> <pattern>%d{HH:mm:ss.SSS} [%t] %-5level %logger{50} - %msg%n</pattern>
</encoder> </encoder>
</appender> </appender>
<logger name="org.apache.hc.client5.http" level="info" /> <logger name="org.apache.hc.client5.http" level="info"/>
<logger name="codes.thischwa.cf.CfBasicHttpClient" level="trace"/>
<root level="debug"> <root level="debug">
<appender-ref ref="current"/> <appender-ref ref="current"/>
</root> </root>
</configuration> </configuration>