Skip to content

Commit 72f1c0c

Browse files
feat: [OpenAPI] GZIP encoding (#1110)
Co-authored-by: Jonas-Isr <jonas.israel@sap.com>
1 parent 7c47325 commit 72f1c0c

3 files changed

Lines changed: 216 additions & 74 deletions

File tree

datamodel/openapi/openapi-core-apache/pom.xml

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,5 +94,15 @@
9494
<artifactId>junit-jupiter-params</artifactId>
9595
<scope>test</scope>
9696
</dependency>
97+
<dependency>
98+
<groupId>org.mockito</groupId>
99+
<artifactId>mockito-core</artifactId>
100+
<scope>test</scope>
101+
</dependency>
102+
<dependency>
103+
<groupId>io.vavr</groupId>
104+
<artifactId>vavr</artifactId>
105+
<scope>test</scope>
106+
</dependency>
97107
</dependencies>
98-
</project>
108+
</project>

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

Lines changed: 119 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@
1414

1515
import static com.sap.cloud.sdk.services.openapi.apache.apiclient.DefaultApiResponseHandler.isJsonMime;
1616
import static lombok.AccessLevel.PRIVATE;
17+
import static org.apache.hc.core5.http.HttpHeaders.CONTENT_ENCODING;
1718

19+
import java.io.ByteArrayOutputStream;
1820
import java.io.File;
1921
import java.io.IOException;
2022
import java.net.URLEncoder;
@@ -28,6 +30,7 @@
2830
import java.util.Map;
2931
import java.util.Map.Entry;
3032
import java.util.Set;
33+
import java.util.zip.GZIPOutputStream;
3134

3235
import javax.annotation.Nonnull;
3336
import javax.annotation.Nullable;
@@ -68,6 +71,7 @@
6871
import lombok.Getter;
6972
import lombok.ToString;
7073
import lombok.With;
74+
import lombok.val;
7175

7276
/**
7377
* API client for executing HTTP requests using Apache HttpClient 5.
@@ -348,77 +352,6 @@ private ContentType getContentType( @Nonnull final String headerValue )
348352
}
349353
}
350354

351-
/**
352-
* Serialize the given Java object into string according the given Content-Type (only JSON is supported for now).
353-
*
354-
* @param obj
355-
* Object
356-
* @param contentType
357-
* Content type
358-
* @param formParams
359-
* Form parameters
360-
* @return Object
361-
* @throws OpenApiRequestException
362-
* API exception
363-
*/
364-
@Nonnull
365-
private HttpEntity serialize(
366-
@Nullable final Object obj,
367-
@Nonnull final Map<String, Object> formParams,
368-
@Nonnull final ContentType contentType )
369-
throws OpenApiRequestException
370-
{
371-
final String mimeType = contentType.getMimeType();
372-
if( isJsonMime(mimeType) ) {
373-
try {
374-
return new StringEntity(
375-
objectMapper.writeValueAsString(obj),
376-
contentType.withCharset(StandardCharsets.UTF_8));
377-
}
378-
catch( JsonProcessingException e ) {
379-
throw new OpenApiRequestException(e);
380-
}
381-
} else if( mimeType.equals(ContentType.MULTIPART_FORM_DATA.getMimeType()) ) {
382-
final MultipartEntityBuilder multiPartBuilder = MultipartEntityBuilder.create();
383-
for( final Entry<String, Object> paramEntry : formParams.entrySet() ) {
384-
final Object value = paramEntry.getValue();
385-
if( value instanceof File file ) {
386-
multiPartBuilder.addBinaryBody(paramEntry.getKey(), file);
387-
} else if( value instanceof byte[] byteArray ) {
388-
multiPartBuilder.addBinaryBody(paramEntry.getKey(), byteArray);
389-
} else {
390-
final Charset charset = contentType.getCharset();
391-
if( charset != null ) {
392-
final ContentType customContentType =
393-
ContentType.create(ContentType.TEXT_PLAIN.getMimeType(), charset);
394-
multiPartBuilder
395-
.addTextBody(
396-
paramEntry.getKey(),
397-
parameterToString(paramEntry.getValue()),
398-
customContentType);
399-
} else {
400-
multiPartBuilder.addTextBody(paramEntry.getKey(), parameterToString(paramEntry.getValue()));
401-
}
402-
}
403-
}
404-
return multiPartBuilder.build();
405-
} else if( mimeType.equals(ContentType.APPLICATION_FORM_URLENCODED.getMimeType()) ) {
406-
final List<NameValuePair> formValues = new ArrayList<>();
407-
for( final Entry<String, Object> paramEntry : formParams.entrySet() ) {
408-
formValues.add(new BasicNameValuePair(paramEntry.getKey(), parameterToString(paramEntry.getValue())));
409-
}
410-
return new UrlEncodedFormEntity(formValues, contentType.getCharset());
411-
} else {
412-
// Handle files with unknown content type
413-
if( obj instanceof File file ) {
414-
return new FileEntity(file, contentType);
415-
} else if( obj instanceof byte[] byteArray ) {
416-
return new ByteArrayEntity(byteArray, contentType);
417-
}
418-
throw new OpenApiRequestException("Serialization for content type '" + contentType + "' not supported");
419-
}
420-
}
421-
422355
/**
423356
* Build full URL by concatenating base URL, the given sub path and query parameters.
424357
*
@@ -560,7 +493,7 @@ public <T> T invokeAPI(
560493
if( body != null || !formParams.isEmpty() ) {
561494
if( isBodyAllowed(Method.valueOf(method)) ) {
562495
// Add entity if we have content and a valid method
563-
builder.setEntity(serialize(body, formParams, contentTypeObj));
496+
builder.setEntity(serialize(body, formParams, contentTypeObj, headerParams));
564497
} else {
565498
throw new OpenApiRequestException("method " + method + " does not support a request body");
566499
}
@@ -578,4 +511,118 @@ public <T> T invokeAPI(
578511
throw new OpenApiRequestException(e);
579512
}
580513
}
514+
515+
/**
516+
* Serialize the given Java object into string according the given Content-Type (only JSON is supported for now).
517+
*
518+
* @param body
519+
* Object
520+
* @param contentType
521+
* Content type
522+
* @param formParams
523+
* Form parameters
524+
* @param headerParams
525+
* Header parameters, used to check content encoding for JSON serialization
526+
* @return Object
527+
* @throws OpenApiRequestException
528+
* API exception
529+
*/
530+
@Nonnull
531+
private HttpEntity serialize(
532+
@Nullable final Object body,
533+
@Nonnull final Map<String, Object> formParams,
534+
@Nonnull final ContentType contentType,
535+
@Nonnull final Map<String, String> headerParams )
536+
throws OpenApiRequestException
537+
{
538+
final String mimeType = contentType.getMimeType();
539+
if( isJsonMime(mimeType) ) {
540+
return serializeJson(body, contentType, headerParams);
541+
} else if( mimeType.equals(ContentType.MULTIPART_FORM_DATA.getMimeType()) ) {
542+
return serializeMultipart(formParams, contentType);
543+
} else if( mimeType.equals(ContentType.APPLICATION_FORM_URLENCODED.getMimeType()) ) {
544+
return serializeFormUrlEncoded(formParams, contentType);
545+
} else if( body instanceof File file ) {
546+
return new FileEntity(file, contentType);
547+
} else if( body instanceof byte[] byteArray ) {
548+
return new ByteArrayEntity(byteArray, contentType);
549+
}
550+
throw new OpenApiRequestException("Serialization for content type '" + contentType + "' not supported");
551+
}
552+
553+
@Nonnull
554+
private HttpEntity serializeJson(
555+
@Nullable final Object body,
556+
@Nonnull final ContentType contentType,
557+
@Nonnull final Map<String, String> headerParams )
558+
throws OpenApiRequestException
559+
{
560+
if( "gzip".equalsIgnoreCase(headerParams.get(CONTENT_ENCODING))
561+
|| "gzip".equalsIgnoreCase(headerParams.get(CONTENT_ENCODING.toLowerCase())) ) {
562+
val outputStream = new ByteArrayOutputStream();
563+
try( val gzip = new GZIPOutputStream(outputStream) ) {
564+
gzip.write(objectMapper.writeValueAsBytes(body));
565+
}
566+
catch( IOException e ) {
567+
throw new OpenApiRequestException("Failed to GZIP compress request body", e);
568+
}
569+
return new ByteArrayEntity(
570+
outputStream.toByteArray(),
571+
contentType.withCharset(StandardCharsets.UTF_8),
572+
"gzip");
573+
}
574+
try {
575+
return new StringEntity(
576+
objectMapper.writeValueAsString(body),
577+
contentType.withCharset(StandardCharsets.UTF_8));
578+
}
579+
catch( JsonProcessingException e ) {
580+
throw new OpenApiRequestException(e);
581+
}
582+
}
583+
584+
@Nonnull
585+
private
586+
HttpEntity
587+
serializeMultipart( @Nonnull final Map<String, Object> formParams, @Nonnull final ContentType contentType )
588+
{
589+
final MultipartEntityBuilder builder = MultipartEntityBuilder.create();
590+
for( final Entry<String, Object> entry : formParams.entrySet() ) {
591+
final Object value = entry.getValue();
592+
if( value instanceof File file ) {
593+
builder.addBinaryBody(entry.getKey(), file);
594+
} else if( value instanceof byte[] byteArray ) {
595+
builder.addBinaryBody(entry.getKey(), byteArray);
596+
} else {
597+
addMultipartTextField(builder, entry, contentType);
598+
}
599+
}
600+
return builder.build();
601+
}
602+
603+
private void addMultipartTextField(
604+
@Nonnull final MultipartEntityBuilder builder,
605+
@Nonnull final Entry<String, Object> entry,
606+
@Nonnull final ContentType contentType )
607+
{
608+
final Charset charset = contentType.getCharset();
609+
if( charset != null ) {
610+
final ContentType textContentType = ContentType.create(ContentType.TEXT_PLAIN.getMimeType(), charset);
611+
builder.addTextBody(entry.getKey(), parameterToString(entry.getValue()), textContentType);
612+
} else {
613+
builder.addTextBody(entry.getKey(), parameterToString(entry.getValue()));
614+
}
615+
}
616+
617+
@Nonnull
618+
private
619+
HttpEntity
620+
serializeFormUrlEncoded( @Nonnull final Map<String, Object> formParams, @Nonnull final ContentType contentType )
621+
{
622+
final List<NameValuePair> formValues = new ArrayList<>();
623+
for( final Entry<String, Object> entry : formParams.entrySet() ) {
624+
formValues.add(new BasicNameValuePair(entry.getKey(), parameterToString(entry.getValue())));
625+
}
626+
return new UrlEncodedFormEntity(formValues, contentType.getCharset());
627+
}
581628
}

datamodel/openapi/openapi-core-apache/src/test/java/com/sap/cloud/sdk/services/openapi/apache/apiclient/ApacheApiClientResponseHandlingTest.java

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,33 +3,44 @@
33
import static com.github.tomakehurst.wiremock.client.WireMock.aResponse;
44
import static com.github.tomakehurst.wiremock.client.WireMock.get;
55
import static com.github.tomakehurst.wiremock.client.WireMock.getRequestedFor;
6+
import static com.github.tomakehurst.wiremock.client.WireMock.post;
7+
import static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor;
68
import static com.github.tomakehurst.wiremock.client.WireMock.stubFor;
79
import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo;
810
import static com.github.tomakehurst.wiremock.client.WireMock.verify;
911
import static org.assertj.core.api.Assertions.assertThat;
1012

13+
import java.nio.charset.StandardCharsets;
1114
import java.util.ArrayList;
1215
import java.util.HashMap;
1316
import java.util.List;
1417
import java.util.Map;
1518
import java.util.concurrent.atomic.AtomicReference;
19+
import java.util.zip.GZIPInputStream;
1620

21+
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
22+
import org.apache.hc.core5.http.protocol.HttpContext;
1723
import org.junit.jupiter.api.Test;
24+
import org.mockito.Mockito;
1825

1926
import com.fasterxml.jackson.annotation.JsonProperty;
2027
import com.fasterxml.jackson.core.type.TypeReference;
2128
import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo;
2229
import com.github.tomakehurst.wiremock.junit5.WireMockTest;
30+
import com.sap.cloud.sdk.cloudplatform.connectivity.ApacheHttpClient5Accessor;
2331
import com.sap.cloud.sdk.services.openapi.apache.core.OpenApiRequestException;
2432
import com.sap.cloud.sdk.services.openapi.apache.core.OpenApiResponse;
2533

34+
import io.vavr.control.Try;
2635
import lombok.Data;
36+
import lombok.SneakyThrows;
2737

2838
@WireMockTest
2939
class ApacheApiClientResponseHandlingTest
3040
{
3141
private static final String TEST_PATH = "/test";
3242
private static final String TEST_RESPONSE_BODY = "{\"message\": \"success\"}";
43+
private static final String TEST_POST_PATH = "/test-post";
3344

3445
@Test
3546
void testResponseMetadataListener( final WireMockRuntimeInfo wmInfo )
@@ -84,6 +95,27 @@ void testCaseInsensitiveHeaderLookup( final WireMockRuntimeInfo wmInfo )
8495
assertThat(headers.get("X-CUSTOM-HEADER")).contains("some-value");
8596
}
8697

98+
@Test
99+
@SneakyThrows
100+
void testGzipEncodedPayload( final WireMockRuntimeInfo wmInfo )
101+
{
102+
stubFor(post(urlEqualTo(TEST_POST_PATH)).willReturn(aResponse().withStatus(200).withBody(TEST_RESPONSE_BODY)));
103+
104+
final CloseableHttpClient client = Mockito.spy((CloseableHttpClient) ApacheHttpClient5Accessor.getHttpClient());
105+
final ApiClient apiClient = ApiClient.fromHttpClient(client).withBasePath(wmInfo.getHttpBaseUrl());
106+
final TestPostApi api = new TestPostApi(apiClient);
107+
final TestResponse result = api.executeGzipRequest();
108+
Mockito.verify(client, Mockito.times(1)).execute(Mockito.argThat(request -> {
109+
final byte[] c = Try.of(() -> new GZIPInputStream(request.getEntity().getContent()).readAllBytes()).get();
110+
return new String(c, StandardCharsets.UTF_8).contains("test payload");
111+
}), Mockito.any(HttpContext.class), Mockito.any());
112+
113+
assertThat(result).isNotNull();
114+
assertThat(result.getMessage()).isEqualTo("success");
115+
116+
verify(1, postRequestedFor(urlEqualTo(TEST_POST_PATH)));
117+
}
118+
87119
private static class TestApi extends BaseApi
88120
{
89121
private final String path;
@@ -113,7 +145,7 @@ TestResponse executeRequest()
113145
final String[] localVarContentTypes = {};
114146
final String localVarContentType = ApiClient.selectHeaderContentType(localVarContentTypes);
115147

116-
final TypeReference<TestResponse> localVarReturnType = new TypeReference<TestResponse>()
148+
final TypeReference<TestResponse> localVarReturnType = new TypeReference<>()
117149
{
118150
};
119151

@@ -133,6 +165,59 @@ TestResponse executeRequest()
133165
}
134166
}
135167

168+
private static class TestPostApi extends BaseApi
169+
{
170+
private final String path;
171+
172+
TestPostApi( final ApiClient apiClient )
173+
{
174+
this(apiClient, TEST_POST_PATH);
175+
}
176+
177+
TestPostApi( final ApiClient apiClient, final String path )
178+
{
179+
super(apiClient);
180+
this.path = path;
181+
}
182+
183+
TestResponse executeGzipRequest()
184+
throws OpenApiRequestException
185+
{
186+
final TestResponse requestBody = new TestResponse();
187+
requestBody.setMessage("test payload");
188+
189+
final List<Pair> localVarQueryParams = new ArrayList<>();
190+
final List<Pair> localVarCollectionQueryParams = new ArrayList<>();
191+
final Map<String, String> localVarHeaderParams = new HashMap<>();
192+
localVarHeaderParams.put("Content-Encoding", "gzip");
193+
final Map<String, Object> localVarFormParams = new HashMap<>();
194+
195+
final String[] localVarAccepts = { "application/json" };
196+
final String localVarAccept = ApiClient.selectHeaderAccept(localVarAccepts);
197+
198+
final String[] localVarContentTypes = { "application/json" };
199+
final String localVarContentType = ApiClient.selectHeaderContentType(localVarContentTypes);
200+
201+
final TypeReference<TestResponse> localVarReturnType = new TypeReference<>()
202+
{
203+
};
204+
205+
return apiClient
206+
.invokeAPI(
207+
path,
208+
"POST",
209+
localVarQueryParams,
210+
localVarCollectionQueryParams,
211+
null,
212+
requestBody,
213+
localVarHeaderParams,
214+
localVarFormParams,
215+
localVarAccept,
216+
localVarContentType,
217+
localVarReturnType);
218+
}
219+
}
220+
136221
@Data
137222
private static class TestResponse
138223
{

0 commit comments

Comments
 (0)