diff --git a/datamodel/openapi/openapi-core/src/main/java/com/sap/cloud/sdk/services/openapi/apache/ApiClient.java b/datamodel/openapi/openapi-core/src/main/java/com/sap/cloud/sdk/services/openapi/apache/ApiClient.java index f2262678f..3e955b9f7 100644 --- a/datamodel/openapi/openapi-core/src/main/java/com/sap/cloud/sdk/services/openapi/apache/ApiClient.java +++ b/datamodel/openapi/openapi-core/src/main/java/com/sap/cloud/sdk/services/openapi/apache/ApiClient.java @@ -93,6 +93,10 @@ public class ApiClient @Nullable private final String tempFolderPath; + @With( onMethod_ = @Beta ) + @Nullable + private final OpenApiResponseListener openApiResponseListener; + // Methods that can have a request body private static final Set BODY_METHODS = Set.of(Method.POST, Method.PUT, Method.PATCH, Method.DELETE); private static final String DEFAULT_BASE_PATH = "http://localhost"; @@ -107,7 +111,7 @@ public class ApiClient @Nonnull public static ApiClient fromHttpClient( @Nonnull final CloseableHttpClient httpClient ) { - return new ApiClient(httpClient, DEFAULT_BASE_PATH, createDefaultObjectMapper(), null); + return new ApiClient(httpClient, DEFAULT_BASE_PATH, createDefaultObjectMapper(), null, null); } /** @@ -567,7 +571,7 @@ public T invokeAPI( try { final HttpClientResponseHandler responseHandler = - new DefaultApiResponseHandler<>(objectMapper, tempFolderPath, returnType); + new DefaultApiResponseHandler<>(objectMapper, tempFolderPath, returnType, openApiResponseListener); return httpClient.execute(builder.build(), context, responseHandler); } catch( IOException e ) { diff --git a/datamodel/openapi/openapi-core/src/main/java/com/sap/cloud/sdk/services/openapi/apache/DefaultApiResponseHandler.java b/datamodel/openapi/openapi-core/src/main/java/com/sap/cloud/sdk/services/openapi/apache/DefaultApiResponseHandler.java index 20c96f58f..75019b07a 100644 --- a/datamodel/openapi/openapi-core/src/main/java/com/sap/cloud/sdk/services/openapi/apache/DefaultApiResponseHandler.java +++ b/datamodel/openapi/openapi-core/src/main/java/com/sap/cloud/sdk/services/openapi/apache/DefaultApiResponseHandler.java @@ -20,9 +20,10 @@ import java.nio.file.Paths; import java.nio.file.StandardCopyOption; import java.util.ArrayList; -import java.util.HashMap; +import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.TreeMap; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -44,6 +45,8 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.sap.cloud.sdk.services.openapi.core.OpenApiRequestException; +import lombok.RequiredArgsConstructor; + /** * Handles HTTP response processing for API client operations. This class encapsulates response deserialization, error * handling, and file download functionality. @@ -51,31 +54,22 @@ * @param * The type of object to deserialize the response into */ +@RequiredArgsConstructor class DefaultApiResponseHandler implements HttpClientResponseHandler { + /** Jackson ObjectMapper for JSON deserialization */ + @Nonnull private final ObjectMapper objectMapper; + /** Temporary folder path for file downloads (null for system default) */ + @Nullable private final String tempFolderPath; + /** Type reference for response deserialization */ + @Nonnull private final TypeReference returnType; - /** - * Creates a new response handler with the specified configuration. - * - * @param objectMapper - * The Jackson ObjectMapper for JSON deserialization - * @param tempFolderPath - * The temporary folder path for file downloads (null for system default) - * @param returnType - * The type reference for response deserialization - */ - DefaultApiResponseHandler( - @Nonnull final ObjectMapper objectMapper, - @Nullable final String tempFolderPath, - @Nonnull final TypeReference returnType ) - { - this.objectMapper = objectMapper; - this.tempFolderPath = tempFolderPath; - this.returnType = returnType; - } + /** Optional listener for OpenAPI response including status code and headers */ + @Nullable + private final OpenApiResponseListener openApiResponseListener; @Nullable @Override @@ -112,9 +106,14 @@ private T processResponse( @Nonnull final ClassicHttpResponse response ) ParseException { final int statusCode = response.getCode(); + final Map> headers = transformResponseHeaders(response.getHeaders()); + if( openApiResponseListener != null ) { + openApiResponseListener.onResponse(new OpenApiResponse(statusCode, headers)); + } + if( statusCode == HttpStatus.SC_NO_CONTENT ) { if( returnType.getType().equals(OpenApiResponse.class) ) { - return (T) new OpenApiResponse(statusCode, transformResponseHeaders(response.getHeaders())); + return (T) new OpenApiResponse(statusCode, headers); } return null; } @@ -122,11 +121,10 @@ private T processResponse( @Nonnull final ClassicHttpResponse response ) if( isSuccessfulStatus(statusCode) ) { return deserialize(response); } else { - final Map> responseHeaders = transformResponseHeaders(response.getHeaders()); final String message = new StatusLine(response).toString(); throw new OpenApiRequestException(message) .statusCode(statusCode) - .responseHeaders(responseHeaders) + .responseHeaders(headers) .responseBody(EntityUtils.toString(response.getEntity())); } } @@ -308,18 +306,11 @@ private static ContentType parseContentType( @Nonnull final String headerValue ) @Nonnull private static Map> transformResponseHeaders( @Nonnull final Header[] headers ) { - final Map> headersMap = new HashMap<>(); + final var headersMap = new TreeMap>(String.CASE_INSENSITIVE_ORDER); for( final Header header : headers ) { - List valuesList = headersMap.get(header.getName()); - if( valuesList != null ) { - valuesList.add(header.getValue()); - } else { - valuesList = new ArrayList<>(); - valuesList.add(header.getValue()); - headersMap.put(header.getName(), valuesList); - } + headersMap.computeIfAbsent(header.getName(), k -> new ArrayList<>()).add(header.getValue()); } - return headersMap; + return Collections.unmodifiableMap(headersMap); } private static boolean isSuccessfulStatus( final int statusCode ) diff --git a/datamodel/openapi/openapi-core/src/main/java/com/sap/cloud/sdk/services/openapi/apache/OpenApiResponse.java b/datamodel/openapi/openapi-core/src/main/java/com/sap/cloud/sdk/services/openapi/apache/OpenApiResponse.java index f4cc3c35d..900df7887 100644 --- a/datamodel/openapi/openapi-core/src/main/java/com/sap/cloud/sdk/services/openapi/apache/OpenApiResponse.java +++ b/datamodel/openapi/openapi-core/src/main/java/com/sap/cloud/sdk/services/openapi/apache/OpenApiResponse.java @@ -6,11 +6,13 @@ import javax.annotation.Nonnull; import lombok.Getter; +import lombok.RequiredArgsConstructor; /** * Response object for Apache HTTP client OpenAPI calls containing status code and headers */ @Getter +@RequiredArgsConstructor public class OpenApiResponse { @@ -18,13 +20,4 @@ public class OpenApiResponse @Nonnull private final Map> headers; - - /** - * Create a new OpenApiResponse with status code and headers. - */ - OpenApiResponse( final int statusCode, @Nonnull final Map> headers ) - { - this.statusCode = statusCode; - this.headers = Map.copyOf(headers); - } } diff --git a/datamodel/openapi/openapi-core/src/main/java/com/sap/cloud/sdk/services/openapi/apache/OpenApiResponseListener.java b/datamodel/openapi/openapi-core/src/main/java/com/sap/cloud/sdk/services/openapi/apache/OpenApiResponseListener.java new file mode 100644 index 000000000..36a97303c --- /dev/null +++ b/datamodel/openapi/openapi-core/src/main/java/com/sap/cloud/sdk/services/openapi/apache/OpenApiResponseListener.java @@ -0,0 +1,20 @@ +package com.sap.cloud.sdk.services.openapi.apache; + +import javax.annotation.Nonnull; + +/** + * Listener for receiving metadata about HTTP responses. + * + * @since 5.25.0 + */ +@FunctionalInterface +public interface OpenApiResponseListener +{ + /** + * Called when an HTTP response is received. + * + * @param response + * The response metadata. + */ + void onResponse( @Nonnull final OpenApiResponse response ); +} diff --git a/datamodel/openapi/openapi-core/src/test/java/com/sap/cloud/sdk/services/openapi/apiclient/ApacheApiClientResponseHandlingTest.java b/datamodel/openapi/openapi-core/src/test/java/com/sap/cloud/sdk/services/openapi/apiclient/ApacheApiClientResponseHandlingTest.java new file mode 100644 index 000000000..533130569 --- /dev/null +++ b/datamodel/openapi/openapi-core/src/test/java/com/sap/cloud/sdk/services/openapi/apiclient/ApacheApiClientResponseHandlingTest.java @@ -0,0 +1,145 @@ +package com.sap.cloud.sdk.services.openapi.apiclient; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.getRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.stubFor; +import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; +import static com.github.tomakehurst.wiremock.client.WireMock.verify; +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; + +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.core.type.TypeReference; +import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo; +import com.github.tomakehurst.wiremock.junit5.WireMockTest; +import com.sap.cloud.sdk.services.openapi.apache.ApiClient; +import com.sap.cloud.sdk.services.openapi.apache.BaseApi; +import com.sap.cloud.sdk.services.openapi.apache.OpenApiResponse; +import com.sap.cloud.sdk.services.openapi.apache.Pair; +import com.sap.cloud.sdk.services.openapi.core.OpenApiRequestException; + +import lombok.Data; + +@WireMockTest +class ApacheApiClientResponseHandlingTest +{ + private static final String TEST_PATH = "/test"; + private static final String TEST_RESPONSE_BODY = "{\"message\": \"success\"}"; + + @Test + void testResponseMetadataListener( final WireMockRuntimeInfo wmInfo ) + { + stubFor( + get(urlEqualTo(TEST_PATH)) + .willReturn( + aResponse() + .withStatus(200) + .withHeader("x-custom-header", "some-value") + .withBody(TEST_RESPONSE_BODY))); + + final AtomicReference metadata = new AtomicReference<>(); + final ApiClient apiClient = + ApiClient.create().withBasePath(wmInfo.getHttpBaseUrl()).withOpenApiResponseListener(metadata::set); + + final TestApi api = new TestApi(apiClient); + final TestResponse result = api.executeRequest(); + + assertThat(result).isNotNull(); + assertThat(result.getMessage()).isEqualTo("success"); + assertThat(metadata.get()).isNotNull(); + assertThat(metadata.get().getStatusCode()).isEqualTo(200); + assertThat(metadata.get().getHeaders()).isNotEmpty(); + assertThat(metadata.get().getHeaders()).containsKey("x-custom-header"); + + verify(1, getRequestedFor(urlEqualTo(TEST_PATH))); + } + + @Test + void testCaseInsensitiveHeaderLookup( final WireMockRuntimeInfo wmInfo ) + { + stubFor( + get(urlEqualTo(TEST_PATH)) + .willReturn( + aResponse() + .withStatus(200) + .withBody(TEST_RESPONSE_BODY) + .withHeader("x-custom-header", "some-value"))); + + final AtomicReference capturedResponse = new AtomicReference<>(); + final ApiClient apiClient = + ApiClient.create().withBasePath(wmInfo.getHttpBaseUrl()).withOpenApiResponseListener(capturedResponse::set); + + final TestApi api = new TestApi(apiClient); + api.executeRequest(); + + // Verify case-insensitive access works + final Map> headers = capturedResponse.get().getHeaders(); + assertThat(headers.get("x-custom-header")).contains("some-value"); + assertThat(headers.get("X-Custom-Header")).contains("some-value"); + assertThat(headers.get("X-CUSTOM-HEADER")).contains("some-value"); + } + + private static class TestApi extends BaseApi + { + private final String path; + + TestApi( final ApiClient apiClient ) + { + this(apiClient, TEST_PATH); + } + + TestApi( final ApiClient apiClient, final String path ) + { + super(apiClient); + this.path = path; + } + + TestResponse executeRequest() + throws OpenApiRequestException + { + final List localVarQueryParams = new ArrayList<>(); + final List localVarCollectionQueryParams = new ArrayList<>(); + final Map localVarHeaderParams = new HashMap<>(); + final Map localVarFormParams = new HashMap<>(); + + final String[] localVarAccepts = { "application/json" }; + final String localVarAccept = ApiClient.selectHeaderAccept(localVarAccepts); + + final String[] localVarContentTypes = {}; + final String localVarContentType = ApiClient.selectHeaderContentType(localVarContentTypes); + + final TypeReference localVarReturnType = new TypeReference() + { + }; + + return apiClient + .invokeAPI( + path, + "GET", + localVarQueryParams, + localVarCollectionQueryParams, + null, + null, + localVarHeaderParams, + localVarFormParams, + localVarAccept, + localVarContentType, + localVarReturnType); + } + } + + @Data + private static class TestResponse + { + @JsonProperty( "message" ) + private String message; + } +}