Skip to content

Commit

Permalink
Add application/zip as a binary type and allow configuration (#1754)
Browse files Browse the repository at this point in the history
Previously when we used serverless, we added application/zip to the list of binary types.

This was lost when we moved away from serverless, and was caught by a test in starter

micronaut-projects/micronaut-starter#1853

This PR changes from a static set of binary types to a singleton bean that allows types to be
added to the set.  It also adds application/zip as a binary type by default.
  • Loading branch information
timyates committed Jun 6, 2023
1 parent eff68b6 commit 0fce0fa
Show file tree
Hide file tree
Showing 6 changed files with 252 additions and 16 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -16,31 +16,50 @@
package io.micronaut.function.aws.proxy;

import io.micronaut.core.annotation.Internal;
import jakarta.inject.Singleton;

import java.util.HashSet;
import java.util.Set;

/**
* Helper methods for API Gateway content.
* Bean to check if response content is binary and should be base 64 encoded
*/
@Internal
public final class GatewayContentHelpers {
@Singleton
public final class BinaryContentConfiguration {

private static final Set<String> BINARY_CONTENT_TYPES = Set.of("application/octet-stream", "image/jpeg", "image/png", "image/gif");
private final Set<String> binaryContentTypes = new HashSet<>();

private GatewayContentHelpers() {
public BinaryContentConfiguration() {
binaryContentTypes.addAll(Set.of(
"application/octet-stream",
"image/jpeg",
"image/png",
"image/gif",
"application/zip"
));
}

/**
* Add a content type to the list of binary content types.
*
* @param contentType The content type to add
*/
public void addBinaryContentType(String contentType) {
binaryContentTypes.add(contentType);
}

/**
* @param contentType The content type
* @return True if the content type is encoded as binary
*/
public static boolean isBinary(String contentType) {
public boolean isBinary(String contentType) {
if (contentType != null) {
int semidx = contentType.indexOf(';');
if (semidx > -1) {
return BINARY_CONTENT_TYPES.contains(contentType.substring(0, semidx).trim());
return binaryContentTypes.contains(contentType.substring(0, semidx).trim());
} else {
return BINARY_CONTENT_TYPES.contains(contentType.trim());
return binaryContentTypes.contains(contentType.trim());
}
}
return false;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import io.micronaut.context.ApplicationContext;
import io.micronaut.core.annotation.Internal;
import io.micronaut.core.convert.ConversionService;
import io.micronaut.function.aws.proxy.BinaryContentConfiguration;
import io.micronaut.servlet.http.BodyBuilder;
import io.micronaut.servlet.http.ServletExchange;
import io.micronaut.servlet.http.ServletHttpHandler;
Expand All @@ -46,7 +47,10 @@ protected ServletExchange<APIGatewayProxyRequestEvent, APIGatewayProxyResponseEv
) {
return new ApiGatewayProxyServletRequest<>(
request,
new ApiGatewayProxyServletResponse<>(getApplicationContext().getConversionService()),
new ApiGatewayProxyServletResponse<>(
getApplicationContext().getConversionService(),
getApplicationContext().getBean(BinaryContentConfiguration.class)
),
getMediaTypeCodecRegistry(),
applicationContext.getConversionService(),
applicationContext.getBean(BodyBuilder.class)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
package io.micronaut.function.aws.proxy.payload1;

import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent;
import io.micronaut.function.aws.proxy.GatewayContentHelpers;
import io.micronaut.function.aws.proxy.BinaryContentConfiguration;
import io.micronaut.function.aws.proxy.MapCollapseUtils;
import io.micronaut.core.annotation.Internal;
import io.micronaut.core.annotation.Nullable;
Expand Down Expand Up @@ -51,15 +51,17 @@
public class ApiGatewayProxyServletResponse<B> implements ServletHttpResponse<APIGatewayProxyResponseEvent, B> {

private final MutableHttpHeaders headers;
private final BinaryContentConfiguration binaryContentConfiguration;
private final ByteArrayOutputStream body = new ByteArrayOutputStream();

private MutableConvertibleValues<Object> attributes;
private B bodyObject;
private int status = HttpStatus.OK.getCode();
private String reason = HttpStatus.OK.getReason();

public ApiGatewayProxyServletResponse(ConversionService conversionService) {
public ApiGatewayProxyServletResponse(ConversionService conversionService, BinaryContentConfiguration binaryContentConfiguration) {
this.headers = new CaseInsensitiveMutableHttpHeaders(conversionService);
this.binaryContentConfiguration = binaryContentConfiguration;
}

@Override
Expand All @@ -70,7 +72,7 @@ public APIGatewayProxyResponseEvent getNativeResponse() {
.withMultiValueHeaders(MapCollapseUtils.getMulitHeaders(headers))
.withHeaders(MapCollapseUtils.getSingleValueHeaders(headers));

if (GatewayContentHelpers.isBinary(getHeaders().getContentType().orElse(null))) {
if (binaryContentConfiguration.isBinary(getHeaders().getContentType().orElse(null))) {
apiGatewayProxyResponseEvent
.withIsBase64Encoded(true)
.withBody(Base64.getMimeEncoder().encodeToString(body.toByteArray()));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import io.micronaut.context.ApplicationContext;
import io.micronaut.core.annotation.Internal;
import io.micronaut.core.convert.ConversionService;
import io.micronaut.function.aws.proxy.BinaryContentConfiguration;
import io.micronaut.servlet.http.BodyBuilder;
import io.micronaut.servlet.http.ServletExchange;
import io.micronaut.servlet.http.ServletHttpHandler;
Expand All @@ -46,7 +47,10 @@ protected ServletExchange<APIGatewayV2HTTPEvent, APIGatewayV2HTTPResponse> creat
) {
return new APIGatewayV2HTTPEventServletRequest<>(
request,
new APIGatewayV2HTTPResponseServletResponse<>(getApplicationContext().getConversionService()),
new APIGatewayV2HTTPResponseServletResponse<>(
getApplicationContext().getConversionService(),
getApplicationContext().getBean(BinaryContentConfiguration.class)
),
getMediaTypeCodecRegistry(),
applicationContext.getConversionService(),
applicationContext.getBean(BodyBuilder.class)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
package io.micronaut.function.aws.proxy.payload2;

import com.amazonaws.services.lambda.runtime.events.APIGatewayV2HTTPResponse;
import io.micronaut.function.aws.proxy.GatewayContentHelpers;
import io.micronaut.function.aws.proxy.BinaryContentConfiguration;
import io.micronaut.function.aws.proxy.MapCollapseUtils;
import io.micronaut.core.annotation.Internal;
import io.micronaut.core.annotation.Nullable;
Expand Down Expand Up @@ -51,15 +51,17 @@
public class APIGatewayV2HTTPResponseServletResponse<B> implements ServletHttpResponse<APIGatewayV2HTTPResponse, B> {

private final MutableHttpHeaders headers;
private final BinaryContentConfiguration binaryContentConfiguration;
private final ByteArrayOutputStream body = new ByteArrayOutputStream();

private MutableConvertibleValues<Object> attributes;
private B bodyObject;
private int status = HttpStatus.OK.getCode();
private String reason = HttpStatus.OK.getReason();

public APIGatewayV2HTTPResponseServletResponse(ConversionService conversionService) {
public APIGatewayV2HTTPResponseServletResponse(ConversionService conversionService, BinaryContentConfiguration binaryContentConfiguration) {
this.headers = new CaseInsensitiveMutableHttpHeaders(conversionService);
this.binaryContentConfiguration = binaryContentConfiguration;
}

@Override
Expand All @@ -69,8 +71,9 @@ public APIGatewayV2HTTPResponse getNativeResponse() {
.withMultiValueHeaders(MapCollapseUtils.getMulitHeaders(headers))
.withStatusCode(status);

if (GatewayContentHelpers.isBinary(getHeaders().getContentType().orElse(null))) {
apiGatewayV2HTTPResponseBuilder.withIsBase64Encoded(true)
if (binaryContentConfiguration.isBinary(getHeaders().getContentType().orElse(null))) {
apiGatewayV2HTTPResponseBuilder
.withIsBase64Encoded(true)
.withBody(Base64.getMimeEncoder().encodeToString(body.toByteArray()));
} else {
apiGatewayV2HTTPResponseBuilder.withBody(body.toString(getCharacterEncoding()));
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
package io.micronaut.function.aws.proxy

import com.amazonaws.services.lambda.runtime.ClientContext
import com.amazonaws.services.lambda.runtime.CognitoIdentity
import com.amazonaws.services.lambda.runtime.Context
import com.amazonaws.services.lambda.runtime.LambdaLogger
import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent
import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent
import com.amazonaws.services.lambda.runtime.events.APIGatewayV2HTTPEvent
import com.amazonaws.services.lambda.runtime.events.APIGatewayV2HTTPResponse
import io.micronaut.context.ApplicationContext
import io.micronaut.context.ApplicationContextBuilder
import io.micronaut.context.annotation.Requires
import io.micronaut.function.aws.proxy.payload1.ApiGatewayProxyRequestEventFunction
import io.micronaut.function.aws.proxy.payload2.APIGatewayV2HTTPEventFunction
import io.micronaut.http.HttpMethod
import io.micronaut.http.MediaType
import io.micronaut.http.annotation.Controller
import io.micronaut.http.annotation.Get
import io.micronaut.http.annotation.Produces
import spock.lang.Specification

import java.nio.charset.StandardCharsets
import java.util.zip.ZipEntry
import java.util.zip.ZipInputStream
import java.util.zip.ZipOutputStream

class BinaryContentConfigurationSpec extends Specification {

void "test v1 zip is considered binary"() {
given:
ApplicationContextBuilder ctxBuilder = ApplicationContext.builder().properties(
'micronaut.security.enabled': false,
'spec.name': 'BinaryContentConfigurationSpec'
)
ApiGatewayProxyRequestEventFunction handler = new ApiGatewayProxyRequestEventFunction(ctxBuilder.build())

when:
APIGatewayProxyRequestEvent request = v1Request("/context")
Context context = createContext()
APIGatewayProxyResponseEvent response = handler.handleRequest(request, context)

then:
handler.applicationContext.containsBean(BinaryContentConfiguration)
response.isBase64Encoded

when:
def zis = new ZipInputStream(new ByteArrayInputStream(Base64.mimeDecoder.decode(response.body.getBytes())))

then:
with(zis.nextEntry) {
name == 'test.txt'
new String(zis.readAllBytes(), StandardCharsets.UTF_8) == 'test'
}

cleanup:
handler.close()
}

void "test v2 binary content configuration"() {
given:
ApplicationContextBuilder ctxBuilder = ApplicationContext.builder().properties(
'micronaut.security.enabled': false,
'spec.name': 'BinaryContentConfigurationSpec'
)
APIGatewayV2HTTPEventFunction handler = new APIGatewayV2HTTPEventFunction(ctxBuilder.build())

when:
APIGatewayV2HTTPEvent request = v2Request("/context", HttpMethod.GET)
Context context = createContext()
APIGatewayV2HTTPResponse response = handler.handleRequest(request, context)

then:
handler.applicationContext.containsBean(BinaryContentConfiguration)
response.isBase64Encoded

when:
def zis = new ZipInputStream(new ByteArrayInputStream(Base64.mimeDecoder.decode(response.body.getBytes())))

then:
with(zis.nextEntry) {
name == 'test.txt'
new String(zis.readAllBytes(), StandardCharsets.UTF_8) == 'test'
}

cleanup:
handler.close()
}

void "test v1 binary content types can be updated"() {
given:
ApplicationContextBuilder ctxBuilder = ApplicationContext.builder().properties(
'micronaut.security.enabled': false,
'spec.name': 'BinaryContentConfigurationSpec'
)
ApplicationContext ctx = ctxBuilder.build()
ApiGatewayProxyRequestEventFunction handler = new ApiGatewayProxyRequestEventFunction(ctx)
BinaryContentConfiguration binaryContentConfiguration = ctx.getBean(BinaryContentConfiguration)

when:
APIGatewayProxyRequestEvent request = v1Request("/context/plain")
Context context = createContext()
APIGatewayProxyResponseEvent response = handler.handleRequest(request, context)

then:
!response.isBase64Encoded
response.body == 'ok'

when:
binaryContentConfiguration.addBinaryContentType(MediaType.TEXT_PLAIN)
request = v1Request("/context/plain")
response = handler.handleRequest(request, context)

then:
response.isBase64Encoded
new String(Base64.mimeDecoder.decode(response.body.getBytes()), StandardCharsets.UTF_8) == 'ok'

cleanup:
handler.close()
}

void "test v2 binary content types can be updated"() {
given:
ApplicationContextBuilder ctxBuilder = ApplicationContext.builder().properties(
'micronaut.security.enabled': false,
'spec.name': 'BinaryContentConfigurationSpec'
)
ApplicationContext ctx = ctxBuilder.build()
APIGatewayV2HTTPEventFunction handler = new APIGatewayV2HTTPEventFunction(ctx)
BinaryContentConfiguration binaryContentConfiguration = ctx.getBean(BinaryContentConfiguration)

when:
APIGatewayV2HTTPEvent request = v2Request("/context/plain", HttpMethod.GET)
Context context = createContext()
APIGatewayV2HTTPResponse response = handler.handleRequest(request, context)

then:
!response.isBase64Encoded
response.body == 'ok'

when:
binaryContentConfiguration.addBinaryContentType(MediaType.TEXT_PLAIN)
request = v2Request("/context/plain", HttpMethod.GET)
response = handler.handleRequest(request, context)

then:
response.isBase64Encoded
new String(Base64.mimeDecoder.decode(response.body.getBytes()), StandardCharsets.UTF_8) == 'ok'

cleanup:
handler.close()
}

private static APIGatewayProxyRequestEvent v1Request(String path, HttpMethod method = HttpMethod.GET) {
new APIGatewayProxyRequestEvent().withPath(path).withHttpMethod(method.toString())
}

private static APIGatewayV2HTTPEvent v2Request(String path, HttpMethod method = HttpMethod.GET) {
APIGatewayV2HTTPEvent.RequestContext.Http http = APIGatewayV2HTTPEvent.RequestContext.Http.builder()
.withMethod(method.toString())
.withPath(path)
.build()
APIGatewayV2HTTPEvent.RequestContext requestContext = APIGatewayV2HTTPEvent.RequestContext.builder()
.withHttp(http)
.build()
APIGatewayV2HTTPEvent.builder()
.withRequestContext(requestContext)
.build()
}

Context createContext() {
Stub(Context) {
getAwsRequestId() >> 'XXX'
getIdentity() >> Mock(CognitoIdentity)
getClientContext() >> Mock(ClientContext)
getClientContext() >> Mock(ClientContext)
getLogger() >> Mock(LambdaLogger)
}
}

@Requires(property = "spec.name", value = "BinaryContentConfigurationSpec")
@Controller("/context")
static class LambdaContextSpecController {

@Get
@Produces("application/zip")
byte[] index() {
def baos = new ByteArrayOutputStream()
new ZipOutputStream(baos).with {
it.putNextEntry(new ZipEntry("test.txt"))
write("test".bytes)
closeEntry()
close()
baos.toByteArray()
}
}

@Get("/plain")
@Produces(MediaType.TEXT_PLAIN)
String plain() {
"ok"
}
}
}

0 comments on commit 0fce0fa

Please sign in to comment.