Skip to content

Commit c567b5b

Browse files
feat: [OpenAPI] ResponseMetadataListener with case insensitive header map (#1070)
Co-authored-by: Charles Dubois <103174266+CharlesDuboisSAP@users.noreply.github.com>
1 parent d9b1dd8 commit c567b5b

5 files changed

Lines changed: 197 additions & 44 deletions

File tree

datamodel/openapi/openapi-core/src/main/java/com/sap/cloud/sdk/services/openapi/apache/ApiClient.java

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,10 @@ public class ApiClient
9393
@Nullable
9494
private final String tempFolderPath;
9595

96+
@With( onMethod_ = @Beta )
97+
@Nullable
98+
private final OpenApiResponseListener openApiResponseListener;
99+
96100
// Methods that can have a request body
97101
private static final Set<Method> BODY_METHODS = Set.of(Method.POST, Method.PUT, Method.PATCH, Method.DELETE);
98102
private static final String DEFAULT_BASE_PATH = "http://localhost";
@@ -107,7 +111,7 @@ public class ApiClient
107111
@Nonnull
108112
public static ApiClient fromHttpClient( @Nonnull final CloseableHttpClient httpClient )
109113
{
110-
return new ApiClient(httpClient, DEFAULT_BASE_PATH, createDefaultObjectMapper(), null);
114+
return new ApiClient(httpClient, DEFAULT_BASE_PATH, createDefaultObjectMapper(), null, null);
111115
}
112116

113117
/**
@@ -567,7 +571,7 @@ public <T> T invokeAPI(
567571

568572
try {
569573
final HttpClientResponseHandler<T> responseHandler =
570-
new DefaultApiResponseHandler<>(objectMapper, tempFolderPath, returnType);
574+
new DefaultApiResponseHandler<>(objectMapper, tempFolderPath, returnType, openApiResponseListener);
571575
return httpClient.execute(builder.build(), context, responseHandler);
572576
}
573577
catch( IOException e ) {

datamodel/openapi/openapi-core/src/main/java/com/sap/cloud/sdk/services/openapi/apache/DefaultApiResponseHandler.java

Lines changed: 24 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,10 @@
2020
import java.nio.file.Paths;
2121
import java.nio.file.StandardCopyOption;
2222
import java.util.ArrayList;
23-
import java.util.HashMap;
23+
import java.util.Collections;
2424
import java.util.List;
2525
import java.util.Map;
26+
import java.util.TreeMap;
2627
import java.util.regex.Matcher;
2728
import java.util.regex.Pattern;
2829

@@ -44,38 +45,31 @@
4445
import com.fasterxml.jackson.databind.ObjectMapper;
4546
import com.sap.cloud.sdk.services.openapi.core.OpenApiRequestException;
4647

48+
import lombok.RequiredArgsConstructor;
49+
4750
/**
4851
* Handles HTTP response processing for API client operations. This class encapsulates response deserialization, error
4952
* handling, and file download functionality.
5053
*
5154
* @param <T>
5255
* The type of object to deserialize the response into
5356
*/
57+
@RequiredArgsConstructor
5458
class DefaultApiResponseHandler<T> implements HttpClientResponseHandler<T>
5559
{
60+
/** Jackson ObjectMapper for JSON deserialization */
61+
@Nonnull
5662
private final ObjectMapper objectMapper;
63+
/** Temporary folder path for file downloads (null for system default) */
64+
@Nullable
5765
private final String tempFolderPath;
66+
/** Type reference for response deserialization */
67+
@Nonnull
5868
private final TypeReference<T> returnType;
5969

60-
/**
61-
* Creates a new response handler with the specified configuration.
62-
*
63-
* @param objectMapper
64-
* The Jackson ObjectMapper for JSON deserialization
65-
* @param tempFolderPath
66-
* The temporary folder path for file downloads (null for system default)
67-
* @param returnType
68-
* The type reference for response deserialization
69-
*/
70-
DefaultApiResponseHandler(
71-
@Nonnull final ObjectMapper objectMapper,
72-
@Nullable final String tempFolderPath,
73-
@Nonnull final TypeReference<T> returnType )
74-
{
75-
this.objectMapper = objectMapper;
76-
this.tempFolderPath = tempFolderPath;
77-
this.returnType = returnType;
78-
}
70+
/** Optional listener for OpenAPI response including status code and headers */
71+
@Nullable
72+
private final OpenApiResponseListener openApiResponseListener;
7973

8074
@Nullable
8175
@Override
@@ -112,21 +106,25 @@ private T processResponse( @Nonnull final ClassicHttpResponse response )
112106
ParseException
113107
{
114108
final int statusCode = response.getCode();
109+
final Map<String, List<String>> headers = transformResponseHeaders(response.getHeaders());
110+
if( openApiResponseListener != null ) {
111+
openApiResponseListener.onResponse(new OpenApiResponse(statusCode, headers));
112+
}
113+
115114
if( statusCode == HttpStatus.SC_NO_CONTENT ) {
116115
if( returnType.getType().equals(OpenApiResponse.class) ) {
117-
return (T) new OpenApiResponse(statusCode, transformResponseHeaders(response.getHeaders()));
116+
return (T) new OpenApiResponse(statusCode, headers);
118117
}
119118
return null;
120119
}
121120

122121
if( isSuccessfulStatus(statusCode) ) {
123122
return deserialize(response);
124123
} else {
125-
final Map<String, List<String>> responseHeaders = transformResponseHeaders(response.getHeaders());
126124
final String message = new StatusLine(response).toString();
127125
throw new OpenApiRequestException(message)
128126
.statusCode(statusCode)
129-
.responseHeaders(responseHeaders)
127+
.responseHeaders(headers)
130128
.responseBody(EntityUtils.toString(response.getEntity()));
131129
}
132130
}
@@ -308,18 +306,11 @@ private static ContentType parseContentType( @Nonnull final String headerValue )
308306
@Nonnull
309307
private static Map<String, List<String>> transformResponseHeaders( @Nonnull final Header[] headers )
310308
{
311-
final Map<String, List<String>> headersMap = new HashMap<>();
309+
final var headersMap = new TreeMap<String, List<String>>(String.CASE_INSENSITIVE_ORDER);
312310
for( final Header header : headers ) {
313-
List<String> valuesList = headersMap.get(header.getName());
314-
if( valuesList != null ) {
315-
valuesList.add(header.getValue());
316-
} else {
317-
valuesList = new ArrayList<>();
318-
valuesList.add(header.getValue());
319-
headersMap.put(header.getName(), valuesList);
320-
}
311+
headersMap.computeIfAbsent(header.getName(), k -> new ArrayList<>()).add(header.getValue());
321312
}
322-
return headersMap;
313+
return Collections.unmodifiableMap(headersMap);
323314
}
324315

325316
private static boolean isSuccessfulStatus( final int statusCode )

datamodel/openapi/openapi-core/src/main/java/com/sap/cloud/sdk/services/openapi/apache/OpenApiResponse.java

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,25 +6,18 @@
66
import javax.annotation.Nonnull;
77

88
import lombok.Getter;
9+
import lombok.RequiredArgsConstructor;
910

1011
/**
1112
* Response object for Apache HTTP client OpenAPI calls containing status code and headers
1213
*/
1314
@Getter
15+
@RequiredArgsConstructor
1416
public class OpenApiResponse
1517
{
1618

1719
private final int statusCode;
1820

1921
@Nonnull
2022
private final Map<String, List<String>> headers;
21-
22-
/**
23-
* Create a new OpenApiResponse with status code and headers.
24-
*/
25-
OpenApiResponse( final int statusCode, @Nonnull final Map<String, List<String>> headers )
26-
{
27-
this.statusCode = statusCode;
28-
this.headers = Map.copyOf(headers);
29-
}
3023
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package com.sap.cloud.sdk.services.openapi.apache;
2+
3+
import javax.annotation.Nonnull;
4+
5+
/**
6+
* Listener for receiving metadata about HTTP responses.
7+
*
8+
* @since 5.25.0
9+
*/
10+
@FunctionalInterface
11+
public interface OpenApiResponseListener
12+
{
13+
/**
14+
* Called when an HTTP response is received.
15+
*
16+
* @param response
17+
* The response metadata.
18+
*/
19+
void onResponse( @Nonnull final OpenApiResponse response );
20+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
package com.sap.cloud.sdk.services.openapi.apiclient;
2+
3+
import static com.github.tomakehurst.wiremock.client.WireMock.aResponse;
4+
import static com.github.tomakehurst.wiremock.client.WireMock.get;
5+
import static com.github.tomakehurst.wiremock.client.WireMock.getRequestedFor;
6+
import static com.github.tomakehurst.wiremock.client.WireMock.stubFor;
7+
import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo;
8+
import static com.github.tomakehurst.wiremock.client.WireMock.verify;
9+
import static org.assertj.core.api.Assertions.assertThat;
10+
11+
import java.util.ArrayList;
12+
import java.util.HashMap;
13+
import java.util.List;
14+
import java.util.Map;
15+
import java.util.concurrent.atomic.AtomicReference;
16+
17+
import org.junit.jupiter.api.Test;
18+
19+
import com.fasterxml.jackson.annotation.JsonProperty;
20+
import com.fasterxml.jackson.core.type.TypeReference;
21+
import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo;
22+
import com.github.tomakehurst.wiremock.junit5.WireMockTest;
23+
import com.sap.cloud.sdk.services.openapi.apache.ApiClient;
24+
import com.sap.cloud.sdk.services.openapi.apache.BaseApi;
25+
import com.sap.cloud.sdk.services.openapi.apache.OpenApiResponse;
26+
import com.sap.cloud.sdk.services.openapi.apache.Pair;
27+
import com.sap.cloud.sdk.services.openapi.core.OpenApiRequestException;
28+
29+
import lombok.Data;
30+
31+
@WireMockTest
32+
class ApacheApiClientResponseHandlingTest
33+
{
34+
private static final String TEST_PATH = "/test";
35+
private static final String TEST_RESPONSE_BODY = "{\"message\": \"success\"}";
36+
37+
@Test
38+
void testResponseMetadataListener( final WireMockRuntimeInfo wmInfo )
39+
{
40+
stubFor(
41+
get(urlEqualTo(TEST_PATH))
42+
.willReturn(
43+
aResponse()
44+
.withStatus(200)
45+
.withHeader("x-custom-header", "some-value")
46+
.withBody(TEST_RESPONSE_BODY)));
47+
48+
final AtomicReference<OpenApiResponse> metadata = new AtomicReference<>();
49+
final ApiClient apiClient =
50+
ApiClient.create().withBasePath(wmInfo.getHttpBaseUrl()).withOpenApiResponseListener(metadata::set);
51+
52+
final TestApi api = new TestApi(apiClient);
53+
final TestResponse result = api.executeRequest();
54+
55+
assertThat(result).isNotNull();
56+
assertThat(result.getMessage()).isEqualTo("success");
57+
assertThat(metadata.get()).isNotNull();
58+
assertThat(metadata.get().getStatusCode()).isEqualTo(200);
59+
assertThat(metadata.get().getHeaders()).isNotEmpty();
60+
assertThat(metadata.get().getHeaders()).containsKey("x-custom-header");
61+
62+
verify(1, getRequestedFor(urlEqualTo(TEST_PATH)));
63+
}
64+
65+
@Test
66+
void testCaseInsensitiveHeaderLookup( final WireMockRuntimeInfo wmInfo )
67+
{
68+
stubFor(
69+
get(urlEqualTo(TEST_PATH))
70+
.willReturn(
71+
aResponse()
72+
.withStatus(200)
73+
.withBody(TEST_RESPONSE_BODY)
74+
.withHeader("x-custom-header", "some-value")));
75+
76+
final AtomicReference<OpenApiResponse> capturedResponse = new AtomicReference<>();
77+
final ApiClient apiClient =
78+
ApiClient.create().withBasePath(wmInfo.getHttpBaseUrl()).withOpenApiResponseListener(capturedResponse::set);
79+
80+
final TestApi api = new TestApi(apiClient);
81+
api.executeRequest();
82+
83+
// Verify case-insensitive access works
84+
final Map<String, List<String>> headers = capturedResponse.get().getHeaders();
85+
assertThat(headers.get("x-custom-header")).contains("some-value");
86+
assertThat(headers.get("X-Custom-Header")).contains("some-value");
87+
assertThat(headers.get("X-CUSTOM-HEADER")).contains("some-value");
88+
}
89+
90+
private static class TestApi extends BaseApi
91+
{
92+
private final String path;
93+
94+
TestApi( final ApiClient apiClient )
95+
{
96+
this(apiClient, TEST_PATH);
97+
}
98+
99+
TestApi( final ApiClient apiClient, final String path )
100+
{
101+
super(apiClient);
102+
this.path = path;
103+
}
104+
105+
TestResponse executeRequest()
106+
throws OpenApiRequestException
107+
{
108+
final List<Pair> localVarQueryParams = new ArrayList<>();
109+
final List<Pair> localVarCollectionQueryParams = new ArrayList<>();
110+
final Map<String, String> localVarHeaderParams = new HashMap<>();
111+
final Map<String, Object> localVarFormParams = new HashMap<>();
112+
113+
final String[] localVarAccepts = { "application/json" };
114+
final String localVarAccept = ApiClient.selectHeaderAccept(localVarAccepts);
115+
116+
final String[] localVarContentTypes = {};
117+
final String localVarContentType = ApiClient.selectHeaderContentType(localVarContentTypes);
118+
119+
final TypeReference<TestResponse> localVarReturnType = new TypeReference<TestResponse>()
120+
{
121+
};
122+
123+
return apiClient
124+
.invokeAPI(
125+
path,
126+
"GET",
127+
localVarQueryParams,
128+
localVarCollectionQueryParams,
129+
null,
130+
null,
131+
localVarHeaderParams,
132+
localVarFormParams,
133+
localVarAccept,
134+
localVarContentType,
135+
localVarReturnType);
136+
}
137+
}
138+
139+
@Data
140+
private static class TestResponse
141+
{
142+
@JsonProperty( "message" )
143+
private String message;
144+
}
145+
}

0 commit comments

Comments
 (0)