From c1675d0fba8786eea74802893a4955c2bcdab61a Mon Sep 17 00:00:00 2001 From: Thilo Schwarz Date: Mon, 21 Apr 2025 14:06:25 +0200 Subject: [PATCH] Refactor response handling and centralize JSON configuration Replaced direct fields in `AbstractResponse` with `ResponseResultInfo` for better encapsulation and consistency. Introduced `JsonConf` to provide a shared, reusable `ObjectMapper` configuration. Updated tests and logic to align with the refactored response structure, enhancing maintainability and readability. --- .gitignore | 1 + .../codes/thischwa/cf/CfBasicHttpClient.java | 129 ++++++------- .../java/codes/thischwa/cf/CfDnsClient.java | 6 +- src/main/java/codes/thischwa/cf/JsonConf.java | 23 +++ .../thischwa/cf/model/AbstractResponse.java | 8 +- .../thischwa/cf/model/ResponseResultInfo.java | 22 +++ .../codes/thischwa/cf/ObjectMapperTest.java | 20 ++ src/test/resources/error-response.json | 11 ++ src/test/resources/logback-test.xml | 2 +- src/test/resources/zone-list-response.json | 174 ++++++++++++++++++ 10 files changed, 325 insertions(+), 71 deletions(-) create mode 100644 src/main/java/codes/thischwa/cf/JsonConf.java create mode 100644 src/main/java/codes/thischwa/cf/model/ResponseResultInfo.java create mode 100644 src/test/java/codes/thischwa/cf/ObjectMapperTest.java create mode 100644 src/test/resources/error-response.json create mode 100644 src/test/resources/zone-list-response.json diff --git a/.gitignore b/.gitignore index 1e4a2fe..6296546 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ /.factorypath /.springBeans /.idea +*.iml # local files /info.txt diff --git a/src/main/java/codes/thischwa/cf/CfBasicHttpClient.java b/src/main/java/codes/thischwa/cf/CfBasicHttpClient.java index d3c428f..0d28e1b 100644 --- a/src/main/java/codes/thischwa/cf/CfBasicHttpClient.java +++ b/src/main/java/codes/thischwa/cf/CfBasicHttpClient.java @@ -2,14 +2,11 @@ package codes.thischwa.cf; import codes.thischwa.cf.model.AbstractEntity; import codes.thischwa.cf.model.AbstractResponse; -import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.PropertyNamingStrategies; -import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import java.nio.charset.StandardCharsets; import lombok.extern.slf4j.Slf4j; + import org.apache.hc.client5.http.classic.methods.HttpDelete; import org.apache.hc.client5.http.classic.methods.HttpGet; import org.apache.hc.client5.http.classic.methods.HttpPatch; @@ -39,7 +36,7 @@ abstract class CfBasicHttpClient { private final ObjectMapper objectMapper; CfBasicHttpClient(String baseUrl, String authEmail, String authKey) - throws IllegalArgumentException { + throws IllegalArgumentException { if (authEmail == null || authEmail.isBlank()) { throw new IllegalArgumentException("Authentication email must not be null or blank!"); } @@ -49,110 +46,113 @@ abstract class CfBasicHttpClient { this.baseUrl = baseUrl; this.authEmail = authEmail; this.authKey = authKey; - this.objectMapper = initObjectMapper(); - } - - private ObjectMapper initObjectMapper() { - ObjectMapper mapper = new ObjectMapper(); - mapper.registerModule(new JavaTimeModule()); - mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL); - mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); - mapper.setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE); - return mapper; + this.objectMapper = JsonConf.initObjectMapper(); } private CloseableHttpClient createHttpClient() { - return HttpClients.custom() - .addRequestInterceptorFirst( - (request, context, execChain) -> { - request.addHeader(HttpHeaders.ACCEPT_CHARSET, "UTF-8"); - request.addHeader(HttpHeaders.ACCEPT_ENCODING, "gzip"); - request.addHeader(HttpHeaders.ACCEPT, ContentType.APPLICATION_JSON.getMimeType()); - request.addHeader( - HttpHeaders.CONTENT_TYPE, ContentType.APPLICATION_JSON.getMimeType()); - request.addHeader("X-Auth-Email", authEmail); - request.addHeader("X-Auth-Key", authKey); - }) - .build(); + return HttpClients.custom().addRequestInterceptorFirst((request, context, execChain) -> { + request.addHeader(HttpHeaders.ACCEPT_CHARSET, "UTF-8"); + request.addHeader(HttpHeaders.ACCEPT_ENCODING, "gzip"); + request.addHeader(HttpHeaders.ACCEPT, ContentType.APPLICATION_JSON.getMimeType()); + request.addHeader(HttpHeaders.CONTENT_TYPE, ContentType.APPLICATION_JSON.getMimeType()); + request.addHeader("X-Auth-Email", authEmail); + request.addHeader("X-Auth-Key", authKey); + }).build(); } - private T executeRequest( - ClassicHttpRequest request, Class responseType) throws CloudflareApiException { + private T executeRequest(ClassicHttpRequest request, + Class responseType) + throws CloudflareApiException { String logUri = null; try (CloseableHttpClient client = createHttpClient()) { - ResultWrapper result = - client.execute( - request, - (ClassicHttpResponse response) -> - new ResultWrapper( - response.getCode(), - EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8))); + ResultWrapper result = client.execute(request, + (ClassicHttpResponse response) -> new ResultWrapper(response.getCode(), + EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8))); + logUri = request.getRequestUri(); if (result.statusCode >= 200 && result.statusCode < 300) { - return objectMapper.readValue(result.responseBody, responseType); + T respObj = objectMapper.readValue(result.responseBody, responseType); + if (!respObj.getResponseResultInfo().isSuccess()) { + log.error("API error."); + respObj.getResponseResultInfo().getErrors().forEach(e -> log.error(" - {}", e)); + throw new CloudflareApiException("API error."); + } + return respObj; } else { - log.error( - "{} request failed for URL {}: Status {}", - request.getMethod(), - request.getUri(), - result.statusCode); + log.error("{} request failed for URL {}: Status {}", request.getMethod(), request.getUri(), + result.statusCode); throw new CloudflareApiException( - request.getMethod() + " request failed with status code: " + result.statusCode); + request.getMethod() + " request failed with status code: " + result.statusCode); } } catch (JsonProcessingException e) { log.error("JSON parsing error for request to {}", logUri, e); throw new CloudflareApiException("Error processing JSON response", e); } catch (Exception e) { - log.error("Error during request execution", e); - throw new CloudflareApiException("Request failed", e); + throw new CloudflareApiException("Server error!", e); } } - /** 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 getRequest(String endpoint, Class responseType) - throws CloudflareApiException { + throws CloudflareApiException { HttpGet request = new HttpGet(buildUrl(endpoint)); 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. + */ T deleteRequest(String endpoint) throws CloudflareApiException { HttpDelete request = new HttpDelete(buildUrl(endpoint)); return executeRequest(request, (Class) codes.thischwa.cf.model.RecordSingleResponse.class); } - /** Sends a POST request with a payload to the given endpoint and maps the response. */ - T postRequest( - String endpoint, R requestPayload) throws CloudflareApiException { + /** + * Sends a POST request with a payload to the given endpoint and maps the response. + */ + T postRequest(String endpoint, + R requestPayload) + throws CloudflareApiException { HttpPost request = new HttpPost(buildUrl(endpoint)); setRequestPayload(request, requestPayload); return executeRequest(request, (Class) codes.thischwa.cf.model.RecordSingleResponse.class); } - /** Sends a PUT request with a payload to the given endpoint and maps the response. */ - T putRequest( - String endpoint, R requestPayload, Class responseType) throws CloudflareApiException { + /** + * Sends a PUT request with a payload to the given endpoint and maps the response. + */ + T putRequest(String endpoint, + R requestPayload, + Class responseType) + throws CloudflareApiException { HttpPut request = new HttpPut(buildUrl(endpoint)); setRequestPayload(request, requestPayload); return executeRequest(request, responseType); } - /** Sends a PATCH request with a payload to the given endpoint and maps the response. */ - T patchRequest( - String endpoint, R requestPayload) throws CloudflareApiException { + /** + * Sends a PATCH request with a payload to the given endpoint and maps the response. + */ + T patchRequest(String endpoint, + R requestPayload) + throws CloudflareApiException { HttpPatch request = new HttpPatch(buildUrl(endpoint)); setRequestPayload(request, requestPayload); return executeRequest(request, (Class) codes.thischwa.cf.model.RecordSingleResponse.class); } - /** Sets the JSON payload for a request. */ - private void setRequestPayload( - BasicClassicHttpRequest request, R requestPayload) throws CloudflareApiException { + /** + * Sets the JSON payload for a request. + */ + private void setRequestPayload(BasicClassicHttpRequest request, + R requestPayload) + throws CloudflareApiException { try { - request.setEntity( - new StringEntity( - objectMapper.writeValueAsString(requestPayload), ContentType.APPLICATION_JSON)); + request.setEntity(new StringEntity(objectMapper.writeValueAsString(requestPayload), + ContentType.APPLICATION_JSON)); } catch (JsonProcessingException e) { throw new CloudflareApiException("Error serializing JSON payload", e); } @@ -162,5 +162,6 @@ abstract class CfBasicHttpClient { return baseUrl + endpoint; } - private record ResultWrapper(int statusCode, String responseBody) {} + private record ResultWrapper(int statusCode, String responseBody) { + } } diff --git a/src/main/java/codes/thischwa/cf/CfDnsClient.java b/src/main/java/codes/thischwa/cf/CfDnsClient.java index 8c9b468..def9047 100644 --- a/src/main/java/codes/thischwa/cf/CfDnsClient.java +++ b/src/main/java/codes/thischwa/cf/CfDnsClient.java @@ -6,6 +6,7 @@ import codes.thischwa.cf.model.RecordEntity; import codes.thischwa.cf.model.RecordMultipleResponse; import codes.thischwa.cf.model.RecordSingleResponse; import codes.thischwa.cf.model.RecordType; +import codes.thischwa.cf.model.ResponseResultInfo; import codes.thischwa.cf.model.ZoneEntity; import codes.thischwa.cf.model.ZoneMultipleResponse; import java.util.List; @@ -307,9 +308,10 @@ public class CfDnsClient extends CfBasicHttpClient { private void checkResponse(AbstractResponse resp, boolean singleResultExpected) throws CloudflareApiException { - if (!resp.isSuccess()) { + ResponseResultInfo resultInfo = resp.getResponseResultInfo(); + if (!resultInfo.isSuccess()) { String errors = - resp.getErrors().stream().map(Object::toString).collect(Collectors.joining(", ")); + resultInfo.getErrors().stream().map(Object::toString).collect(Collectors.joining(", ")); throw new CloudflareApiException("Error in response: " + errors); } diff --git a/src/main/java/codes/thischwa/cf/JsonConf.java b/src/main/java/codes/thischwa/cf/JsonConf.java new file mode 100644 index 0000000..c3f5b8b --- /dev/null +++ b/src/main/java/codes/thischwa/cf/JsonConf.java @@ -0,0 +1,23 @@ +package codes.thischwa.cf; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; + +/** + * The JsonConf class provides a utility method for initializing and configuring a shared + * {@link ObjectMapper} instance for JSON serialization and deserialization. + */ +class JsonConf { + + static ObjectMapper initObjectMapper() { + ObjectMapper mapper = new ObjectMapper(); + mapper.registerModule(new JavaTimeModule()); + mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL); + mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + mapper.setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE); + return mapper; + } +} diff --git a/src/main/java/codes/thischwa/cf/model/AbstractResponse.java b/src/main/java/codes/thischwa/cf/model/AbstractResponse.java index 0efe60f..d414bf9 100644 --- a/src/main/java/codes/thischwa/cf/model/AbstractResponse.java +++ b/src/main/java/codes/thischwa/cf/model/AbstractResponse.java @@ -1,6 +1,6 @@ package codes.thischwa.cf.model; -import java.util.List; +import com.fasterxml.jackson.annotation.JsonUnwrapped; import lombok.Data; /** @@ -22,7 +22,7 @@ import lombok.Data; */ @Data public abstract class AbstractResponse { - private boolean success; - private List errors; - private List messages; + + @JsonUnwrapped + private ResponseResultInfo responseResultInfo; } diff --git a/src/main/java/codes/thischwa/cf/model/ResponseResultInfo.java b/src/main/java/codes/thischwa/cf/model/ResponseResultInfo.java new file mode 100644 index 0000000..4db568e --- /dev/null +++ b/src/main/java/codes/thischwa/cf/model/ResponseResultInfo.java @@ -0,0 +1,22 @@ +package codes.thischwa.cf.model; + +import java.util.List; +import lombok.Data; + +/** + * Represents the result of a response with metadata about its success and associated messages or + * errors. + *

This class provides a structure to capture the outcome of an operation, including: + *

    + *
  • Whether the operation was successful. + *
  • A list of error messages if the operation failed. + *
  • A list of informational or success messages. + *
+ * It can be used to standardize the response format in an application. + */ +@Data +public class ResponseResultInfo { + private boolean success; + private List errors; + private List messages; +} diff --git a/src/test/java/codes/thischwa/cf/ObjectMapperTest.java b/src/test/java/codes/thischwa/cf/ObjectMapperTest.java new file mode 100644 index 0000000..ef63f15 --- /dev/null +++ b/src/test/java/codes/thischwa/cf/ObjectMapperTest.java @@ -0,0 +1,20 @@ +package codes.thischwa.cf; + +import codes.thischwa.cf.model.ZoneMultipleResponse; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import org.junit.jupiter.api.Test; + +public class ObjectMapperTest { + + @Test + void testObjectMapper() throws IOException { + ObjectMapper mapper = JsonConf.initObjectMapper(); + ZoneMultipleResponse resp = + mapper.readValue(this.getClass().getResourceAsStream("/zone-list-response.json"), + ZoneMultipleResponse.class); + assertNotNull(resp.getResponseResultInfo()); + } +} diff --git a/src/test/resources/error-response.json b/src/test/resources/error-response.json new file mode 100644 index 0000000..4c3f2ad --- /dev/null +++ b/src/test/resources/error-response.json @@ -0,0 +1,11 @@ +{ + "result": null, + "success": false, + "errors": [ + { + "code": 81053, + "message": "An A, AAAA, or CNAME record with that host already exists. For more details, refer to \u003chttps://developers.cloudflare.com/dns/manage-dns-records/troubleshooting/records-with-same-name/\u003e." + } + ], + "messages": [] +} \ No newline at end of file diff --git a/src/test/resources/logback-test.xml b/src/test/resources/logback-test.xml index ba396f7..64aef5d 100644 --- a/src/test/resources/logback-test.xml +++ b/src/test/resources/logback-test.xml @@ -1,5 +1,5 @@ - + diff --git a/src/test/resources/zone-list-response.json b/src/test/resources/zone-list-response.json new file mode 100644 index 0000000..b72edc8 --- /dev/null +++ b/src/test/resources/zone-list-response.json @@ -0,0 +1,174 @@ +{ + "result": [ + { + "id": "0a83dd6e7f8c46039f2517bbded8115e", + "name": "mein-d-ns.de", + "status": "active", + "paused": false, + "type": "full", + "development_mode": -230181, + "name_servers": [ + "blair.ns.cloudflare.com", + "sergi.ns.cloudflare.com" + ], + "original_name_servers": [ + "c.ns14.net", + "b.ns14.net", + "a.ns14.net", + "d.ns14.net" + ], + "original_registrar": null, + "original_dnshost": null, + "modified_on": "2025-01-20T09:06:52.122538Z", + "created_on": "2025-01-20T08:56:34.736214Z", + "activated_on": "2025-01-20T09:06:52.122538Z", + "meta": { + "step": 2, + "custom_certificate_quota": 0, + "page_rule_quota": 3, + "phishing_detected": false + }, + "owner": { + "id": null, + "type": "user", + "email": null + }, + "account": { + "id": "563335e3d73549dbbc0620f4ce82d527", + "name": "myAccount" + }, + "tenant": { + "id": null, + "name": null + }, + "tenant_unit": { + "id": null + }, + "permissions": [ + "#image:read", + "#image:edit", + "#worker:edit", + "#analytics:read", + "#ssl:edit", + "#zone_settings:read", + "#organization:edit", + "#waf:read", + "#waf:edit", + "#logs:read", + "#member:read", + "#worker:read", + "#blocks:read", + "#blocks:edit", + "#access:read", + "#access:edit", + "#billing:read", + "#teams:read", + "#organization:read", + "#logs:edit", + "#zaraz:publish", + "#zone_settings:edit", + "#fbm:read", + "#subscription:read", + "#lb:read", + "#waitingroom:edit", + "#ssl:read", + "#subscription:edit", + "#dns_records:read", + "#dns_records:edit", + "#api_gateway:read", + "#api_gateway:edit", + "#cds_compute_account:edit", + "#ces_submissions:read", + "#fbm:edit", + "#healthchecks:edit", + "#stream:edit", + "#lb:edit", + "#healthchecks:read", + "#billing:edit", + "#member:edit", + "#cfone_read", + "#cfone_edit", + "#integration:read", + "#zaraz:edit", + "#magic:read", + "#magic:edit", + "#zaraz:read", + "#stream:read", + "#integration:install", + "#dash_sso:edit", + "#zone:read", + "#http_applications:read", + "#http_applications:edit", + "#ces_analytics:read", + "#resilience:edit", + "#teams:edit", + "#r2_bucket_warehouse:read", + "#r2_bucket_warehouse:edit", + "#query_cache:read", + "#query_cache:edit", + "#dex:read", + "#zone_versioning:read", + "#zone_versioning:edit", + "#cds:read", + "#ces_search:action", + "#ces_search:read", + "#ces_search:preview", + "#ces_search:raw", + "#ces_search:trace", + "#fbm_acc:edit", + "#teams:pii", + "#r2_bucket_item:read", + "#r2_bucket_item:edit", + "#integration:edit", + "#resilience:read", + "#zone:edit", + "#dash_sso:read", + "#legal:read", + "#ces_integration:edit", + "#ces_integration:read", + "#cache_purge:edit", + "#vectorize:read", + "#vectorize:edit", + "#auditlogs:read", + "#teams:report", + "#ces_settings:edit", + "#ces_settings:read", + "#waitingroom:read", + "#web3:read", + "#web3:edit", + "#dex:edit", + "#cds_compute_account:read", + "#cds:edit", + "#r2_bucket:read", + "#r2_bucket:edit", + "#ces_phishguard:read", + "#page_shield:read", + "#page_shield:edit", + "#legal:edit", + "#app:edit" + ], + "plan": { + "id": "0feeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "name": "Free Website", + "price": 0, + "currency": "USD", + "frequency": "", + "is_subscribed": false, + "can_subscribe": false, + "legacy_id": "free", + "legacy_discount": false, + "externally_managed": false + } + } + ], + "result_info": { + "page": 1, + "per_page": 20, + "total_pages": 1, + "count": 1, + "total_count": 1 + }, + "success": true, + "errors": [], + "messages": [] +} \ No newline at end of file