diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md new file mode 100644 index 00000000..32e04baa --- /dev/null +++ b/.github/ISSUE_TEMPLATE.md @@ -0,0 +1,30 @@ +*To help us debug your issue fill in the basic information below using the options provided* + +*Serverless Java Container version*: `eg. 1.5` + +*Implementations:* `Jersey / Spring / Spring Boot / Spring Boot 2 / Spark` + +*Framework version:* `eg SpringBoot 2.2.6.RELEASE` + +*Frontend service:* `REST API / HTTP API / ALB` + +*Deployment method:* `eg SAM, Serverless Framework, Console` + +## Scenario +*Describe what you are trying to accomplish* + +## Expected behavior +*Describe how you would expect the application to behave* + +## Actual behavior +*Describe what you are seeing instead* + +## Steps to reproduce +*Provide code samples we can use to reproduce the issue as part of our integration tests. If there is a public repository for the misbehaving application link to it here* + +## Full log output +*Paste the full log output from the Lambda function's CloudWatch logs* + +``` +logs +``` \ No newline at end of file diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000..379cffdb --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,9 @@ +*Issue #, if available:* + +*Description of changes:* + + +By submitting this pull request + +- [ ] I confirm that my contribution is made under the terms of the Apache 2.0 license. +- [ ] I confirm that I've made a best effort attempt to update all relevant documentation. \ No newline at end of file diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..acc835c6 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,62 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 +updates: + - package-ecosystem: maven + directories: + - "**/*" + groups: + jersey: + patterns: + - "org.glassfish.jersey.*:*" + spring: + patterns: + - "org.springframework:*" + slf4j: + patterns: + - "org.slf4j:*" + jackson: + patterns: + - "com.fasterxml.jackson.*:*" + log4j: + patterns: + - "org.apache.logging.log4j:*" + junit: + patterns: + - "org.junit:*" + maven-install-plugin: + patterns: + - "org.apache.maven.plugins:maven-install-plugin" + httpclient: + patterns: + - "org.apache.httpcomponents.client5:*" + schedule: + interval: "weekly" + open-pull-requests-limit: 20 + - package-ecosystem: gradle + directories: + - "**/*" + groups: + jersey: + patterns: + - "org.glassfish.jersey.*:*" + spring: + patterns: + - "org.springframework:*" + slf4j: + patterns: + - "org.slf4j:*" + log4j: + patterns: + - "org.apache.logging.log4j:*" + jackson: + patterns: + - "com.fasterxml.jackson.*:*" + httpclient: + patterns: + - "org.apache.httpcomponents.client5:*" + schedule: + interval: "weekly" \ No newline at end of file diff --git a/.github/workflows/continuous-integration-workflow.yml b/.github/workflows/continuous-integration-workflow.yml new file mode 100644 index 00000000..68d7a2f3 --- /dev/null +++ b/.github/workflows/continuous-integration-workflow.yml @@ -0,0 +1,93 @@ +name: Continuous Integration +on: + push: + pull_request: + branches: + - main + - 2.0.x + - 1.x + workflow_dispatch: + +permissions: + contents: read + +jobs: + build_core: + name: Build and test core + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Set up JDK 17 + uses: actions/setup-java@v3 + with: + distribution: 'corretto' + java-version: 17 + - name: Build latest + run: mvn -q clean install + working-directory: ./aws-serverless-java-container-core + + build_jersey: + name: Build and test Jersey + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Set up JDK 17 + uses: actions/setup-java@v3 + with: + distribution: 'corretto' + java-version: 17 + - name: Build latest + run: ./gha_build.sh jersey true true +# - name: Set up JDK 8 +# uses: actions/setup-java@v3 +# with: +# distribution: 'corretto' +# java-version: 8 +# - name: Build Jersey 2.27 +# run: ./gha_build.sh jersey false false -Djersey.version=2.27 +# - name: Build Jersey 2.28 +# run: ./gha_build.sh jersey false false -Djersey.version=2.28 +# - name: Build Jersey 2.29 +# run: ./gha_build.sh jersey false false -Djersey.version=2.29.1 + + build_spring: + name: Build and test Spring + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Set up JDK 17 + uses: actions/setup-java@v3 + with: + distribution: 'corretto' + java-version: 17 + - name: Build latest + run: ./gha_build.sh spring true true +# - name: Build with Spring 6.0.x +# run: ./gha_build.sh spring false false -Dspring.version=6.0.16 -Dspring-security.version=6.1.10 -Ddependency-check.skip=true + + + build_springboot4: + name: Build and test SpringBoot 4 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Set up JDK 17 + uses: actions/setup-java@v3 + with: + distribution: 'corretto' + java-version: 17 + - name: Build latest + run: ./gha_build.sh springboot4 true true +# temporarily disabled as Struts is not released at the moment +# build_struts2: +# name: Build and test Struts +# runs-on: ubuntu-latest +# steps: +# - uses: actions/checkout@v3 +# - name: Set up JDK 17 +# uses: actions/setup-java@v3 +# with: +# distribution: 'corretto' +# java-version: 17 +# - name: Build latest +# run: ./gha_build.sh struts true true \ No newline at end of file diff --git a/.github/workflows/owasp-dependency-check.yml b/.github/workflows/owasp-dependency-check.yml new file mode 100644 index 00000000..b7df2a77 --- /dev/null +++ b/.github/workflows/owasp-dependency-check.yml @@ -0,0 +1,21 @@ +name: OWASP dependency check +on: + schedule: + - cron: "10 10 * * 3" + +permissions: + contents: read + +jobs: + owasp-dependency-check: + name: Verify dependencies with OWASP checker + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Set up JDK 17 + uses: actions/setup-java@v3 + with: + distribution: 'corretto' + java-version: 17 + - name: Build latest + run: mvn -q package org.owasp:dependency-check-maven:check \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..57750612 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,52 @@ +name: Publish package to the Maven Central Repository +on: + workflow_dispatch: + inputs: + releaseVersion: + description: "Version to use for the release." + required: true + default: "X.Y.Z" + developmentVersion: + description: "Version to use for further development" + required: true + default: "X.Y.Z-SNAPSHOT" +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Configure Git User # may be removed after https://github.com/actions/checkout/issues/13 is resolved + run: | + git config user.email "${GITHUB_ACTOR}@users.noreply.github.com" + git config user.name "$GITHUB_ACTOR" + env: + GITHUB_ACTOR: ${{ github.actor }} + - name: Set up Maven + uses: actions/setup-java@v3 + with: + distribution: 'corretto' + java-version: 17 + server-id: sonatype-central-staging + server-username: MAVEN_USERNAME + server-password: MAVEN_PASSWORD + gpg-private-key: ${{ secrets.GPG_SIGNING_KEY }} # Value of the GPG private key to import + gpg-passphrase: GPG_PASSPHRASE # env variable for GPG private key passphrase + - name: Release and publish package + run: mvn release:prepare release:perform -B -DreleaseVersion="$RELEASE_VERSION" -DdevelopmentVersion="$DEVELOPMENT_VERSION" + env: + MAVEN_USERNAME: ${{ secrets.CENTRAL_USERNAME }} + MAVEN_PASSWORD: ${{ secrets.CENTRAL_PASSWORD }} + GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} + RELEASE_VERSION: ${{ github.event.inputs.releaseVersion }} + DEVELOPMENT_VERSION: ${{ github.event.inputs.developmentVersion }} + - name: Upload to Central Publisher Portal + run: | + AUTH=$(echo -n "$CENTRAL_USERNAME:$CENTRAL_PASSWORD" | base64) + curl --fail -X POST "https://ossrh-staging-api.central.sonatype.com/manual/upload/defaultRepository/com.amazonaws.serverless" \ + -H "Authorization: Bearer $AUTH" + env: + CENTRAL_USERNAME: ${{ secrets.CENTRAL_USERNAME }} + CENTRAL_PASSWORD: ${{ secrets.CENTRAL_PASSWORD }} diff --git a/.gitignore b/.gitignore index c79f7d61..2aa1654c 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,9 @@ *.jar *.war *.ear +*.project +*.classpath +*.settings # Idea project files .idea/ @@ -22,6 +25,14 @@ release.properties dependency-reduced-pom.xml buildNumber.properties .mvn/timing.properties +.gradle/ +gradle/ +build/ +gradlew* # Exclude maven wrapper !/.mvn/wrapper/maven-wrapper.jar + +# SAM files +samconfig.toml +.aws-sam/ diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index edc61950..00000000 --- a/.travis.yml +++ /dev/null @@ -1,4 +0,0 @@ -language: java -jdk: - - oraclejdk8 -script: mvn install diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..3b644668 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,4 @@ +## Code of Conduct +This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). +For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact +opensource-codeofconduct@amazon.com with any additional questions or comments. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..5d42e96a --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,61 @@ +# Contributing Guidelines + +Thank you for your interest in contributing to our project. Whether it's a bug report, new feature, correction, or additional +documentation, we greatly value feedback and contributions from our community. + +Please read through this document before submitting any issues or pull requests to ensure we have all the necessary +information to effectively respond to your bug report or contribution. + + +## Reporting Bugs/Feature Requests + +We welcome you to use the GitHub issue tracker to report bugs or suggest features. + +When filing an issue, please check [existing open](https://github.com/aws/serverless-java-container/issues), or [recently closed](https://github.com/aws/serverless-java-container/issues?utf8=%E2%9C%93&q=is%3Aissue%20is%3Aclosed%20), issues to make sure somebody else hasn't already +reported the issue. Please try to include as much information as you can. Details like these are incredibly useful: + +* A reproducible test case or series of steps +* The version of our code being used +* Any modifications you've made relevant to the bug +* Anything unusual about your environment or deployment + + +## Contributing via Pull Requests +Contributions via pull requests are much appreciated. Before sending us a pull request, please ensure that: + +1. You are working against the latest source on the *master* branch. +2. You check existing open, and recently merged, pull requests to make sure someone else hasn't addressed the problem already. +3. You open an issue to discuss any significant work - we would hate for your time to be wasted. + +To send us a pull request, please: + +1. Fork the repository. +2. Modify the source; please focus on the specific change you are contributing. If you also reformat all the code, it will be hard for us to focus on your change. +3. Ensure local tests pass. +4. Commit to your fork using clear commit messages. +5. Send us a pull request, answering any default questions in the pull request interface. +6. Pay attention to any automated CI failures reported in the pull request, and stay involved in the conversation. + +GitHub provides additional document on [forking a repository](https://help.github.com/articles/fork-a-repo/) and +[creating a pull request](https://help.github.com/articles/creating-a-pull-request/). + + +## Finding contributions to work on +Looking at the existing issues is a great way to find something to contribute on. As our projects, by default, use the default GitHub issue labels ((enhancement/bug/duplicate/help wanted/invalid/question/wontfix), looking at any ['help wanted'](https://github.com/aws/serverless-java-container/labels/help%20wanted) issues is a great place to start. + + +## Code of Conduct +This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). +For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact +opensource-codeofconduct@amazon.com with any additional questions or comments. + + +## Security issue notifications +If you discover a potential security issue in this project we ask that you notify AWS/Amazon Security via our [vulnerability reporting page](http://aws.amazon.com/security/vulnerability-reporting/). Please do **not** create a public github issue. + + +## Licensing + +See the [LICENSE](https://github.com/aws/serverless-java-container/blob/master/LICENSE) file for our project's licensing. We will ask you to confirm the licensing of your contribution. + +We may ask you to sign a [Contributor License Agreement (CLA)](http://en.wikipedia.org/wiki/Contributor_License_Agreement) for larger changes. diff --git a/NOTICE b/NOTICE new file mode 100644 index 00000000..cd46713a --- /dev/null +++ b/NOTICE @@ -0,0 +1,2 @@ +AWS Serverless Java Container +Copyright 2018 Amazon.com, Inc. or its affiliates. All Rights Reserved. \ No newline at end of file diff --git a/README.md b/README.md index a081b4d7..15fc7c60 100644 --- a/README.md +++ b/README.md @@ -1,193 +1,63 @@ -# Serverless Java container [![Build Status](https://travis-ci.org/awslabs/aws-serverless-java-container.svg?branch=master)](https://travis-ci.org/awslabs/aws-serverless-java-container) [![Help](http://img.shields.io/badge/help-gitter-E91E63.svg?style=flat-square)](https://gitter.im/awslabs/aws-serverless-java-container) -The `aws-serverless-java-container` is collection of interfaces and their implementations that let you run Java application written with frameworks such as [Jersey](https://jersey.java.net/) or [Spark](http://sparkjava.com/) in [AWS Lambda](https://aws.amazon.com/lambda/). +# Serverless Java container [![Build Status](https://github.com/aws/serverless-java-container/workflows/Continuous%20Integration/badge.svg)](https://github.com/aws/serverless-java-container/actions) [![Maven Central](https://maven-badges.herokuapp.com/maven-central/com.amazonaws.serverless/aws-serverless-java-container/badge.svg)](https://maven-badges.herokuapp.com/maven-central/com.amazonaws.serverless/aws-serverless-java-container) [![Help](http://img.shields.io/badge/help-gitter-E91E63.svg?style=flat-square)](https://gitter.im/aws/serverless-java-container) +The `aws-serverless-java-container` makes it easy to run Java applications written with frameworks such as [Spring](https://spring.io/), [Spring Boot](https://projects.spring.io/spring-boot/), [Apache Struts](http://struts.apache.org/), [Jersey](https://jersey.java.net/), or [Spark](http://sparkjava.com/) in [AWS Lambda](https://aws.amazon.com/lambda/). -The library contains a core artifact called `aws-serverless-java-container-core` that defines the interfaces and base classes required as well as default implementation of the Java servlet `HttpServletRequest` and `HttpServletResponse`. -The library also includes two initial implementations of the interfaces to support Jersey apps (`aws-serverless-java-container-jersey`) and Spark (`aws-serverless-java-container-spark`). +Serverless Java Container natively supports API Gateway's proxy integration models for requests and responses, you can create and inject custom models for methods that use custom mappings. -To include the library in your Maven project, add the desired implementation to your `pom.xml` file, for example: +Currently the following versions are maintained: -``` - - com.amazonaws.serverless - aws-serverless-java-container-jersey - 0.4 - -``` +| Version | Branch | Java Enterprise support | Spring versions | JAX-RS/ Jersey version | Struts support | Spark support | +|---------|--------|-----------------------------|-----------------|------------------------|----------------|---------------| +| 1.x | [1.x](https://github.com/aws/serverless-java-container/tree/1.x) | Java EE (javax.*) | 5.x (Boot 2.x) | 2.x | :white_check_mark: | :white_check_mark: | +| 2.x | [2.1.x](https://github.com/aws/serverless-java-container/tree/2.1.x) | Jakarta EE 9-10 (jakarta.*) | 6.x (Boot 3.x) | 3.x | :x: | :x: | +| 3.x | [main](https://github.com/aws/serverless-java-container/tree/main) | Jakarta EE 11 (jakarta.*) | 7.x (Boot 4.x) | 3.x | :x: | :x: | -## Integrating with Lambda -The simplest way to run your application serverlessly is to configure [API Gateway](https://aws.amazon.com/api-gateway/) to use the -[`AWS_PROXY`](http://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-set-up-simple-proxy.html#api-gateway-set-up-lambda-proxy-integration-on-proxy-resource) integration type and -configure your desired `LambdaContainerHandler` implementation to use `AwsProxyRequest`/`AwsProxyResponse` readers and writers. Both Spark and Jersey implementations provide static helper methods that -pre-configure this for you. +Follow the quick start guides in [our wiki](https://github.com/aws/serverless-java-container/wiki) to integrate Serverless Java Container with your project: +* [Spring quick start](https://github.com/aws/serverless-java-container/wiki/Quick-start---Spring) +* [Spring Boot 2 quick start](https://github.com/aws/serverless-java-container/wiki/Quick-start---Spring-Boot2) +* [Spring Boot 3 quick start](https://github.com/aws/serverless-java-container/wiki/Quick-start---Spring-Boot3) +* [Spring Boot 4 quick start](https://github.com/aws/serverless-java-container/wiki/Quick-start---Spring-Boot4) +* [Apache Struts quick start](https://github.com/aws/serverless-java-container/wiki/Quick-start---Struts) +* [Jersey quick start](https://github.com/aws/serverless-java-container/wiki/Quick-start---Jersey) +* [Spark quick start](https://github.com/aws/serverless-java-container/wiki/Quick-start---Spark) -When using a Cognito User Pool authorizer, use the Lambda `RequestStreamHandler` instead of the POJO-based `RequestHandler` handler. An example of this is included at the bottom of this file. The POJO handler does not support Jackson annotations required for the `CognitoAuthorizerClaims` class. - -### Jersey support -The library expects to receive a valid [JAX-RS](https://jax-rs-spec.java.net) application object. For the Jersey implementation this is the `ResourceConfig` object. - -```java -public class LambdaHandler implements RequestHandler { - private ResourceConfig jerseyApplication = new ResourceConfig().packages("my.jersey.app.package"); - private JerseyLambdaContainerHandler handler - = JerseyLambdaContainerHandler.getAwsProxyHandler(jerseyApplication); - - public AwsProxyResponse handleRequest(AwsProxyRequest awsProxyRequest, Context context) { - return handler.proxy(awsProxyRequest, context); - } -} -``` - -### Spring support -The library supports Spring applications that are configured using annotations (in code) rather than in an XML file. The simplest possible configuration uses the `@ComponentScan` annotation to load all controller classes from a package. For example, our unit test application has the following configuration class. - -```java -@Configuration -@ComponentScan("com.amazonaws.serverless.proxy.spring.echoapp") -public class EchoSpringAppConfig { -} -``` - -Once you have declared a configuration class, you can initialize the library with the class name: -```java -public class LambdaHandler implements RequestHandler { - SpringLambdaContainerHandler handler = - SpringLambdaContainerHandler.getAwsProxyHandler(EchoSpringAppConfig.class); - - public AwsProxyResponse handleRequest(AwsProxyRequest awsProxyRequest, Context context) { - return handler.proxy(awsProxyRequest, context); - } -} -``` - -#### Spring Profiles -You can enable Spring Profiles (as defined with the `@Profile` annotation) by using the `SpringLambdaContainerHandler.activateSpringProfiles(String...)` method - common drivers of this might be the AWS Lambda stage that you're deployed under, or stage variables. See [@Profile documentation](http://docs.spring.io/spring/docs/current/javadoc-api/org/springframework/context/annotation/Profile.html) for details. - -### Spark support -The library also supports applications written with the [Spark framework](http://sparkjava.com/). When using the library with Spark, it's important to initialize the `SparkLambdaContainerHandler` before defining routes. +Below is the most basic AWS Lambda handler example that launches a Spring application. You can also take a look at the [samples](https://github.com/aws/serverless-java-container/tree/master/samples) in this repository, our main wiki page includes a [step-by-step guide](https://github.com/aws/serverless-java-container/wiki#deploying-the-sample-applications) on how to deploy the various sample applications using Maven and [SAM](https://github.com/awslabs/serverless-application-model). ```java -public class LambdaHandler implements RequestHandler { - private SparkLambdaContainerHandler handler = - SparkLambdaContainerHandler.getAwsProxyHandler(); - private boolean initialized = false; - - public AwsProxyResponse handleRequest(AwsProxyRequest awsProxyRequest, Context context) { - if (!initialized) { - defineRoutes(); - initialized = true; +public class StreamLambdaHandler implements RequestStreamHandler { + private static final SpringLambdaContainerHandler handler; + + static { + try { + handler = SpringLambdaContainerHandler.getAwsProxyHandler(PetStoreSpringAppConfig.class); + } catch (ContainerInitializationException e) { + // if we fail here. We re-throw the exception to force another cold start + e.printStackTrace(); + throw new RuntimeException("Could not initialize Spring framework", e); } - return handler.proxy(awsProxyRequest, context); } - - private void defineRoutes() { - get("/hello", (req, res) -> "Hello World"); - } -} -``` - -# Security context -The `aws-serverless-java-container-core` contains a default implementation of the `SecurityContextWriter` that supports API Gateway's proxy integration. The generated security context uses the API Gateway `$context` object to establish the request security context. The context looks for the following values in order and returns the first matched type: - -1. Cognito My User Pools -2. Custom authorizers -3. IAM auth. - -The String values for these are exposed as static variables in the `AwsProxySecurityContext` object. -1. `AUTH_SCHEME_COGNITO_POOL` -2. `AUTH_SCHEME_CUSTOM` -3. `AUTH_SCHEME_IAM` - -# Supporting other event types -The `RequestReader` and `ResponseWriter` interfaces in the core package can be used to support event types and generate different responses. For example, ff you have configured mapping templates in -API Gateway to create a custom event body or response you can create your own implementation of the `RequestReader` and `ResponseWriter` to handle these. - -The `LambdaContainerHandler` also requires a `SecurityContextWriter` and an `ExceptionHandler`. You can also create custom implementations of these interfaces. - -The `RequestReader`, `ResponseWriter`, `SecurityContextWriter`, and `ExceptionHandler` objects are passed to the constructor of the `LambdaContainerHandler` implementation: - -```java -JerseyLambdaContainerHandler handler = - new JerseyLambdaContainerHandler<>(new MyCustomRequestReader(), - new MyCustomResponseWriter(), - new MyCustomSecurityContextWriter(), - new MyCustomExceptionHandler(), - jaxRsApplication); -``` - -# Jersey Servlet injection -The `aws-serverless-java-container-jersey` includes Jersey factory classes to produce `HttpServletRequest` and `ServletContext` objects for your methods. First, you will need to register the factory with your Jersey application. - -```java -ResourceConfig app = new ResourceConfig() - .packages("com.amazonaws.serverless.proxy.test.jersey") - .register(new AbstractBinder() { - @Override - protected void configure() { - bindFactory(AwsProxyServletRequestFactory.class) - .to(HttpServletRequest.class) - .in(RequestScoped.class); - bindFactory(AwsProxyServletContextFactory.class) - .to(ServletContext.class) - .in(RequestScoped.class); - } - }); -``` - -Once the factory is registered, you can receive `HttpServletRequest` and `ServletContext` objects in your methods using the `@Context` annotation. - -```java -@Path("/my-servlet") @GET -public String echoServletHeaders(@Context HttpServletRequest context) { - Enumeration headerNames = context.getHeaderNames(); - while (headerNames.hasMoreElements()) { - String headerName = headerNames.nextElement(); + @Override + public void handleRequest(InputStream inputStream, OutputStream outputStream, Context context) + throws IOException { + handler.proxyStream(inputStream, outputStream, context); } - return "servlet"; } -``` +``` -## Servlet Filters -You can register [`Filter`](https://docs.oracle.com/javaee/7/api/javax/servlet/Filter.html) implementations by implementing a `StartupsHandler` as defined in the `AwsLambdaServletContainerHandler` class. The `onStartup` methods receives a reference to the current `ServletContext`. +## Public Examples -```java -handler.onStartup(c -> { - FilterRegistration.Dynamic registration = c.addFilter("CustomHeaderFilter", CustomHeaderFilter.class); - // update the registration to map to a path - registration.addMappingForUrlPatterns(EnumSet.of(DispatcherType.REQUEST), true, "/*"); - // servlet name mappings are disabled and will throw an exception -}); -``` +### Blogs -# Using the Lambda Stream handler -By default, Lambda does not use Jackson annotations when marshalling and unmarhsalling JSON. This can cause issues when receiving requests that include the claims object from a Cognito User Pool authorizer. To support these type of requests, use Lambda's `RequestStreamHandler` interface instead of the POJO-based `RequestHandler`. This allows you to use a custom version of Jackson with support for annotations. +- [Re-platforming Java applications using the updated AWS Serverless Java Container](https://aws.amazon.com/blogs/compute/re-platforming-java-applications-using-the-updated-aws-serverless-java-container/) -This library uses Jackson annotations in the `com.amazonaws.serverless.proxy.internal.model.CognitoAuthorizerClaims` object. The example below shows how to do this with a `SpringLambdaContainerHandler`, you can use the same methodology with all of the other implementations. +### Workshops -```java -public class StreamLambdaHandler implements RequestStreamHandler { - private SpringLambdaContainerHandler handler; - private static ObjectMapper mapper = new ObjectMapper(); +- [Java on AWS Lambda](https://catalog.workshops.aws/java-on-aws-lambda) From Serverful to Serverless Java with AWS Lambda in 2 hours - @Override - public void handleRequest(InputStream inputStream, OutputStream outputStream, Context context) - throws IOException { - if (handler == null) { - try { - handler = SpringLambdaContainerHandler.getAwsProxyHandler(PetStoreSpringAppConfig.class); - } catch (ContainerInitializationException e) { - e.printStackTrace(); - outputStream.close(); - } - } +### Videos - AwsProxyRequest request = mapper.readValue(inputStream, AwsProxyRequest.class); +- [Spring on AWS Lambda](https://www.youtube.com/watch?v=A1rYiHTy9Lg&list=PLCOG9xkUD90IDm9tcY-5nMK6X6g8SD-Sz) YouTube Playlist from [@plantpowerjames](https://twitter.com/plantpowerjames) - AwsProxyResponse resp = handler.proxy(request, context); +### Java samples with different frameworks - mapper.writeValue(outputStream, resp); - // just in case it wasn't closed by the mapper - outputStream.close(); - } -} -``` \ No newline at end of file +- [Dagger, Micronaut, Quarkus, Spring Boot](https://github.com/aws-samples/serverless-java-frameworks-samples/) diff --git a/aws-serverless-java-container-core/pom.xml b/aws-serverless-java-container-core/pom.xml index a2d09710..feb8f587 100644 --- a/aws-serverless-java-container-core/pom.xml +++ b/aws-serverless-java-container-core/pom.xml @@ -6,65 +6,162 @@ AWS Serverless Java container support - Core Allows Java applications written for a servlet container to run in AWS Lambda https://aws.amazon.com/lambda - 0.5-SNAPSHOT + 3.0.3-SNAPSHOT com.amazonaws.serverless aws-serverless-java-container - 0.5-SNAPSHOT + 3.0.3-SNAPSHOT + .. + + 3.1.0 + 6.1.0 + 3.0.2 + + - com.amazonaws aws-lambda-java-core - 1.1.0 + 1.4.0 - - javax.servlet - javax.servlet-api - 3.1.0 + jakarta.servlet + jakarta.servlet-api + ${servlet.version} - - javax.ws.rs - javax.ws.rs-api - 2.1-m01 + jakarta.ws.rs + jakarta.ws.rs-api + ${jaxrs.version} - - com.fasterxml.jackson.core + tools.jackson.core jackson-databind - 2.8.4 + ${jackson.version} - - commons-fileupload - commons-fileupload - 1.3.2 + tools.jackson.module + jackson-module-afterburner + ${jackson.version} - - junit - junit - 4.12 - test + org.apache.commons + commons-fileupload2-jakarta-servlet6 + 2.0.0-M4 - - org.mockito - mockito-all - 1.10.19 + org.springframework.security + spring-security-web + 6.5.1 test + + + + org.apache.maven.plugins + maven-jar-plugin + 3.4.2 + + + + test-jar + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + false + + + + org.jacoco + jacoco-maven-plugin + + ${basedir}/target/coverage-reports/jacoco-unit.exec + ${basedir}/target/coverage-reports/jacoco-unit.exec + + com/amazonaws/serverless/proxy/internal/testutils/** + + + + + default-prepare-agent + + prepare-agent + + + + jacoco-site + package + + report + + + + jacoco-check + test + + check + + + true + + BUNDLE + + + INSTRUCTION + COVEREDRATIO + ${jacoco.minCoverage} + + + + + + + + + com.github.spotbugs + spotbugs-maven-plugin + + + + analyze-compile + compile + + check + + + + + + org.owasp + dependency-check-maven + ${dependencyCheck.version} + + true + + ${project.basedir}/../owasp-suppression.xml + + 7 + false + + + + diff --git a/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/exceptions/ContainerInitializationException.java b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/exceptions/ContainerInitializationException.java index 3bacd3ea..c68023fd 100644 --- a/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/exceptions/ContainerInitializationException.java +++ b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/exceptions/ContainerInitializationException.java @@ -12,11 +12,15 @@ */ package com.amazonaws.serverless.exceptions; + +import com.amazonaws.serverless.proxy.RequestReader; + + /** * This exception is thrown when the ContainerHandler fails to parse a request object or input stream into the * object required by the Container. The exception is thrown by implementing sub-classes of RequestReader * - * @see com.amazonaws.serverless.proxy.internal.RequestReader + * @see RequestReader */ public class ContainerInitializationException extends Exception { public ContainerInitializationException(String message, Exception e) { diff --git a/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/exceptions/InvalidRequestEventException.java b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/exceptions/InvalidRequestEventException.java index f20a8e55..bf42df94 100644 --- a/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/exceptions/InvalidRequestEventException.java +++ b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/exceptions/InvalidRequestEventException.java @@ -12,14 +12,22 @@ */ package com.amazonaws.serverless.exceptions; + +import com.amazonaws.serverless.proxy.RequestReader; + + /** * This exception is thrown when the ContainerHandler fails to parse a request object or input stream into the * object required by the Container. The exception is thrown by implementing sub-classes of RequestReader * - * @see com.amazonaws.serverless.proxy.internal.RequestReader + * @see RequestReader */ public class InvalidRequestEventException extends Exception { public InvalidRequestEventException(String message, Exception e) { super(message, e); } + + public InvalidRequestEventException(String message) { + super(message); + } } diff --git a/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/exceptions/InvalidResponseObjectException.java b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/exceptions/InvalidResponseObjectException.java index 33189ad4..b3ac9c1e 100644 --- a/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/exceptions/InvalidResponseObjectException.java +++ b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/exceptions/InvalidResponseObjectException.java @@ -12,11 +12,15 @@ */ package com.amazonaws.serverless.exceptions; + +import com.amazonaws.serverless.proxy.ResponseWriter; + + /** * This exception is thrown when the ContainerHandler cannot transform the Container response into a valid return value * for the Lambda function. This exception is thrown by implementing sub-classes of ResponseWriter * - * @see com.amazonaws.serverless.proxy.internal.ResponseWriter + * @see ResponseWriter */ public class InvalidResponseObjectException extends Exception { public InvalidResponseObjectException(String message, Exception e) { diff --git a/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/AsyncInitializationWrapper.java b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/AsyncInitializationWrapper.java new file mode 100644 index 00000000..12e1590a --- /dev/null +++ b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/AsyncInitializationWrapper.java @@ -0,0 +1,142 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance + * with the License. A copy of the License is located at + * + * http://aws.amazon.com/apache2.0/ + * + * or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES + * OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions + * and limitations under the License. + */ +package com.amazonaws.serverless.proxy; + +import com.amazonaws.serverless.exceptions.ContainerInitializationException; +import com.amazonaws.serverless.proxy.internal.InitializableLambdaContainerHandler; +import com.amazonaws.services.lambda.runtime.Context; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.lang.management.ManagementFactory; +import java.time.Instant; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +/** + * An async implementation of the InitializationWrapper interface. This initializer calls the + * {@link InitializableLambdaContainerHandler#initialize()} in a separate thread. Then uses a latch to wait for the maximum Lambda + * initialization time of 10 seconds, if the initialize method takes longer than 10 seconds to return, the + * {@link #start(InitializableLambdaContainerHandler)} returns control to the caller and lets the initialization thread continue in + * the background. The {@link com.amazonaws.serverless.proxy.internal.LambdaContainerHandler#proxy(Object, Context)} automatically waits for the latch of the + * initializer to be released. + * + * The constructor of this class expects an epoch long. This is meant to be as close as possible to the time the Lambda + * function actually started. In most cases, the first action in the constructor of the handler class should be to populate + * this long value ({@code Instant.now().toEpochMs();}). This class uses the value to estimate how much of the init 10 + * seconds has already been used up. + */ +public class AsyncInitializationWrapper extends InitializationWrapper { + + private static final int DEFAULT_INIT_GRACE_TIME_MS = 150; + private static final String INIT_GRACE_TIME_ENVIRONMENT_VARIABLE_NAME = "AWS_SERVERLESS_JAVA_CONTAINER_INIT_GRACE_TIME"; + private static final int INIT_GRACE_TIME_MS = Integer.parseInt(System.getenv().getOrDefault( + INIT_GRACE_TIME_ENVIRONMENT_VARIABLE_NAME, Integer.toString(DEFAULT_INIT_GRACE_TIME_MS))); + private static final int LAMBDA_MAX_INIT_TIME_MS = 10_000; + + private CountDownLatch initializationLatch; + private final long actualStartTime; + private final Logger log = LoggerFactory.getLogger(AsyncInitializationWrapper.class); + + + /** + * Creates a new instance of the async initializer. + * @param startTime The epoch ms start time of the Lambda function, this should be measured as close as possible to + * the initialization of the function. + */ + public AsyncInitializationWrapper(long startTime) { + actualStartTime = startTime; + } + + /** + * Creates a new instance of the async initializer using the actual JVM start time as the starting point to measure + * the 10 seconds timeout. + */ + public AsyncInitializationWrapper() { + actualStartTime = ManagementFactory.getRuntimeMXBean().getStartTime(); + } + + @Override + public void start(InitializableLambdaContainerHandler handler) throws ContainerInitializationException { + if (InitializationTypeHelper.isAsyncInitializationDisabled()){ + log.info("Async init disabled due to \"{}\" initialization", InitializationTypeHelper.getInitializationType()); + super.start(handler); + return; + } + initializationLatch = new CountDownLatch(1); + AsyncInitializer initializer = new AsyncInitializer(initializationLatch, handler); + Thread initThread = new Thread(initializer); + initThread.start(); + try { + long curTime = Instant.now().toEpochMilli(); + // account for the time it took to call the various constructors with the actual start time + a grace time + long awaitTime = (actualStartTime + LAMBDA_MAX_INIT_TIME_MS) - curTime - INIT_GRACE_TIME_MS; + log.info("Async initialization will wait for {}ms (init grace time is configured to {})", + awaitTime, INIT_GRACE_TIME_MS); + if (!initializationLatch.await(awaitTime, TimeUnit.MILLISECONDS)) { + log.info("Initialization took longer than " + LAMBDA_MAX_INIT_TIME_MS + ", setting new CountDownLatch and " + + "continuing in event handler"); + initializationLatch = new CountDownLatch(1); + initializer.replaceLatch(initializationLatch); + } + } catch (InterruptedException e) { + // at the moment we assume that this happened because of a timeout since the init thread calls System.exit + // when an exception is thrown. + throw new ContainerInitializationException("Container initialization interrupted", e); + } + } + + public long getActualStartTimeMs() { + return actualStartTime; + } + + @Override + public CountDownLatch getInitializationLatch() { + if (InitializationTypeHelper.isAsyncInitializationDisabled()){ + return super.getInitializationLatch(); + } + return initializationLatch; + } + + private static class AsyncInitializer implements Runnable { + private final InitializableLambdaContainerHandler handler; + private CountDownLatch initLatch; + private final Logger log = LoggerFactory.getLogger(AsyncInitializationWrapper.class); + + AsyncInitializer(CountDownLatch latch, InitializableLambdaContainerHandler h) { + initLatch = latch; + handler = h; + } + + synchronized void replaceLatch(CountDownLatch newLatch) { + initLatch = newLatch; + } + + @Override + @SuppressFBWarnings("DM_EXIT") + public void run() { + log.info("Starting async initializer"); + try { + handler.initialize(); + } catch (ContainerInitializationException e) { + log.error("Failed to initialize container handler", e); + // we cannot return the exception so we crash the whole kaboodle here + System.exit(1); + } + synchronized(this) { + initLatch.countDown(); + } + } + } +} diff --git a/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/AwsHttpApiV2SecurityContextWriter.java b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/AwsHttpApiV2SecurityContextWriter.java new file mode 100644 index 00000000..d4192141 --- /dev/null +++ b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/AwsHttpApiV2SecurityContextWriter.java @@ -0,0 +1,26 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance + * with the License. A copy of the License is located at + * + * http://aws.amazon.com/apache2.0/ + * + * or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES + * OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions + * and limitations under the License. + */ +package com.amazonaws.serverless.proxy; + +import com.amazonaws.serverless.proxy.internal.jaxrs.AwsHttpApiV2SecurityContext; +import com.amazonaws.serverless.proxy.model.HttpApiV2ProxyRequest; +import com.amazonaws.services.lambda.runtime.Context; + +import jakarta.ws.rs.core.SecurityContext; + +public class AwsHttpApiV2SecurityContextWriter implements SecurityContextWriter { + @Override + public SecurityContext writeSecurityContext(HttpApiV2ProxyRequest event, Context lambdaContext) { + return new AwsHttpApiV2SecurityContext(lambdaContext, event); + } +} diff --git a/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/AwsProxyExceptionHandler.java b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/AwsProxyExceptionHandler.java similarity index 55% rename from aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/AwsProxyExceptionHandler.java rename to aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/AwsProxyExceptionHandler.java index 5a2afef3..8e13bd79 100644 --- a/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/AwsProxyExceptionHandler.java +++ b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/AwsProxyExceptionHandler.java @@ -10,55 +10,60 @@ * OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions * and limitations under the License. */ -package com.amazonaws.serverless.proxy.internal; +package com.amazonaws.serverless.proxy; import com.amazonaws.serverless.exceptions.InvalidRequestEventException; -import com.amazonaws.serverless.proxy.internal.model.AwsProxyResponse; -import com.amazonaws.serverless.proxy.internal.model.ErrorModel; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; +import com.amazonaws.serverless.proxy.internal.LambdaContainerHandler; +import com.amazonaws.serverless.proxy.model.AwsProxyResponse; +import com.amazonaws.serverless.proxy.model.ErrorModel; +import com.amazonaws.serverless.proxy.model.Headers; -import javax.ws.rs.core.HttpHeaders; -import javax.ws.rs.core.MediaType; +import tools.jackson.core.JacksonException; +import jakarta.ws.rs.core.Response; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import jakarta.ws.rs.InternalServerErrorException; +import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.core.MediaType; import java.io.IOException; import java.io.OutputStream; -import java.util.HashMap; -import java.util.Map; /** * Default implementation of the ExceptionHandler object that returns AwsProxyResponse objects. * - * Returns application/json messages with a status code of 500 when the RequestReader failed to read the incoming event. + * Returns application/json messages with a status code of 500 when the RequestReader failed to read the incoming event + * or if InternalServerErrorException is thrown. * For all other exceptions returns a 502. Responses are populated with a JSON object containing a message property. * - * @see com.amazonaws.serverless.proxy.internal.ExceptionHandler + * @see ExceptionHandler */ public class AwsProxyExceptionHandler implements ExceptionHandler { + private Logger log = LoggerFactory.getLogger(AwsProxyExceptionHandler.class); + //------------------------------------------------------------- // Constants //------------------------------------------------------------- - static final String INTERNAL_SERVER_ERROR = "Internal Server Error"; - static final String GATEWAY_TIMEOUT_ERROR = "Gateway timeout"; + static final String INTERNAL_SERVER_ERROR = Response.Status.INTERNAL_SERVER_ERROR.getReasonPhrase(); + static final String GATEWAY_TIMEOUT_ERROR = Response.Status.GATEWAY_TIMEOUT.getReasonPhrase(); //------------------------------------------------------------- // Variables - Private - Static //------------------------------------------------------------- - private static Map headers = new HashMap<>(); - private static ObjectMapper objectMapper = new ObjectMapper(); - + protected static final Headers HEADERS = new Headers(); //------------------------------------------------------------- // Constructors //------------------------------------------------------------- static { - headers.put(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON); + HEADERS.putSingle(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON); } @@ -69,10 +74,15 @@ public class AwsProxyExceptionHandler @Override public AwsProxyResponse handle(Throwable ex) { - if (ex instanceof InvalidRequestEventException) { - return new AwsProxyResponse(500, headers, getErrorJson(INTERNAL_SERVER_ERROR)); + log.error("Called exception handler for:", ex); + + // adding a print stack trace in case we have no appender or we are running inside SAM local, where need the + // output to go to the stderr. + ex.printStackTrace(); + if (ex instanceof InvalidRequestEventException || ex instanceof InternalServerErrorException) { + return new AwsProxyResponse(500, HEADERS, getErrorJson(INTERNAL_SERVER_ERROR)); } else { - return new AwsProxyResponse(502, headers, getErrorJson(GATEWAY_TIMEOUT_ERROR)); + return new AwsProxyResponse(502, HEADERS, getErrorJson(GATEWAY_TIMEOUT_ERROR)); } } @@ -81,7 +91,7 @@ public AwsProxyResponse handle(Throwable ex) { public void handle(Throwable ex, OutputStream stream) throws IOException { AwsProxyResponse response = handle(ex); - objectMapper.writeValue(stream, response); + LambdaContainerHandler.getObjectMapper().writeValue(stream, response); } @@ -89,11 +99,12 @@ public void handle(Throwable ex, OutputStream stream) throws IOException { // Methods - Protected //------------------------------------------------------------- - String getErrorJson(String message) { + protected String getErrorJson(String message) { + try { - return objectMapper.writeValueAsString(new ErrorModel(message)); - } catch (JsonProcessingException e) { - e.printStackTrace(); + return LambdaContainerHandler.getObjectMapper().writeValueAsString(new ErrorModel(message)); + } catch (JacksonException e) { + log.error("Could not produce error JSON", e); return "{ \"message\": \"" + message + "\" }"; } } diff --git a/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/AwsProxySecurityContextWriter.java b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/AwsProxySecurityContextWriter.java similarity index 77% rename from aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/AwsProxySecurityContextWriter.java rename to aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/AwsProxySecurityContextWriter.java index 839658ff..8a58bc47 100644 --- a/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/AwsProxySecurityContextWriter.java +++ b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/AwsProxySecurityContextWriter.java @@ -10,16 +10,16 @@ * OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions * and limitations under the License. */ -package com.amazonaws.serverless.proxy.internal; +package com.amazonaws.serverless.proxy; import com.amazonaws.serverless.proxy.internal.jaxrs.AwsProxySecurityContext; -import com.amazonaws.serverless.proxy.internal.model.AwsProxyRequest; +import com.amazonaws.serverless.proxy.model.AwsProxyRequest; import com.amazonaws.services.lambda.runtime.Context; -import javax.ws.rs.core.SecurityContext; +import jakarta.ws.rs.core.SecurityContext; /** - * Default impolementation of SecurityContextWriter. Creates a SecurityContext object based on an API Gateway + * Default implementation of SecurityContextWriter. Creates a SecurityContext object based on an API Gateway * event and the Lambda context. This returns the default AwsProxySecurityContext instance. */ public class AwsProxySecurityContextWriter implements SecurityContextWriter { @@ -28,15 +28,16 @@ public class AwsProxySecurityContextWriter implements SecurityContextWriterhandle method for requests that are being proxied using a + * The interface declares two methods. A typed handle method for requests that are being proxied using a * request and response type LambdaContainerHandler, and a stream-based * handle method for * Lambda's RequestStreamHandler. diff --git a/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/InitializationTypeHelper.java b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/InitializationTypeHelper.java new file mode 100644 index 00000000..c40c5ecc --- /dev/null +++ b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/InitializationTypeHelper.java @@ -0,0 +1,33 @@ +/* + * Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance + * with the License. A copy of the License is located at + * + * http://aws.amazon.com/apache2.0/ + * + * or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES + * OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions + * and limitations under the License. + */ +package com.amazonaws.serverless.proxy; + +/** + * Utility class that helps determine the initialization type + */ +public final class InitializationTypeHelper { + + private static final String INITIALIZATION_TYPE_ENVIRONMENT_VARIABLE_NAME = "AWS_LAMBDA_INITIALIZATION_TYPE"; + private static final String INITIALIZATION_TYPE_ON_DEMAND = "on-demand"; + private static final String INITIALIZATION_TYPE = System.getenv().getOrDefault(INITIALIZATION_TYPE_ENVIRONMENT_VARIABLE_NAME, + INITIALIZATION_TYPE_ON_DEMAND); + private static final boolean ASYNC_INIT_DISABLED = !INITIALIZATION_TYPE.equals(INITIALIZATION_TYPE_ON_DEMAND); + + public static boolean isAsyncInitializationDisabled() { + return ASYNC_INIT_DISABLED; + } + + public static String getInitializationType() { + return INITIALIZATION_TYPE; + } +} diff --git a/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/InitializationWrapper.java b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/InitializationWrapper.java new file mode 100644 index 00000000..a261b6cc --- /dev/null +++ b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/InitializationWrapper.java @@ -0,0 +1,46 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance + * with the License. A copy of the License is located at + * + * http://aws.amazon.com/apache2.0/ + * + * or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES + * OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions + * and limitations under the License. + */ +package com.amazonaws.serverless.proxy; + +import com.amazonaws.serverless.exceptions.ContainerInitializationException; +import com.amazonaws.serverless.proxy.internal.InitializableLambdaContainerHandler; + +import java.util.concurrent.CountDownLatch; + +/** + * This class is in charge of initializing a {@link InitializableLambdaContainerHandler}. + * In most cases, this means calling the {@link InitializableLambdaContainerHandler#initialize()} method. Some implementations may + * require additional initialization steps, in this case implementations should provide their own + * InitializationWrapper. This library includes an async implementation of this class + * {@link AsyncInitializationWrapper} for frameworks that are likely to take longer than 10 seconds to start. + */ +public class InitializationWrapper { + /** + * This is the main entry point. Container handler builder and the static getAwsProxyHandler() methods + * of the various implementations will call this to initialize the underlying framework + * @param handler The container handler to be initializer + * @throws ContainerInitializationException If anything goes wrong during container initialization. + */ + public void start(InitializableLambdaContainerHandler handler) throws ContainerInitializationException { + handler.initialize(); + } + + /** + * Asynchronous implementations of the framework should return a latch that the container handler can use to decide + * whether it can start handling events. Synchronous implementations of this interface should return null. + * @return An initialized latch if the underlying container is starting in a separate thread, null otherwise. + */ + public CountDownLatch getInitializationLatch() { + return null; + } +} diff --git a/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/LogFormatter.java b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/LogFormatter.java new file mode 100644 index 00000000..8bc75ca9 --- /dev/null +++ b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/LogFormatter.java @@ -0,0 +1,34 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance + * with the License. A copy of the License is located at + * + * http://aws.amazon.com/apache2.0/ + * + * or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES + * OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions + * and limitations under the License. + */ +package com.amazonaws.serverless.proxy; + +import jakarta.ws.rs.core.SecurityContext; + +/** + * Implementations of the log formatter interface are used by {@link com.amazonaws.serverless.proxy.internal.LambdaContainerHandler} class to log each request + * processed in the container. You can set the log formatter using the {@link com.amazonaws.serverless.proxy.internal.LambdaContainerHandler#setLogFormatter(LogFormatter)} + * method. The servlet implementation of the container ({@link com.amazonaws.serverless.proxy.internal.servlet.AwsLambdaServletContainerHandler} includes a + * default log formatter that produces Apache combined logs. {@link com.amazonaws.serverless.proxy.internal.servlet.ApacheCombinedServletLogFormatter}. + * @param The request type used by the underlying framework + * @param The response type produced by the underlying framework + */ +public interface LogFormatter { + /** + * The format method is called by the container handler to produce the log line that should be written to the logs. + * @param req The incoming request + * @param res The completed response + * @param ctx The security context produced based on the request + * @return The log line + */ + String format(ContainerRequestType req, ContainerResponseType res, SecurityContext ctx); +} diff --git a/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/RequestReader.java b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/RequestReader.java similarity index 51% rename from aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/RequestReader.java rename to aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/RequestReader.java index 973f6f98..d8293d64 100644 --- a/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/RequestReader.java +++ b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/RequestReader.java @@ -10,18 +10,14 @@ * OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions * and limitations under the License. */ -package com.amazonaws.serverless.proxy.internal; +package com.amazonaws.serverless.proxy; import com.amazonaws.serverless.exceptions.InvalidRequestEventException; +import com.amazonaws.serverless.proxy.model.ContainerConfig; import com.amazonaws.services.lambda.runtime.Context; -import com.fasterxml.jackson.databind.ObjectMapper; - -import javax.ws.rs.core.SecurityContext; - -import java.io.IOException; -import java.io.InputStream; +import jakarta.ws.rs.core.SecurityContext; /** @@ -31,7 +27,7 @@ * object that supports requests for the AWS_PROXY integration. * * @param The type for the AWS Lambda event - * @param The type for the undelying container request object + * @param The type for the underlying container request object */ public abstract class RequestReader { @@ -49,11 +45,40 @@ public abstract class RequestReader { */ public static final String API_GATEWAY_STAGE_VARS_PROPERTY = "com.amazonaws.apigateway.stage.variables"; + /** + * The key for the ALB context property in the PropertiesDelegate object + */ + public static final String ALB_CONTEXT_PROPERTY = "com.amazonaws.alb.request.context"; + + /** + * The key to store the entire API Gateway event + */ + public static final String API_GATEWAY_EVENT_PROPERTY = "com.amazonaws.apigateway.request"; + /** * The key for the AWS Lambda context property in the PropertiesDelegate object */ public static final String LAMBDA_CONTEXT_PROPERTY = "com.amazonaws.lambda.context"; + /** + * The key for the JAX RS security context properties stored in the request attributes + */ + public static final String JAX_SECURITY_CONTEXT_PROPERTY = "com.amazonaws.serverless.jaxrs.securityContext"; + + /** + * The key for the HTTP API request context passed by the services + */ + public static final String HTTP_API_CONTEXT_PROPERTY = "com.amazonaws.httpapi.request.context"; + + /** + * The key for the HTTP API stage variables + */ + public static final String HTTP_API_STAGE_VARS_PROPERTY = "com.amazonaws.httpapi.stage.variables"; + + /** + * The key for the HTTP API proxy request event + */ + public static final String HTTP_API_EVENT_PROPERTY = "com.amazonaws.httpapi.request"; //------------------------------------------------------------- // Methods - Abstract @@ -62,14 +87,42 @@ public abstract class RequestReader { /** * Reads the incoming event object and produces a populated request for the underlying container * @param request The incoming request object - * @param securityContext A jax-rs SecurityContext object (@see com.amazonaws.serverless.proxy.internal.SecurityContextWriter) + * @param securityContext A jax-rs SecurityContext object (@see com.amazonaws.serverless.proxy.SecurityContextWriter) * @param lambdaContext The AWS Lambda context for the request + * @param config The container configuration object. This is passed in by the LambdaContainerHandler. * @return A valid request object for the underlying container * @throws InvalidRequestEventException This exception is thrown if anything goes wrong during the creation of the request object */ - protected abstract ContainerRequestType readRequest(RequestType request, SecurityContext securityContext, Context lambdaContext) + public abstract ContainerRequestType readRequest(RequestType request, SecurityContext securityContext, Context lambdaContext, ContainerConfig config) throws InvalidRequestEventException; protected abstract Class getRequestClass(); + + + //------------------------------------------------------------- + // Methods - Protected + //------------------------------------------------------------- + + /** + * Strips the base path from the request path if the container configuration object requires it + * @param requestPath The incoming request path + * @param config The container configuration object + * @return The final request path + */ + protected String stripBasePath(String requestPath, ContainerConfig config) { + if (!config.isStripBasePath()) { + return requestPath; + } + + if (requestPath.startsWith(config.getServiceBasePath())) { + String newRequestPath = requestPath.replaceFirst(config.getServiceBasePath(), ""); + if (!newRequestPath.startsWith("/")) { + newRequestPath = "/" + newRequestPath; + } + return newRequestPath; + } + + return requestPath; + } } diff --git a/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/ResponseWriter.java b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/ResponseWriter.java similarity index 90% rename from aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/ResponseWriter.java rename to aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/ResponseWriter.java index dd9ce225..6984af2f 100644 --- a/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/ResponseWriter.java +++ b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/ResponseWriter.java @@ -10,16 +10,13 @@ * OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions * and limitations under the License. */ -package com.amazonaws.serverless.proxy.internal; +package com.amazonaws.serverless.proxy; import com.amazonaws.serverless.exceptions.InvalidResponseObjectException; import com.amazonaws.services.lambda.runtime.Context; -import com.fasterxml.jackson.databind.ObjectMapper; - -import java.io.IOException; -import java.io.OutputStream; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; /** @@ -28,7 +25,7 @@ * or a ResponseReader implementation. For example, the Jersey library passes the response reader object to * the default implementation of this class. * - * @param The response object expceted from the underlying container + * @param The response object expected from the underlying container * @param The type for the Lambda function return value */ public abstract class ResponseWriter { @@ -44,7 +41,7 @@ public abstract class ResponseWriter { * @return A valid return value for the Lambda function * @throws InvalidResponseObjectException When the implementation cannot read the container response object */ - protected abstract ResponseType writeResponse(ContainerResponseType containerResponse, Context lambdaContext) + public abstract ResponseType writeResponse(ContainerResponseType containerResponse, Context lambdaContext) throws InvalidResponseObjectException; /** @@ -52,6 +49,7 @@ protected abstract ResponseType writeResponse(ContainerResponseType containerRes * @param input The byte[] to check against * @return true if the contend is valid UTF-8, false otherwise */ + @SuppressFBWarnings("NS_NON_SHORT_CIRCUIT") protected boolean isValidUtf8(final byte[] input) { int i = 0; // Check for BOM diff --git a/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/SecurityContextWriter.java b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/SecurityContextWriter.java similarity index 90% rename from aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/SecurityContextWriter.java rename to aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/SecurityContextWriter.java index 23e6e508..27a4c6c7 100644 --- a/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/SecurityContextWriter.java +++ b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/SecurityContextWriter.java @@ -10,12 +10,12 @@ * OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions * and limitations under the License. */ -package com.amazonaws.serverless.proxy.internal; +package com.amazonaws.serverless.proxy; -import com.amazonaws.serverless.proxy.internal.model.AwsProxyRequest; +import com.amazonaws.serverless.proxy.model.AwsProxyRequest; import com.amazonaws.services.lambda.runtime.Context; -import javax.ws.rs.core.SecurityContext; +import jakarta.ws.rs.core.SecurityContext; /** * This object is used by the container implementation to generated a Jax-Rs SecurityContext object from the diff --git a/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/HttpUtils.java b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/HttpUtils.java new file mode 100644 index 00000000..f6eba415 --- /dev/null +++ b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/HttpUtils.java @@ -0,0 +1,68 @@ +package com.amazonaws.serverless.proxy.internal; + +import org.apache.commons.io.Charsets; + +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.nio.charset.UnsupportedCharsetException; + +public final class HttpUtils { + + static final String HEADER_KEY_VALUE_SEPARATOR = "="; + static final String HEADER_VALUE_SEPARATOR = ";"; + static final String ENCODING_VALUE_KEY = "charset"; + + + static public Charset parseCharacterEncoding(String contentTypeHeader,Charset defaultCharset) { + // we only look at content-type because content-encoding should only be used for + // "binary" requests such as gzip/deflate. + if (contentTypeHeader == null) { + return defaultCharset; + } + + String[] contentTypeValues = contentTypeHeader.split(HEADER_VALUE_SEPARATOR); + if (contentTypeValues.length <= 1) { + return defaultCharset; + } + + for (String contentTypeValue : contentTypeValues) { + if (contentTypeValue.trim().startsWith(ENCODING_VALUE_KEY)) { + String[] encodingValues = contentTypeValue.split(HEADER_KEY_VALUE_SEPARATOR); + if (encodingValues.length <= 1) { + return defaultCharset; + } + try { + return Charsets.toCharset(encodingValues[1]); + } catch (UnsupportedCharsetException ex) { + return defaultCharset; + } + } + } + return defaultCharset; + } + + + static public String appendCharacterEncoding(String currentContentType, String newEncoding) { + if (currentContentType == null || currentContentType.trim().isEmpty()) { + return null; + } + + if (currentContentType.contains(HEADER_VALUE_SEPARATOR)) { + String[] contentTypeValues = currentContentType.split(HEADER_VALUE_SEPARATOR); + StringBuilder contentType = new StringBuilder(contentTypeValues[0]); + + for (int i = 1; i < contentTypeValues.length; i++) { + String contentTypeValue = contentTypeValues[i]; + String contentTypeString = HEADER_VALUE_SEPARATOR + " " + contentTypeValue; + if (contentTypeValue.trim().startsWith(ENCODING_VALUE_KEY)) { + contentTypeString = HEADER_VALUE_SEPARATOR + " " + ENCODING_VALUE_KEY + HEADER_KEY_VALUE_SEPARATOR + newEncoding; + } + contentType.append(contentTypeString); + } + + return contentType.toString(); + } else { + return currentContentType + HEADER_VALUE_SEPARATOR + " " + ENCODING_VALUE_KEY + HEADER_KEY_VALUE_SEPARATOR + newEncoding; + } + } +} diff --git a/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/InitializableLambdaContainerHandler.java b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/InitializableLambdaContainerHandler.java new file mode 100644 index 00000000..daf4a69d --- /dev/null +++ b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/InitializableLambdaContainerHandler.java @@ -0,0 +1,31 @@ +/* + * Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance + * with the License. A copy of the License is located at + * + * http://aws.amazon.com/apache2.0/ + * + * or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES + * OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions + * and limitations under the License. + */ +package com.amazonaws.serverless.proxy.internal; + +import com.amazonaws.serverless.exceptions.ContainerInitializationException; + +/** + * Interface to define initialization/ cold-start related methods. + * See also the documentation for + * + * AWS Lambda Execution Environments. + */ +public interface InitializableLambdaContainerHandler { + + /** + * This method is called on the first (cold) invocation + * + * @throws ContainerInitializationException in case initialization fails + */ + void initialize() throws ContainerInitializationException; +} diff --git a/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/LambdaContainerHandler.java b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/LambdaContainerHandler.java index bff64b9a..86d4216a 100644 --- a/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/LambdaContainerHandler.java +++ b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/LambdaContainerHandler.java @@ -13,11 +13,26 @@ package com.amazonaws.serverless.proxy.internal; +import com.amazonaws.serverless.exceptions.ContainerInitializationException; +import com.amazonaws.serverless.proxy.*; +import com.amazonaws.serverless.proxy.internal.servlet.ApacheCombinedServletLogFormatter; +import com.amazonaws.serverless.proxy.model.ContainerConfig; import com.amazonaws.services.lambda.runtime.Context; -import javax.ws.rs.core.SecurityContext; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.ObjectReader; +import tools.jackson.databind.ObjectWriter; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import jakarta.ws.rs.core.SecurityContext; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; /** @@ -28,7 +43,8 @@ * @param The request type for the wrapped Java container * @param The response or response writer type for the wrapped Java container */ -public abstract class LambdaContainerHandler { +public abstract class LambdaContainerHandler + implements InitializableLambdaContainerHandler { //------------------------------------------------------------- // Constants @@ -36,6 +52,7 @@ public abstract class LambdaContainerHandler responseWriter; private SecurityContextWriter securityContextWriter; private ExceptionHandler exceptionHandler; + private Class requestTypeClass; + private Class responseTypeClass; + private InitializationWrapper initializationWrapper; + + protected Context lambdaContext; + private LogFormatter logFormatter; + + private Logger log = LoggerFactory.getLogger(LambdaContainerHandler.class); + + private ObjectReader objectReader; + private ObjectWriter objectWriter; + + //------------------------------------------------------------- + // Variables - Private - Static + //------------------------------------------------------------- + + private static ContainerConfig config = ContainerConfig.defaultConfig(); + private static ObjectMapper objectMapper = new ObjectMapper(); + static { + registerAfterBurner(); + } + private static void registerAfterBurner() { + // AfterburnerModule is built-in in Jackson 3, no need to register + } //------------------------------------------------------------- // Constructors //------------------------------------------------------------- - protected LambdaContainerHandler(RequestReader requestReader, + protected LambdaContainerHandler(Class requestClass, + Class responseClass, + RequestReader requestReader, ResponseWriter responseWriter, SecurityContextWriter securityContextWriter, - ExceptionHandler exceptionHandler) { + ExceptionHandler exceptionHandler, + InitializationWrapper init) { + log.info("Starting Lambda Container Handler"); + requestTypeClass = requestClass; + responseTypeClass = responseClass; this.requestReader = requestReader; this.responseWriter = responseWriter; this.securityContextWriter = securityContextWriter; this.exceptionHandler = exceptionHandler; + initializationWrapper = init; + objectReader = getObjectMapper().readerFor(requestTypeClass); + objectWriter = getObjectMapper().writerFor(responseTypeClass); + + } + + protected LambdaContainerHandler(Class requestClass, + Class responseClass, + RequestReader requestReader, + ResponseWriter responseWriter, + SecurityContextWriter securityContextWriter, + ExceptionHandler exceptionHandler) { + this(requestClass, responseClass, requestReader, responseWriter, securityContextWriter, exceptionHandler, new InitializationWrapper()); } @@ -65,17 +125,67 @@ protected LambdaContainerHandler(RequestReader formatter) { + this.logFormatter = formatter; + } + + /** * Proxies requests to the underlying container given the incoming Lambda request. This method returns a populated * return object for the Lambda function. @@ -85,26 +195,85 @@ protected abstract void handleRequest(ContainerRequestType containerRequest, Con * @return A valid response type */ public ResponseType proxy(RequestType request, Context context) { + lambdaContext = context; + CountDownLatch latch = new CountDownLatch(1); try { SecurityContext securityContext = securityContextWriter.writeSecurityContext(request, context); - CountDownLatch latch = new CountDownLatch(1); - ContainerResponseType containerResponse = getContainerResponse(latch); - ContainerRequestType containerRequest = requestReader.readRequest(request, securityContext, context); + ContainerRequestType containerRequest = requestReader.readRequest(request, securityContext, context, config); + ContainerResponseType containerResponse = getContainerResponse(containerRequest, latch); + + if (initializationWrapper != null && initializationWrapper.getInitializationLatch() != null) { + // we let the potential InterruptedException bubble up + if (!initializationWrapper.getInitializationLatch().await(config.getInitializationTimeout(), TimeUnit.MILLISECONDS)) { + throw new ContainerInitializationException("Could not initialize framework within the " + config.getInitializationTimeout() + "ms timeout", null); + } + } handleRequest(containerRequest, containerResponse, context); latch.await(); + if (logFormatter != null) { + log.info(SecurityUtils.crlf(logFormatter.format(containerRequest, containerResponse, securityContext))); + } + return responseWriter.writeResponse(containerResponse, context); } catch (Exception e) { - context.getLogger().log("Error while handling request: " + e.getMessage()); + log.error("Error while handling request", e); + // release all waiting threads. This is safe here because if the count was already 0 + // the latch will do nothing + latch.countDown(); + + if (getContainerConfig().isDisableExceptionMapper()) { + if (e instanceof RuntimeException) { + throw (RuntimeException) e; + } else { + throw new RuntimeException(e); + } + } else { + return exceptionHandler.handle(e); + } + } + } + - /*for (StackTraceElement el : e.getStackTrace()) { - context.getLogger().log(el.toString()); - }*/ - e.printStackTrace(); + /** + * Handles Lambda RequestStreamHandler method. The method uses an ObjectMapper + * to transform the incoming input stream into the given {@link RequestType} and then calls the + * {@link #proxy(Object, Context)} method to handle the request. The output from the proxy method is + * written on the given output stream. + * @param input Lambda's incoming input stream + * @param output Lambda's response output stream + * @param context Lambda's context object + * @throws IOException If an error occurs during the stream processing + */ + public void proxyStream(InputStream input, OutputStream output, Context context) + throws IOException { - return exceptionHandler.handle(e); + try { + RequestType request = objectReader.readValue(input); + ResponseType resp = proxy(request, context); + + objectWriter.writeValue(output, resp); + } catch (JacksonException e) { + log.error("Error while parsing request object stream", e); + getObjectMapper().writeValue(output, exceptionHandler.handle(e)); + } finally { + output.flush(); + output.close(); } } + + + //------------------------------------------------------------- + // Methods - Getter/Setter + //------------------------------------------------------------- + + /** + * Returns the current container configuration object. + * @return The container configuration object + */ + public static ContainerConfig getContainerConfig() { + return config; + } } diff --git a/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/SecurityUtils.java b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/SecurityUtils.java new file mode 100644 index 00000000..3f52f895 --- /dev/null +++ b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/SecurityUtils.java @@ -0,0 +1,232 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance + * with the License. A copy of the License is located at + * + * http://aws.amazon.com/apache2.0/ + * + * or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES + * OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions + * and limitations under the License. + */ +package com.amazonaws.serverless.proxy.internal; + +import com.amazonaws.serverless.proxy.model.AlbContext; +import com.amazonaws.serverless.proxy.model.AwsProxyRequestContext; +import com.amazonaws.serverless.proxy.model.ContainerConfig; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.File; +import java.io.IOException; +import java.util.HashSet; +import java.util.Locale; +import java.util.Objects; +import java.util.Set; + +/** + * This class contains utility methods to address FSB security issues found in the application, such as string sanitization + * and file path validation. + */ +public final class SecurityUtils { + private static Logger log = LoggerFactory.getLogger(SecurityUtils.class); + + private static Set SCHEMES = new HashSet() {{ + add("http"); + add("https"); + add("HTTP"); + add("HTTPS"); + }}; + + private static Set PORTS = new HashSet() {{ + add(443); + add(80); + add(3000); // we allow port 3000 for SAM local + }}; + + public static boolean isValidPort(String port) { + if (port == null) { + return false; + } + try { + int intPort = Integer.parseInt(port); + return PORTS.contains(intPort); + } catch (NumberFormatException e) { + log.error("Invalid port parameter: " + crlf(port)); + return false; + } + } + + public static boolean isValidScheme(String scheme) { + return SCHEMES.contains(scheme); + } + + public static boolean isValidHost(String host, String apiId, AlbContext elb, String region) { + if (host == null) { + return false; + } + if (!Objects.isNull(elb)) { + String albhost = new StringBuilder().append(region) + .append(".elb.amazonaws.com").toString(); + return host.endsWith(albhost) || LambdaContainerHandler.getContainerConfig().getCustomDomainNames().contains(host); + } else if (host.endsWith(".amazonaws.com")) { + String defaultHost = new StringBuilder().append(apiId) + .append(".execute-api.") + .append(region) + .append(".amazonaws.com").toString(); + return host.equals(defaultHost); + } else { + return LambdaContainerHandler.getContainerConfig().getCustomDomainNames().contains(host); + } + } + + /** + * Replaces CRLF characters in a string with empty string (""). + * @param s The string to be cleaned + * @return A copy of the original string without CRLF characters + */ + public static String crlf(String s) { + if (s == null) { + return null; + } + return s.replaceAll("[\r\n]", ""); + } + + + /** + * Escapes all special characters in a java string + * @param s The string to be cleaned + * @return The escaped string + */ + public static String encode(String s) { + if (s == null) { + return null; + } + + int sz = s.length(); + + StringBuffer buffer = new StringBuffer(); + for (int i = 0; i < sz; i++) { + char ch = s.charAt(i); + + // handle unicode + if (ch > 0xfff) { + buffer.append("\\u" + Integer.toHexString(ch).toUpperCase(Locale.ENGLISH)); + } else if (ch > 0xff) { + buffer.append("\\u0" + Integer.toHexString(ch).toUpperCase(Locale.ENGLISH)); + } else if (ch > 0x7f) { + buffer.append("\\u00" + Integer.toHexString(ch).toUpperCase(Locale.ENGLISH)); + } else if (ch < 32) { + switch (ch) { + case '\b': + buffer.append('\\'); + buffer.append('b'); + break; + case '\n': + buffer.append('\\'); + buffer.append('n'); + break; + case '\t': + buffer.append('\\'); + buffer.append('t'); + break; + case '\f': + buffer.append('\\'); + buffer.append('f'); + break; + case '\r': + buffer.append('\\'); + buffer.append('r'); + break; + default: + if (ch > 0xf) { + buffer.append("\\u00" + Integer.toHexString(ch).toUpperCase(Locale.ENGLISH)); + } else { + buffer.append("\\u000" + Integer.toHexString(ch).toUpperCase(Locale.ENGLISH)); + } + break; + } + } else { + switch (ch) { + case '\'': + + buffer.append('\''); + break; + case '"': + buffer.append('\\'); + buffer.append('"'); + break; + case '\\': + buffer.append('\\'); + buffer.append('\\'); + break; + case '/': + buffer.append('/'); + break; + default: + buffer.append(ch); + break; + } + } + } + + return buffer.toString(); + } + + public static String getValidFilePath(String inputPath) { + return getValidFilePath(inputPath, false); + } + + /** + * Returns an absolute file path given an input path and validates that it is not trying + * to write/read from a directory other than /tmp. + * + * We suppress the path traversal warnings because this method is used to validate paths passed + * to the servlet implementation methods such as {@link com.amazonaws.serverless.proxy.internal.servlet.AwsProxyRequestPart#write(String)}. + * Using relative paths is a valid use-case for developers implementing a servlet-based API. We + * mitigate the potential partial path traversal by checking the resulting absolute path against + * the list of allowed paths specified in {@link ContainerConfig#getValidFilePaths()}. We also + * block the /var/task directory regardless. + * + * @param inputPath The input path + * @return The absolute path to the file + * @throws IllegalArgumentException If the given path is not valid or outside of /tmp + */ + @SuppressFBWarnings("PATH_TRAVERSAL_IN") + public static String getValidFilePath(final String inputPath, boolean isWrite) { + if (inputPath == null || "".equals(inputPath.trim())) { + return null; + } + String testInputPath = inputPath; + if (testInputPath.startsWith("file://")) { + testInputPath = testInputPath.substring(6); + } + + File f = new File(testInputPath); + try { + String canonicalPath = f.getCanonicalPath(); + + if (isWrite && canonicalPath.startsWith("/var/task")) { + throw new IllegalArgumentException("Trying to write to /var/task folder"); + } + + boolean isAllowed = false; + for (String allowedPath : LambdaContainerHandler.getContainerConfig().getValidFilePaths()) { + if (canonicalPath.startsWith(allowedPath)) { + isAllowed = true; + break; + } + } + if (!isAllowed) { + throw new IllegalArgumentException("File path not allowed: " + encode(canonicalPath)); + } + + return (inputPath.startsWith("file://") ? "file://" + canonicalPath : canonicalPath); + } catch (IOException e) { + log.error("Invalid file path: {}", encode(testInputPath)); + throw new IllegalArgumentException("Invalid file path", e); + } + } +} diff --git a/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/jaxrs/AwsHttpApiV2SecurityContext.java b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/jaxrs/AwsHttpApiV2SecurityContext.java new file mode 100644 index 00000000..6fff4db7 --- /dev/null +++ b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/jaxrs/AwsHttpApiV2SecurityContext.java @@ -0,0 +1,102 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance + * with the License. A copy of the License is located at + * + * http://aws.amazon.com/apache2.0/ + * + * or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES + * OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions + * and limitations under the License. + */ +package com.amazonaws.serverless.proxy.internal.jaxrs; + +import com.amazonaws.serverless.proxy.internal.LambdaContainerHandler; +import com.amazonaws.serverless.proxy.internal.SecurityUtils; +import com.amazonaws.serverless.proxy.model.HttpApiV2ProxyRequest; +import com.amazonaws.services.lambda.runtime.Context; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.JsonNode; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.core.SecurityContext; +import java.nio.charset.StandardCharsets; +import java.security.Principal; +import java.util.Base64; + +public class AwsHttpApiV2SecurityContext implements SecurityContext { + public static final String AUTH_SCHEME_JWT = "JWT"; + + private static Logger log = LoggerFactory.getLogger(AwsHttpApiV2SecurityContext.class); + + private Context lambdaContext; + private HttpApiV2ProxyRequest event; + + public AwsHttpApiV2SecurityContext(final Context lambdaCtx, final HttpApiV2ProxyRequest request) { + lambdaContext = lambdaCtx; + event = request; + } + + @Override + public Principal getUserPrincipal() { + if (getAuthenticationScheme() == null || !event.getHeaders().containsKey(HttpHeaders.AUTHORIZATION)) { + return null; + } + + String authValue = event.getHeaders().get(HttpHeaders.AUTHORIZATION); + if (authValue.startsWith("Bearer ")) { + authValue = authValue.replace("Bearer ", ""); + } + String[] parts = authValue.split("\\."); + if (parts.length != 3) { + log.warn("Could not parse JWT token for requestId: " + SecurityUtils.crlf(event.getRequestContext().getRequestId())); + return null; + } + String decodedBody = new String(Base64.getMimeDecoder().decode(parts[1]), StandardCharsets.UTF_8); + try { + JsonNode parsedBody = LambdaContainerHandler.getObjectMapper().readTree(decodedBody); + if (!parsedBody.isObject() && parsedBody.has("sub")) { + log.debug("Could not find \"sub\" field in JWT body for requestId: " + SecurityUtils.crlf(event.getRequestContext().getRequestId())); + return null; + } + String subject = parsedBody.get("sub").asText(); + return (() -> { + return subject; + }); + } catch (JacksonException e) { + log.error("Error while attempting to parse JWT body for requestId: " + SecurityUtils.crlf(event.getRequestContext().getRequestId()), e); + return null; + } + + } + + @Override + public boolean isUserInRole(String s) { + if (getAuthenticationScheme() == null) { + return false; + } + + return event.getRequestContext().getAuthorizer().getJwtAuthorizer().getScopes().contains(s) || + event.getRequestContext().getAuthorizer().getJwtAuthorizer().getClaims().containsKey(s); + + } + + @Override + public boolean isSecure() { + return getAuthenticationScheme() != null; + } + + @Override + public String getAuthenticationScheme() { + if (event.getRequestContext().getAuthorizer() == null) { + return null; + } + if (event.getRequestContext().getAuthorizer().isJwt()) { + return AUTH_SCHEME_JWT; + } + return null; + } +} diff --git a/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/jaxrs/AwsProxySecurityContext.java b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/jaxrs/AwsProxySecurityContext.java index c6698d86..83b3e85a 100644 --- a/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/jaxrs/AwsProxySecurityContext.java +++ b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/jaxrs/AwsProxySecurityContext.java @@ -12,10 +12,12 @@ */ package com.amazonaws.serverless.proxy.internal.jaxrs; -import com.amazonaws.serverless.proxy.internal.model.AwsProxyRequest; +import com.amazonaws.serverless.proxy.model.AwsProxyRequest; +import com.amazonaws.serverless.proxy.model.CognitoAuthorizerClaims; import com.amazonaws.services.lambda.runtime.Context; -import javax.ws.rs.core.SecurityContext; +import jakarta.ws.rs.core.SecurityContext; + import java.security.Principal; /** @@ -30,22 +32,34 @@ public class AwsProxySecurityContext implements SecurityContext { //------------------------------------------------------------- - // Constants - Private + // Constants - Package //------------------------------------------------------------- - private static final String AUTH_SCHEME_CUSTOM = "CUSTOM_AUTHORIZER"; - private static final String AUTH_SCHEME_COGNITO_POOL = "COGNITO_USER_POOL"; - private static final String AUTH_SCHEME_AWS_IAM = "AWS_IAM"; + static final String AUTH_SCHEME_CUSTOM = "CUSTOM_AUTHORIZER"; + static final String AUTH_SCHEME_COGNITO_POOL = "COGNITO_USER_POOL"; + static final String AUTH_SCHEME_AWS_IAM = "AWS_IAM"; + + static final String ALB_ACESS_TOKEN_HEADER = "x-amzn-oidc-accesstoken"; + static final String ALB_IDENTITY_HEADER = "x-amzn-oidc-identity"; //------------------------------------------------------------- // Variables - Private //------------------------------------------------------------- - protected Context lambdaContext; - protected AwsProxyRequest event; + private Context lambdaContext; + private AwsProxyRequest event; + public Context getLambdaContext() { + return lambdaContext; + } + + + public AwsProxyRequest getEvent() { + return event; + } + //------------------------------------------------------------- // Constructors //------------------------------------------------------------- @@ -60,44 +74,97 @@ public AwsProxySecurityContext(final Context lambdaContext, final AwsProxyReques // Implementation - SecurityContext //------------------------------------------------------------- + @Override public Principal getUserPrincipal() { - return () -> { - if (getAuthenticationScheme() == null) { + if (getAuthenticationScheme() == null) { + return () -> null; + } + + if (getAuthenticationScheme().equals(AUTH_SCHEME_CUSTOM) || getAuthenticationScheme().equals(AUTH_SCHEME_AWS_IAM)) { + return () -> { + if (getAuthenticationScheme().equals(AUTH_SCHEME_CUSTOM)) { + switch (event.getRequestSource()) { + case API_GATEWAY: + return event.getRequestContext().getAuthorizer().getPrincipalId(); + case ALB: + return event.getMultiValueHeaders().getLast(ALB_IDENTITY_HEADER); + } + } else if (getAuthenticationScheme().equals(AUTH_SCHEME_AWS_IAM)) { + // if we received credentials from Cognito Federated Identities then we return the identity id + if (event.getRequestContext().getIdentity().getCognitoIdentityId() != null) { + return event.getRequestContext().getIdentity().getCognitoIdentityId(); + } else { // otherwise the user arn from the credentials + return event.getRequestContext().getIdentity().getUserArn(); + } + } + + // return null if we couldn't find a valid scheme return null; - } + }; + } - if (getAuthenticationScheme().equals(AUTH_SCHEME_CUSTOM)) { - return event.getRequestContext().getAuthorizer().getPrincipalId(); - } else if (getAuthenticationScheme().equals(AUTH_SCHEME_AWS_IAM)) { - return event.getRequestContext().getIdentity().getUserArn(); - } else if (getAuthenticationScheme().equals(AUTH_SCHEME_COGNITO_POOL)) { - return event.getRequestContext().getAuthorizer().getClaims().getSubject(); - } + if (getAuthenticationScheme().equals(AUTH_SCHEME_COGNITO_POOL)) { + return new CognitoUserPoolPrincipal(event.getRequestContext().getAuthorizer().getClaims()); + } - return null; - }; + throw new RuntimeException("Cannot recognize authorization scheme in event"); } + @Override public boolean isUserInRole(String role) { return (role.equals(event.getRequestContext().getIdentity().getUserArn())); } + @Override public boolean isSecure() { return getAuthenticationScheme() != null; } + @Override public String getAuthenticationScheme() { - if (event.getRequestContext().getAuthorizer() != null && event.getRequestContext().getAuthorizer().getClaims() != null && event.getRequestContext().getAuthorizer().getClaims().getSubject() != null) { - return AUTH_SCHEME_COGNITO_POOL; - } else if (event.getRequestContext().getAuthorizer() != null) { - return AUTH_SCHEME_CUSTOM; - } else if (event.getRequestContext().getIdentity().getAccessKey() != null) { - return AUTH_SCHEME_AWS_IAM; - } else { - return null; + switch (event.getRequestSource()) { + case API_GATEWAY: + if (event.getRequestContext().getAuthorizer() != null && event.getRequestContext().getAuthorizer().getClaims() != null + && event.getRequestContext().getAuthorizer().getClaims().getSubject() != null) { + return AUTH_SCHEME_COGNITO_POOL; + } else if (event.getRequestContext().getAuthorizer() != null) { + return AUTH_SCHEME_CUSTOM; + } else if (event.getRequestContext().getIdentity().getAccessKey() != null) { + return AUTH_SCHEME_AWS_IAM; + } else { + return null; + } + case ALB: + if (event.getMultiValueHeaders().containsKey(ALB_ACESS_TOKEN_HEADER)) { + return AUTH_SCHEME_CUSTOM; + } + } + return null; + } + + + /** + * Custom object for request authorized with a Cognito User Pool authorizer. By casting the Principal + * object to this you can extract the Claims object included in the token. + */ + public static class CognitoUserPoolPrincipal implements Principal { + + private CognitoAuthorizerClaims claims; + + CognitoUserPoolPrincipal(CognitoAuthorizerClaims c) { + claims = c; + } + + @Override + public String getName() { + return claims.getSubject(); + } + + public CognitoAuthorizerClaims getClaims() { + return claims; } } } diff --git a/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/servlet/ApacheCombinedServletLogFormatter.java b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/servlet/ApacheCombinedServletLogFormatter.java new file mode 100644 index 00000000..e0c7a735 --- /dev/null +++ b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/servlet/ApacheCombinedServletLogFormatter.java @@ -0,0 +1,170 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance + * with the License. A copy of the License is located at + * + * http://aws.amazon.com/apache2.0/ + * + * or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES + * OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions + * and limitations under the License. + */ +package com.amazonaws.serverless.proxy.internal.servlet; + +import com.amazonaws.serverless.proxy.LogFormatter; +import com.amazonaws.serverless.proxy.model.AwsProxyRequestContext; + +import com.amazonaws.serverless.proxy.model.HttpApiV2ProxyRequestContext; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.ws.rs.core.SecurityContext; + +import java.time.*; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeFormatterBuilder; +import java.util.Locale; + +import static com.amazonaws.serverless.proxy.RequestReader.*; +import static java.time.temporal.ChronoField.DAY_OF_MONTH; +import static java.time.temporal.ChronoField.HOUR_OF_DAY; +import static java.time.temporal.ChronoField.MINUTE_OF_HOUR; +import static java.time.temporal.ChronoField.MONTH_OF_YEAR; +import static java.time.temporal.ChronoField.SECOND_OF_MINUTE; +import static java.time.temporal.ChronoField.YEAR; + +/** + * Default implementation of the log formatter. Based on an HttpServletRequest and HttpServletResponse implementations produced + * a log line in the Apache combined log format: https://httpd.apache.org/docs/2.4/logs.html + * @param An implementation of HttpServletRequest + * @param An implementation of HttpServletResponse + */ +public class ApacheCombinedServletLogFormatter + implements LogFormatter { + private final DateTimeFormatter dateFormat; + private final Clock clock; + + public ApacheCombinedServletLogFormatter() { + this(Clock.systemDefaultZone()); + } + + ApacheCombinedServletLogFormatter(Clock clock) { + this.clock = clock; + this.dateFormat = new DateTimeFormatterBuilder() + .parseCaseInsensitive() + .appendLiteral("[") + .appendValue(DAY_OF_MONTH, 2) + .appendLiteral("/") + .appendValue(MONTH_OF_YEAR, 2) + .appendLiteral("/") + .appendValue(YEAR, 4) + .appendLiteral(":") + .appendValue(HOUR_OF_DAY, 2) + .appendLiteral(":") + .appendValue(MINUTE_OF_HOUR, 2) + .appendLiteral(":") + .appendValue(SECOND_OF_MINUTE, 2) + .optionalStart() + .appendOffset("+HHMM", "Z") + .optionalEnd() + .appendLiteral("]") + .toFormatter(); + } + + @Override + @SuppressFBWarnings({ "SERVLET_HEADER_REFERER", "SERVLET_HEADER_USER_AGENT" }) + public String format(ContainerRequestType servletRequest, ContainerResponseType servletResponse, SecurityContext ctx) { + //LogFormat "%h %l %u %t \"%r\" %>s %b \"%{Referer}i\" \"%{User-agent}i\"" combined + StringBuilder logLineBuilder = new StringBuilder(); + AwsProxyRequestContext gatewayContext = (AwsProxyRequestContext)servletRequest.getAttribute(API_GATEWAY_CONTEXT_PROPERTY); + HttpApiV2ProxyRequestContext httpApiContext = (HttpApiV2ProxyRequestContext)servletRequest.getAttribute(HTTP_API_CONTEXT_PROPERTY); + + // %h + logLineBuilder.append(servletRequest.getRemoteAddr()); + logLineBuilder.append(" "); + + // %l + if (servletRequest.getUserPrincipal() != null) { + logLineBuilder.append(servletRequest.getUserPrincipal().getName()); + } else { + logLineBuilder.append("-"); + } + if (gatewayContext != null && gatewayContext.getIdentity() != null && gatewayContext.getIdentity().getUserArn() != null) { + logLineBuilder.append(gatewayContext.getIdentity().getUserArn()); + } else { + logLineBuilder.append("-"); + } + logLineBuilder.append(" "); + + // %u + if (servletRequest.getUserPrincipal() != null) { + logLineBuilder.append(servletRequest.getUserPrincipal().getName()); + } + logLineBuilder.append(" "); + + + // %t + long timeEpoch = ZonedDateTime.now(clock).toEpochSecond(); + if (gatewayContext != null && gatewayContext.getRequestTimeEpoch() > 0) { + timeEpoch = gatewayContext.getRequestTimeEpoch() / 1000; + } else if (httpApiContext != null && httpApiContext.getTimeEpoch() > 0) { + timeEpoch = httpApiContext.getTimeEpoch() / 1000; + } + logLineBuilder.append( + dateFormat.format(ZonedDateTime.of( + LocalDateTime.ofEpochSecond(timeEpoch, 0, ZoneOffset.UTC), + clock.getZone()) + )); + logLineBuilder.append(" "); + + // %r + logLineBuilder.append("\""); + logLineBuilder.append(servletRequest.getMethod().toUpperCase(Locale.ENGLISH)); + logLineBuilder.append(" "); + logLineBuilder.append(servletRequest.getRequestURI()); + logLineBuilder.append(" "); + logLineBuilder.append(servletRequest.getProtocol()); + logLineBuilder.append("\" "); + + // %>s + logLineBuilder.append(servletResponse.getStatus()); + logLineBuilder.append(" "); + + // %b + if (servletResponse instanceof AwsHttpServletResponse) { + AwsHttpServletResponse awsResponse = (AwsHttpServletResponse)servletResponse; + if (awsResponse.getAwsResponseBodyBytes().length > 0) { + logLineBuilder.append(awsResponse.getAwsResponseBodyBytes().length); + } else { + logLineBuilder.append("-"); + } + } else { + logLineBuilder.append("-"); + } + logLineBuilder.append(" "); + + // \"%{Referer}i\" + logLineBuilder.append("\""); + if (servletRequest.getHeader("referer") != null) { + logLineBuilder.append(servletRequest.getHeader("referer")); + } else { + logLineBuilder.append("-"); + } + logLineBuilder.append("\" "); + + // \"%{User-agent}i\" + logLineBuilder.append("\""); + if (servletRequest.getHeader("user-agent") != null) { + logLineBuilder.append(servletRequest.getHeader("user-agent")); + } else { + logLineBuilder.append("-"); + } + logLineBuilder.append("\" "); + + logLineBuilder.append("combined"); + + return logLineBuilder.toString(); + } +} diff --git a/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/servlet/AwsAsyncContext.java b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/servlet/AwsAsyncContext.java new file mode 100644 index 00000000..d64af896 --- /dev/null +++ b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/servlet/AwsAsyncContext.java @@ -0,0 +1,240 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance + * with the License. A copy of the License is located at + * + * http://aws.amazon.com/apache2.0/ + * + * or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES + * OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions + * and limitations under the License. + */ +package com.amazonaws.serverless.proxy.internal.servlet; + +import com.amazonaws.serverless.proxy.internal.SecurityUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import jakarta.servlet.*; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * Async context for Serverless Java Container. This is used to support reactive embedded servers for our support for + * Spring Boot 2. Behind the scenes, the Async context still uses the CountDownLatch to synchronize response + * generation. + */ +public class AwsAsyncContext implements AsyncContext { + private HttpServletRequest req; + private HttpServletResponse res; + private List listeners; + private long timeout; + private AtomicBoolean dispatched; + private AtomicBoolean completed; + private AtomicBoolean dispatchStarted; + + private Logger log = LoggerFactory.getLogger(AwsAsyncContext.class); + + public AwsAsyncContext(HttpServletRequest request, HttpServletResponse response) { + log.debug("Initializing async context for request: " + SecurityUtils.crlf(request.getPathInfo()) + " - " + SecurityUtils.crlf(request.getMethod())); + req = request; + res = response; + listeners = new ArrayList<>(); + timeout = 3000; + dispatched = new AtomicBoolean(false); + completed = new AtomicBoolean(false); + dispatchStarted = new AtomicBoolean(false); + } + + @Override + public ServletRequest getRequest() { + return req; + } + + @Override + public ServletResponse getResponse() { + return res; + } + + @Override + public boolean hasOriginalRequestAndResponse() { + return true; + } + + @Override + public void dispatch() { + log.debug("Dispatching request"); + + if (dispatched.get()) { + throw new IllegalStateException("Dispatching already started"); + } + if (dispatchStarted.getAndSet(true)) { + dispatched.set(true); + notifyListeners(NotificationType.START_ASYNC, null); + } + } + + @Override + public void dispatch(String s) { + // amend the request path + req = new AwsHttpServletRequestWrapper(req, s); + + dispatch(); + } + + @Override + public void dispatch(ServletContext servletContext, String s) { + req = new AwsHttpServletRequestWrapper(req, s); + ((AwsHttpServletRequestWrapper)req).setServletContext(servletContext); + dispatch(s); + } + + @Override + public void complete() { + try { + log.debug("Completing request"); + notifyListeners(NotificationType.COMPLETE, null); + res.flushBuffer(); + completed.set(true); + } catch (IOException e) { + log.error("Could not flush response buffer", e); + throw new RuntimeException(e); + } + } + + @Override + public void start(Runnable runnable) { + throw new UnsupportedOperationException("Operation not supported"); + } + + @Override + public void addListener(AsyncListener asyncListener) { + listeners.add(new AsyncListenerHolder(asyncListener, this)); + } + + @Override + public void addListener(AsyncListener asyncListener, ServletRequest servletRequest, ServletResponse servletResponse) { + AsyncListenerHolder holder = new AsyncListenerHolder(asyncListener, this); + holder.setSuppliedRequest(servletRequest); + holder.setSuppliedResponse(servletResponse); + listeners.add(holder); + } + + @Override + public T createListener(Class aClass) throws ServletException { + try { + return aClass.newInstance(); + } catch (InstantiationException | IllegalAccessException e) { + throw new ServletException(e); + } + } + + @Override + public void setTimeout(long l) { + timeout = l; + } + + @Override + public long getTimeout() { + return timeout; + } + + public boolean isDispatched() { + return dispatched.get(); + } + + public boolean isCompleted() { + return completed.get(); + } + + public boolean isDispatchStarted() { + return dispatchStarted.get(); + } + + private void notifyListeners(NotificationType type, Throwable t) { + listeners.forEach((h) -> { + try { + switch (type) { + case COMPLETE: + case START_ASYNC: + case TIMEOUT: + h.getListener().onComplete(h.getAsyncEvent()); + break; + case ERROR: + h.getListener().onError(h.getAsyncEvent(t)); + break; + } + } catch (IOException e) { + if (type != NotificationType.ERROR) { + notifyListeners(NotificationType.ERROR, e); + } + } + }); + } + + private enum NotificationType { + COMPLETE, + ERROR, + START_ASYNC, + TIMEOUT + } + + /** + * The listener holder wraps and AsyncListener with information about its context such as the request, + * response, and async servlet context. + */ + private static final class AsyncListenerHolder { + private AsyncListener listener; + private ServletRequest suppliedRequest; + private ServletResponse suppliedResponse; + private AsyncContext context; + + public AsyncListenerHolder(AsyncListener l, AsyncContext ctx) { + listener = l; + context = ctx; + } + + public AsyncListener getListener() { + return listener; + } + + public void setListener(AsyncListener listener) { + this.listener = listener; + } + + public ServletRequest getSuppliedRequest() { + return suppliedRequest; + } + + public void setSuppliedRequest(ServletRequest suppliedRequest) { + this.suppliedRequest = suppliedRequest; + } + + public ServletResponse getSuppliedResponse() { + return suppliedResponse; + } + + public void setSuppliedResponse(ServletResponse suppliedResponse) { + this.suppliedResponse = suppliedResponse; + } + + public AsyncEvent getAsyncEvent(Throwable t) { + if (suppliedRequest != null && suppliedResponse != null) { + if (t != null) { + return new AsyncEvent(context, suppliedRequest, suppliedResponse, t); + } + return new AsyncEvent(context, suppliedRequest, suppliedResponse); + } + return new AsyncEvent(context); + } + + public AsyncEvent getAsyncEvent() { + return getAsyncEvent(null); + } + } +} diff --git a/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/servlet/AwsCookieProcessor.java b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/servlet/AwsCookieProcessor.java new file mode 100644 index 00000000..36ade344 --- /dev/null +++ b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/servlet/AwsCookieProcessor.java @@ -0,0 +1,273 @@ +package com.amazonaws.serverless.proxy.internal.servlet; + +import com.amazonaws.serverless.proxy.internal.SecurityUtils; +import jakarta.servlet.http.Cookie; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import java.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.*; + +/** + * Implementation of the CookieProcessor interface that provides cookie parsing and generation functionality. + */ +public class AwsCookieProcessor implements CookieProcessor { + + // Cookie attribute constants + static final String COOKIE_COMMENT_ATTR = "Comment"; + static final String COOKIE_DOMAIN_ATTR = "Domain"; + static final String COOKIE_EXPIRES_ATTR = "Expires"; + static final String COOKIE_MAX_AGE_ATTR = "Max-Age"; + static final String COOKIE_PATH_ATTR = "Path"; + static final String COOKIE_SECURE_ATTR = "Secure"; + static final String COOKIE_HTTP_ONLY_ATTR = "HttpOnly"; + static final String COOKIE_SAME_SITE_ATTR = "SameSite"; + static final String COOKIE_PARTITIONED_ATTR = "Partitioned"; + static final String EMPTY_STRING = ""; + + // BitSet to store valid token characters as defined in RFC 2616 + static final BitSet tokenValid = createTokenValidSet(); + + // BitSet to validate domain characters + static final BitSet domainValid = createDomainValidSet(); + + static final DateTimeFormatter COOKIE_DATE_FORMATTER = DateTimeFormatter.RFC_1123_DATE_TIME.withZone(ZoneId.of("GMT")); + + static final String ANCIENT_DATE = COOKIE_DATE_FORMATTER.format(Instant.ofEpochMilli(10000)); + + static BitSet createTokenValidSet() { + BitSet tokenSet = new BitSet(128); + for (char c = '0'; c <= '9'; c++) tokenSet.set(c); + for (char c = 'a'; c <= 'z'; c++) tokenSet.set(c); + for (char c = 'A'; c <= 'Z'; c++) tokenSet.set(c); + for (char c : "!#$%&'*+-.^_`|~".toCharArray()) tokenSet.set(c); + return tokenSet; + } + + static BitSet createDomainValidSet() { + BitSet domainValid = new BitSet(128); + for (char c = '0'; c <= '9'; c++) domainValid.set(c); + for (char c = 'a'; c <= 'z'; c++) domainValid.set(c); + for (char c = 'A'; c <= 'Z'; c++) domainValid.set(c); + domainValid.set('.'); + domainValid.set('-'); + return domainValid; + } + + private final Logger log = LoggerFactory.getLogger(AwsCookieProcessor.class); + + @Override + public Cookie[] parseCookieHeader(String cookieHeader) { + // Return an empty array if the input is null or empty after trimming + if (cookieHeader == null || cookieHeader.trim().isEmpty()) { + return new Cookie[0]; + } + + // Parse cookie header and convert to Cookie array + return Arrays.stream(cookieHeader.split("\\s*;\\s*")) + .map(this::parseCookiePair) + .filter(Objects::nonNull) // Filter out invalid pairs + .toArray(Cookie[]::new); + } + + /** + * Parse a single cookie pair (name=value). + * + * @param cookiePair The cookie pair string. + * @return A valid Cookie object or null if the pair is invalid. + */ + private Cookie parseCookiePair(String cookiePair) { + String[] kv = cookiePair.split("=", 2); + + if (kv.length != 2) { + log.warn("Ignoring invalid cookie: {}", cookiePair); + return null; // Skip malformed cookie pairs + } + + String cookieName = kv[0]; + String cookieValue = kv[1]; + + // Validate name and value + if (!isToken(cookieName)){ + log.warn("Ignoring cookie with invalid name: {}={}", cookieName, cookieValue); + return null; // Skip invalid cookie names + } + + if (!isValidCookieValue(cookieValue)) { + log.warn("Ignoring cookie with invalid value: {}={}", cookieName, cookieValue); + return null; // Skip invalid cookie values + } + + // Return a new Cookie object after security processing + return new Cookie(SecurityUtils.crlf(cookieName), SecurityUtils.crlf(cookieValue)); + } + + @Override + public String generateHeader(Cookie cookie) { + StringBuilder header = new StringBuilder(); + header.append(cookie.getName()).append('='); + + String value = cookie.getValue(); + if (value != null && value.length() > 0) { + validateCookieValue(value); + header.append(value); + } + + int maxAge = cookie.getMaxAge(); + if (maxAge == 0) { + appendAttribute(header, COOKIE_EXPIRES_ATTR, ANCIENT_DATE); + } else if (maxAge > 0){ + Instant expiresAt = Instant.now().plusSeconds(maxAge); + appendAttribute(header, COOKIE_EXPIRES_ATTR, COOKIE_DATE_FORMATTER.format(expiresAt)); + appendAttribute(header, COOKIE_MAX_AGE_ATTR, String.valueOf(maxAge)); + } + + String domain = cookie.getDomain(); + if (domain != null && !domain.isEmpty()) { + validateDomain(domain); + appendAttribute(header, COOKIE_DOMAIN_ATTR, domain); + } + + String path = cookie.getPath(); + if (path != null && !path.isEmpty()) { + validatePath(path); + appendAttribute(header, COOKIE_PATH_ATTR, path); + } + + if (cookie.getSecure()) { + appendAttributeWithoutValue(header, COOKIE_SECURE_ATTR); + } + + if (cookie.isHttpOnly()) { + appendAttributeWithoutValue(header, COOKIE_HTTP_ONLY_ATTR); + } + + String sameSite = cookie.getAttribute(COOKIE_SAME_SITE_ATTR); + if (sameSite != null) { + appendAttribute(header, COOKIE_SAME_SITE_ATTR, sameSite); + } + + String partitioned = cookie.getAttribute(COOKIE_PARTITIONED_ATTR); + if (EMPTY_STRING.equals(partitioned)) { + appendAttributeWithoutValue(header, COOKIE_PARTITIONED_ATTR); + } + + addAdditionalAttributes(cookie, header); + + return header.toString(); + } + + private void appendAttribute(StringBuilder header, String name, String value) { + header.append("; ").append(name); + if (!EMPTY_STRING.equals(value)) { + header.append('=').append(value); + } + } + + private void appendAttributeWithoutValue(StringBuilder header, String name) { + header.append("; ").append(name); + } + + private void addAdditionalAttributes(Cookie cookie, StringBuilder header) { + for (Map.Entry entry : cookie.getAttributes().entrySet()) { + switch (entry.getKey()) { + case COOKIE_COMMENT_ATTR: + case COOKIE_DOMAIN_ATTR: + case COOKIE_MAX_AGE_ATTR: + case COOKIE_PATH_ATTR: + case COOKIE_SECURE_ATTR: + case COOKIE_HTTP_ONLY_ATTR: + case COOKIE_SAME_SITE_ATTR: + case COOKIE_PARTITIONED_ATTR: + // Already handled attributes are ignored + break; + default: + validateAttribute(entry.getKey(), entry.getValue()); + appendAttribute(header, entry.getKey(), entry.getValue()); + break; + } + } + } + + private void validateCookieValue(String value) { + if (!isValidCookieValue(value)) { + throw new IllegalArgumentException("Invalid cookie value: " + value); + } + } + + private void validateDomain(String domain) { + if (!isValidDomain(domain)) { + throw new IllegalArgumentException("Invalid cookie domain: " + domain); + } + } + + private void validatePath(String path) { + for (char ch : path.toCharArray()) { + if (ch < 0x20 || ch > 0x7E || ch == ';') { + throw new IllegalArgumentException("Invalid cookie path: " + path); + } + } + } + + private void validateAttribute(String name, String value) { + if (!isToken(name)) { + throw new IllegalArgumentException("Invalid cookie attribute name: " + name); + } + + for (char ch : value.toCharArray()) { + if (ch < 0x20 || ch > 0x7E || ch == ';') { + throw new IllegalArgumentException("Invalid cookie attribute value: " + ch); + } + } + } + + private boolean isValidCookieValue(String value) { + int start = 0; + int end = value.length(); + boolean quoted = end > 1 && value.charAt(0) == '"' && value.charAt(end - 1) == '"'; + + char[] chars = value.toCharArray(); + for (int i = start; i < end; i++) { + if (quoted && (i == start || i == end - 1)) { + continue; + } + char c = chars[i]; + if (!isValidCookieChar(c)) return false; + } + return true; + } + + private boolean isValidDomain(String domain) { + if (domain.isEmpty()) { + return false; + } + int prev = -1; + for (char c : domain.toCharArray()) { + if (!domainValid.get(c) || isInvalidLabelStartOrEnd(prev, c)) { + return false; + } + prev = c; + } + return prev != '.' && prev != '-'; + } + + private boolean isInvalidLabelStartOrEnd(int prev, char current) { + return (prev == '.' || prev == -1) && (current == '.' || current == '-') || + (prev == '-' && current == '.'); + } + + private boolean isToken(String s) { + if (s.isEmpty()) return false; + for (char c : s.toCharArray()) { + if (!tokenValid.get(c)) { + return false; + } + } + return true; + } + + private boolean isValidCookieChar(char c) { + return !(c < 0x21 || c > 0x7E || c == 0x22 || c == 0x2c || c == 0x3b || c == 0x5c); + } +} diff --git a/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/servlet/AwsFilterChainManager.java b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/servlet/AwsFilterChainManager.java index 119a15a9..5247633e 100644 --- a/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/servlet/AwsFilterChainManager.java +++ b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/servlet/AwsFilterChainManager.java @@ -40,6 +40,7 @@ public class AwsFilterChainManager extends FilterChainManager * Returns the filter holders stored in the AwsServletContext object * @return The map of filter holders */ + @Override protected Map getFilterHolders() { return servletContext.getFilterHolders(); } diff --git a/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/servlet/AwsHttpApiV2HttpServletRequestReader.java b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/servlet/AwsHttpApiV2HttpServletRequestReader.java new file mode 100644 index 00000000..c40740c9 --- /dev/null +++ b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/servlet/AwsHttpApiV2HttpServletRequestReader.java @@ -0,0 +1,50 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance + * with the License. A copy of the License is located at + * + * http://aws.amazon.com/apache2.0/ + * + * or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES + * OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions + * and limitations under the License. + */ +package com.amazonaws.serverless.proxy.internal.servlet; + +import com.amazonaws.serverless.exceptions.InvalidRequestEventException; +import com.amazonaws.serverless.proxy.RequestReader; +import com.amazonaws.serverless.proxy.model.ContainerConfig; +import com.amazonaws.serverless.proxy.model.HttpApiV2ProxyRequest; +import com.amazonaws.services.lambda.runtime.Context; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.ws.rs.core.SecurityContext; + +public class AwsHttpApiV2HttpServletRequestReader extends RequestReader { + static final String INVALID_REQUEST_ERROR = "The incoming event is not a valid HTTP API v2 proxy request"; + + @Override + public HttpServletRequest readRequest(HttpApiV2ProxyRequest request, SecurityContext securityContext, Context lambdaContext, ContainerConfig config) throws InvalidRequestEventException { + if (request.getRequestContext() == null || request.getRequestContext().getHttp().getMethod() == null || request.getRequestContext().getHttp().getMethod().equals("")) { + throw new InvalidRequestEventException(INVALID_REQUEST_ERROR); + } + + // clean out the request path based on the container config + request.setRawPath(stripBasePath(request.getRawPath(), config)); + + AwsHttpApiV2ProxyHttpServletRequest servletRequest = new AwsHttpApiV2ProxyHttpServletRequest(request, lambdaContext, securityContext, config); + servletRequest.setAttribute(HTTP_API_CONTEXT_PROPERTY, request.getRequestContext()); + servletRequest.setAttribute(HTTP_API_STAGE_VARS_PROPERTY, request.getStageVariables()); + servletRequest.setAttribute(HTTP_API_EVENT_PROPERTY, request); + servletRequest.setAttribute(LAMBDA_CONTEXT_PROPERTY, lambdaContext); + servletRequest.setAttribute(JAX_SECURITY_CONTEXT_PROPERTY, securityContext); + + return servletRequest; + } + + @Override + protected Class getRequestClass() { + return HttpApiV2ProxyRequest.class; + } +} diff --git a/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/servlet/AwsHttpApiV2ProxyHttpServletRequest.java b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/servlet/AwsHttpApiV2ProxyHttpServletRequest.java new file mode 100644 index 00000000..f318b727 --- /dev/null +++ b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/servlet/AwsHttpApiV2ProxyHttpServletRequest.java @@ -0,0 +1,528 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance + * with the License. A copy of the License is located at + * + * http://aws.amazon.com/apache2.0/ + * + * or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES + * OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions + * and limitations under the License. + */ +package com.amazonaws.serverless.proxy.internal.servlet; + +import com.amazonaws.serverless.proxy.internal.HttpUtils; +import com.amazonaws.serverless.proxy.internal.LambdaContainerHandler; +import com.amazonaws.serverless.proxy.internal.SecurityUtils; +import com.amazonaws.serverless.proxy.model.ContainerConfig; +import com.amazonaws.serverless.proxy.model.Headers; +import com.amazonaws.serverless.proxy.model.HttpApiV2ProxyRequest; +import com.amazonaws.serverless.proxy.model.MultiValuedTreeMap; +import com.amazonaws.services.lambda.runtime.Context; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import jakarta.servlet.*; +import jakarta.servlet.http.*; +import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.core.SecurityContext; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.StringReader; +import java.io.UnsupportedEncodingException; +import java.net.URLDecoder; +import java.nio.charset.Charset; +import java.security.Principal; +import java.time.Instant; +import java.time.ZonedDateTime; +import java.time.format.DateTimeParseException; +import java.util.*; +import java.util.stream.Stream; + +public class AwsHttpApiV2ProxyHttpServletRequest extends AwsHttpServletRequest { + private static Logger log = LoggerFactory.getLogger(AwsHttpApiV2ProxyHttpServletRequest.class); + + private HttpApiV2ProxyRequest request; + private MultiValuedTreeMap queryString; + private Headers headers; + private ContainerConfig config; + private SecurityContext securityContext; + private AwsAsyncContext asyncContext; + + /** + * Protected constructors for implementing classes. This should be called first with the context received from + * AWS Lambda + * + * @param lambdaContext The Lambda function context. This object is used for utility methods such as log + */ + public AwsHttpApiV2ProxyHttpServletRequest(HttpApiV2ProxyRequest req, Context lambdaContext, SecurityContext sc, ContainerConfig cfg) { + super(lambdaContext); + request = req; + config = cfg; + securityContext = sc; + queryString = parseRawQueryString(request.getRawQueryString()); + headers = headersMapToMultiValue(request.getHeaders()); + } + + public HttpApiV2ProxyRequest getRequest() { + return request; + } + + @Override + public String getAuthType() { + // TODO + return null; + } + + @Override + public Cookie[] getCookies() { + Cookie[] rhc; + if (headers == null || !headers.containsKey(HttpHeaders.COOKIE)) { + rhc = new Cookie[0]; + } else { + rhc = getCookieProcessor().parseCookieHeader(headers.getFirst(HttpHeaders.COOKIE)); + } + + Cookie[] rc; + if (request.getCookies() == null) { + rc = new Cookie[0]; + } else { + rc = getCookieProcessor().parseCookieHeader(String.join("; ", request.getCookies())); + } + + return Stream.concat(Arrays.stream(rhc), Arrays.stream(rc)).toArray(Cookie[]::new); + } + + @Override + public long getDateHeader(String s) { + if (headers == null) { + return -1L; + } + String dateString = headers.getFirst(s); + if (dateString == null) { + return -1L; + } + try { + return Instant.from(ZonedDateTime.parse(dateString, dateFormatter)).toEpochMilli(); + } catch (DateTimeParseException e) { + log.warn("Invalid date header in request: " + SecurityUtils.crlf(dateString)); + return -1L; + } + } + + @Override + public String getHeader(String s) { + if (headers == null) { + return null; + } + return headers.getFirst(s); + } + + @Override + public Enumeration getHeaders(String s) { + if (headers == null || !headers.containsKey(s)) { + return Collections.emptyEnumeration(); + } + return Collections.enumeration(headers.get(s)); + } + + @Override + public Enumeration getHeaderNames() { + if (headers == null) { + return Collections.emptyEnumeration(); + } + return Collections.enumeration(headers.keySet()); + } + + @Override + public int getIntHeader(String s) { + if (headers == null) { + return -1; + } + String headerValue = headers.getFirst(s); + if (headerValue == null || "".equals(headerValue)) { + return -1; + } + + return Integer.parseInt(headerValue); + } + + @Override + public String getMethod() { + return request.getRequestContext().getHttp().getMethod(); + } + + @Override + public String getPathInfo() { + String pathInfo = cleanUri(request.getRawPath()); + return decodeRequestPath(pathInfo, LambdaContainerHandler.getContainerConfig()); + } + + @Override + public String getPathTranslated() { + // Return null because it is an archive on a remote system + return null; + } + + @Override + public String getContextPath() { + return generateContextPath(config, request.getRequestContext().getStage()); + } + + @Override + public String getQueryString() { + return request.getRawQueryString(); + } + + @Override + public String getRemoteUser() { + if (securityContext == null || securityContext.getUserPrincipal() == null) { + return null; + } + return securityContext.getUserPrincipal().getName(); + } + + @Override + public boolean isUserInRole(String s) { + // TODO: Not supported + return false; + } + + @Override + public Principal getUserPrincipal() { + if (securityContext == null) { + return null; + } + return securityContext.getUserPrincipal(); + } + + @Override + public String getRequestURI() { + return cleanUri(getContextPath()) + cleanUri(request.getRawPath()); + } + + @Override + public StringBuffer getRequestURL() { + return generateRequestURL(request.getRawPath()); + } + + + @Override + public boolean authenticate(HttpServletResponse httpServletResponse) throws IOException, ServletException { + throw new UnsupportedOperationException(); + } + + @Override + public void login(String s, String s1) throws ServletException { + throw new UnsupportedOperationException(); + } + + @Override + public void logout() throws ServletException { + throw new UnsupportedOperationException(); + } + + @Override + public T upgrade(Class aClass) throws IOException, ServletException { + throw new UnsupportedOperationException(); + } + + @Override + public String getCharacterEncoding() { + if (headers == null) { + return config.getDefaultContentCharset(); + } + Charset charset = HttpUtils.parseCharacterEncoding(headers.getFirst(HttpHeaders.CONTENT_TYPE),null); + return charset != null ? charset.name() : null; + } + + @Override + public void setCharacterEncoding(String s) throws UnsupportedEncodingException { + if (headers == null || !headers.containsKey(HttpHeaders.CONTENT_TYPE)) { + log.debug("Called set character encoding to " + SecurityUtils.crlf(s) + " on a request without a content type. Character encoding will not be set"); + return; + } + String currentContentType = headers.getFirst(HttpHeaders.CONTENT_TYPE); + headers.putSingle(HttpHeaders.CONTENT_TYPE, HttpUtils.appendCharacterEncoding(currentContentType, s)); + } + + @Override + public int getContentLength() { + String headerValue = headers.getFirst(HttpHeaders.CONTENT_LENGTH); + if (headerValue == null) { + return -1; + } + return Integer.parseInt(headerValue); + } + + @Override + public long getContentLengthLong() { + String headerValue = headers.getFirst(HttpHeaders.CONTENT_LENGTH); + if (headerValue == null) { + return -1; + } + return Long.parseLong(headerValue); + } + + @Override + public String getContentType() { + if (headers == null) { + return null; + } + return headers.getFirst(HttpHeaders.CONTENT_TYPE); + } + + @Override + public String getParameter(String s) { + String queryStringParameter = getFirstQueryParamValue(queryString, s, config.isQueryStringCaseSensitive()); + if (queryStringParameter != null) { + return queryStringParameter; + } + + String[] bodyParams = getFormBodyParameterCaseInsensitive(s); + if (bodyParams.length == 0) { + return null; + } else { + return bodyParams[0]; + } + } + + @Override + public Enumeration getParameterNames() { + if (queryString == null) { + return Collections.emptyEnumeration(); + } + + return Collections.enumeration(queryString.keySet()); + } + + @Override + @SuppressFBWarnings("PZLA_PREFER_ZERO_LENGTH_ARRAYS") // suppressing this as according to the specs we should be returning null here if we can't find params + public String[] getParameterValues(String s) { + + List values = getQueryParamValuesAsList(queryString, s, config.isQueryStringCaseSensitive()); + + // copy list so we don't modifying the underlying multi-value query params + if (values != null) { + values = new ArrayList<>(values); + } else { + values = new ArrayList<>(); + } + + values.addAll(Arrays.asList(getFormBodyParameterCaseInsensitive(s))); + + if (values.size() == 0) { + return null; + } else { + return values.toArray(new String[0]); + } + } + + @Override + public Map getParameterMap() { + return generateParameterMap(queryString, config); + } + + @Override + public String getProtocol() { + return request.getRequestContext().getHttp().getProtocol(); + } + + @Override + public String getScheme() { + return getSchemeFromHeader(headers); + } + + @Override + public String getServerName() { + // we match the behavior of the v1 proxy request here. Should we? + String region = System.getenv("AWS_REGION"); + if (region == null) { + // this is not a critical failure, we just put a static region in the URI + region = "us-east-1"; + } + + if (headers != null && headers.containsKey(HOST_HEADER_NAME)) { + String hostHeader = headers.getFirst(HOST_HEADER_NAME); + if (SecurityUtils.isValidHost(hostHeader, request.getRequestContext().getApiId(), request.getRequestContext().getElb(), region)) { + return hostHeader; + } + } + + return request.getRequestContext().getDomainName(); + } + + @Override + public int getServerPort() { + if (headers == null || !headers.containsKey(PORT_HEADER_NAME)) { + return 443; // we default to 443 as HTTP APIs can only be HTTPS + } + String port = headers.getFirst(PORT_HEADER_NAME); + if (SecurityUtils.isValidPort(port)) { + return Integer.parseInt(port); + } + return 443; // default port + } + + @Override + public ServletInputStream getInputStream() throws IOException { + if (requestInputStream == null) { + requestInputStream = new AwsServletInputStream(bodyStringToInputStream(request.getBody(), request.isBase64Encoded())); + } + return requestInputStream; + } + + @Override + public BufferedReader getReader() throws IOException { + return new BufferedReader(new StringReader(request.getBody())); + } + + @Override + public String getRemoteAddr() { + if (request.getRequestContext() == null || request.getRequestContext().getHttp() == null || request.getRequestContext().getHttp().getSourceIp() == null) { + return "127.0.0.1"; + } + return request.getRequestContext().getHttp().getSourceIp(); + } + + @Override + public String getRemoteHost() { + if (headers == null) { + return null; + } + return headers.getFirst(HttpHeaders.HOST); + } + + @Override + public Locale getLocale() { + List locales = parseAcceptLanguageHeader(headers.getFirst(HttpHeaders.ACCEPT_LANGUAGE)); + return locales.size() == 0 ? Locale.getDefault() : locales.get(0); + } + + @Override + public Enumeration getLocales() { + List locales = parseAcceptLanguageHeader(headers.getFirst(HttpHeaders.ACCEPT_LANGUAGE)); + return Collections.enumeration(locales); + } + + @Override + public boolean isSecure() { + return securityContext.isSecure(); + } + + @Override + public RequestDispatcher getRequestDispatcher(String s) { + return getServletContext().getRequestDispatcher(s); + } + + @Override + public int getRemotePort() { + return 0; + } + + @Override + public boolean isAsyncSupported() { + return true; + } + + @Override + public boolean isAsyncStarted() { + if (asyncContext == null) { + return false; + } + if (asyncContext.isCompleted() || asyncContext.isDispatched()) { + return false; + } + return true; + } + + @Override + public AsyncContext startAsync() throws IllegalStateException { + asyncContext = new AwsAsyncContext(this, response); + setAttribute(DISPATCHER_TYPE_ATTRIBUTE, DispatcherType.ASYNC); + log.debug("Starting async context for request: " + SecurityUtils.crlf(request.getRequestContext().getRequestId())); + return asyncContext; + } + + @Override + public AsyncContext startAsync(ServletRequest servletRequest, ServletResponse servletResponse) throws IllegalStateException { + asyncContext = new AwsAsyncContext((HttpServletRequest) servletRequest, (HttpServletResponse) servletResponse); + setAttribute(DISPATCHER_TYPE_ATTRIBUTE, DispatcherType.ASYNC); + log.debug("Starting async context for request: " + SecurityUtils.crlf(request.getRequestContext().getRequestId())); + return asyncContext; + } + + @Override + public AsyncContext getAsyncContext() { + if (asyncContext == null) { + throw new IllegalStateException("Request " + SecurityUtils.crlf(request.getRequestContext().getRequestId()) + + " is not in asynchronous mode. Call startAsync before attempting to get the async context."); + } + return asyncContext; + } + + @Override + public String getRequestId() { + return request.getRequestContext().getRequestId(); + } + + @Override + public String getProtocolRequestId() { + return ""; + } + + @Override + public ServletConnection getServletConnection() { + return null; + } + + private MultiValuedTreeMap parseRawQueryString(String qs) { + if (qs == null || "".equals(qs.trim())) { + return new MultiValuedTreeMap<>(); + } + + MultiValuedTreeMap qsMap = new MultiValuedTreeMap<>(); + for (String value : qs.split(QUERY_STRING_SEPARATOR)) { + try { + if (!value.contains(QUERY_STRING_KEY_VALUE_SEPARATOR)) { + qsMap.add(URLDecoder.decode(value, LambdaContainerHandler.getContainerConfig().getUriEncoding()), null); + log.warn("Query string parameter with empty value and no =: " + SecurityUtils.crlf(value)); + continue; + } + + String[] kv = value.split(QUERY_STRING_KEY_VALUE_SEPARATOR); + String key = URLDecoder.decode(kv[0], LambdaContainerHandler.getContainerConfig().getUriEncoding()); + String val = kv.length == 2 ? AwsHttpServletRequest.decodeValueIfEncoded(kv[1]) : ""; + qsMap.add(key, val); + } catch (UnsupportedEncodingException e) { + log.error("Unsupported encoding in query string key-value pair: " + SecurityUtils.crlf(value), e); + } + } + return qsMap; + } + + private Headers headersMapToMultiValue(Map headers) { + if (headers == null || headers.size() == 0) { + return new Headers(); + } + + Headers h = new Headers(); + for (Map.Entry hkv : headers.entrySet()) { + // Exceptions for known header values that contain commas + if (hkv.getKey().equalsIgnoreCase(HttpHeaders.DATE) || + hkv.getKey().equalsIgnoreCase(HttpHeaders.IF_MODIFIED_SINCE) || + hkv.getKey().equalsIgnoreCase(HttpHeaders.USER_AGENT) || + hkv.getKey().toLowerCase(Locale.getDefault()).startsWith("accept-")) { + h.add(hkv.getKey(), hkv.getValue()); + continue; + } + + for (String value : hkv.getValue().split(",")) { + h.add(hkv.getKey(), value); + } + } + return h; + } +} diff --git a/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/servlet/AwsHttpServletRequest.java b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/servlet/AwsHttpServletRequest.java index 53106fd8..0029ee2d 100644 --- a/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/servlet/AwsHttpServletRequest.java +++ b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/servlet/AwsHttpServletRequest.java @@ -12,19 +12,41 @@ */ package com.amazonaws.serverless.proxy.internal.servlet; +import com.amazonaws.serverless.proxy.RequestReader; +import com.amazonaws.serverless.proxy.internal.LambdaContainerHandler; +import com.amazonaws.serverless.proxy.internal.SecurityUtils; +import com.amazonaws.serverless.proxy.internal.testutils.Timer; +import com.amazonaws.serverless.proxy.model.AwsProxyRequestContext; +import com.amazonaws.serverless.proxy.model.ContainerConfig; +import com.amazonaws.serverless.proxy.model.Headers; +import com.amazonaws.serverless.proxy.model.MultiValuedTreeMap; import com.amazonaws.services.lambda.runtime.Context; - -import javax.servlet.AsyncContext; -import javax.servlet.DispatcherType; -import javax.servlet.ServletContext; -import javax.servlet.http.Cookie; -import javax.servlet.http.HttpServletRequest; -import javax.servlet.http.HttpSession; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import org.apache.commons.fileupload2.core.DiskFileItem; +import org.apache.commons.fileupload2.core.FileItem; +import org.apache.commons.fileupload2.core.FileUploadException; +import org.apache.commons.fileupload2.core.DiskFileItemFactory; +import org.apache.commons.fileupload2.jakarta.servlet6.JakartaServletFileUpload; +import org.apache.commons.io.FilenameUtils; +import org.apache.commons.io.IOUtils; +import org.apache.commons.io.input.NullInputStream; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import jakarta.servlet.*; +import jakarta.servlet.http.*; +import jakarta.ws.rs.core.MediaType; + +import java.io.ByteArrayInputStream; +import java.io.IOException; import java.io.UnsupportedEncodingException; +import java.net.URLDecoder; import java.net.URLEncoder; -import java.nio.charset.StandardCharsets; +import java.nio.charset.Charset; +import java.time.format.DateTimeFormatter; import java.util.*; import java.util.stream.Collectors; +import java.util.stream.Stream; /** @@ -40,14 +62,21 @@ public abstract class AwsHttpServletRequest implements HttpServletRequest { static final String HEADER_KEY_VALUE_SEPARATOR = "="; static final String HEADER_VALUE_SEPARATOR = ";"; + static final String HEADER_QUALIFIER_SEPARATOR = ","; static final String FORM_DATA_SEPARATOR = "&"; - static final String DEFAULT_CHARACTER_ENCODING = "UTF-8"; - static final String HEADER_DATE_FORMAT = "EEE, d MMM yyyy HH:mm:ss z"; + static final DateTimeFormatter dateFormatter = DateTimeFormatter.RFC_1123_DATE_TIME; static final String ENCODING_VALUE_KEY = "charset"; + static final String DISPATCHER_TYPE_ATTRIBUTE = "com.amazonaws.serverless.javacontainer.dispatchertype"; + static final String QUERY_STRING_SEPARATOR = "&"; + static final String QUERY_STRING_KEY_VALUE_SEPARATOR = "="; // We need this to pickup the protocol from the CloudFront header since Lambda doesn't receive this // information from anywhere else static final String CF_PROTOCOL_HEADER_NAME = "CloudFront-Forwarded-Proto"; + static final String PROTOCOL_HEADER_NAME = "X-Forwarded-Proto"; + static final String HOST_HEADER_NAME = "Host"; + static final String PORT_HEADER_NAME = "X-Forwarded-Port"; + static final String CLIENT_IP_HEADER = "X-Forwarded-For"; //------------------------------------------------------------- @@ -56,6 +85,19 @@ public abstract class AwsHttpServletRequest implements HttpServletRequest { private Context lambdaContext; private Map attributes; + private ServletContext servletContext; + private AwsHttpSession session; + private String queryString; + private Map> multipartFormParameters; + private Map> urlEncodedFormParameters; + private CookieProcessor cookieProcessor; + + protected AwsHttpServletResponse response; + protected AwsLambdaServletContainerHandler containerHandler; + protected ServletInputStream requestInputStream; + + + private static Logger log = LoggerFactory.getLogger(AwsHttpServletRequest.class); //------------------------------------------------------------- @@ -63,67 +105,87 @@ public abstract class AwsHttpServletRequest implements HttpServletRequest { //------------------------------------------------------------- /** - * Protected constructors for implemnenting classes. This should be called first with the context received from + * Protected constructors for implementing classes. This should be called first with the context received from * AWS Lambda * @param lambdaContext The Lambda function context. This object is used for utility methods such as log */ - AwsHttpServletRequest(Context lambdaContext) { + protected AwsHttpServletRequest(Context lambdaContext) { this.lambdaContext = lambdaContext; attributes = new HashMap<>(); + setAttribute(DISPATCHER_TYPE_ATTRIBUTE, DispatcherType.REQUEST); + } + + public AwsHttpServletResponse getResponse() { + return response; + } + + public void setResponse(AwsHttpServletResponse response) { + this.response = response; + } + + public void setContainerHandler(AwsLambdaServletContainerHandler containerHandler) { + this.containerHandler = containerHandler; } + //------------------------------------------------------------- // Implementation - HttpServletRequest //------------------------------------------------------------- @Override public String getRequestedSessionId() { - throw new UnsupportedOperationException(); + return null; } @Override public HttpSession getSession(boolean b) { - return null; + log.debug("Trying to access session. Lambda functions are stateless and should not rely on the session"); + if (b && null == this.session) { + AwsProxyRequestContext requestContext = (AwsProxyRequestContext) getAttribute(RequestReader.API_GATEWAY_CONTEXT_PROPERTY); + this.session = new AwsHttpSession(requestContext.getRequestId()); + } + return this.session; } + /** + * as per Servlet spec this method creates a session if none exists + * @return exisiting or new http session + */ @Override public HttpSession getSession() { - return null; + return getSession(true); } @Override public String changeSessionId() { + log.debug("Trying to access session. Lambda functions are stateless and should not rely on the session"); return null; } @Override public boolean isRequestedSessionIdValid() { + log.debug("Trying to access session. Lambda functions are stateless and should not rely on the session"); return false; } @Override public boolean isRequestedSessionIdFromCookie() { + log.debug("Trying to access session. Lambda functions are stateless and should not rely on the session"); return false; } @Override public boolean isRequestedSessionIdFromURL() { + log.debug("Trying to access session. Lambda functions are stateless and should not rely on the session"); return false; } - - @Override - public boolean isRequestedSessionIdFromUrl() { - return false; - } - - //------------------------------------------------------------- // Implementation - ServletRequest //------------------------------------------------------------- @@ -185,7 +247,7 @@ public int getLocalPort() { @Override public ServletContext getServletContext() { - return AwsServletContext.getInstance(lambdaContext); + return servletContext; } @@ -200,16 +262,27 @@ public boolean isAsyncSupported() { return false; } + @Override + public DispatcherType getDispatcherType() { + if (getAttribute(DISPATCHER_TYPE_ATTRIBUTE) != null) { + return (DispatcherType) getAttribute(DISPATCHER_TYPE_ATTRIBUTE); + } + return DispatcherType.REQUEST; + } @Override - public AsyncContext getAsyncContext() { - return null; + public String getServletPath() { + // we always work on the root path + return ""; } - @Override - public DispatcherType getDispatcherType() { - return DispatcherType.REQUEST; + //------------------------------------------------------------- + // Methods - Getter/Setter + //------------------------------------------------------------- + + public void setServletContext(ServletContext context) { + servletContext = context; } @@ -223,48 +296,351 @@ public DispatcherType getDispatcherType() { * @return An array of Cookie objects from the header */ protected Cookie[] parseCookieHeaderValue(String headerValue) { - - List> parsedHeaders = this.parseHeaderValue(headerValue); - - return parsedHeaders.stream() - .filter(e -> e.getKey() != null) - .map(e -> new Cookie(e.getKey(), e.getValue())) - .toArray(Cookie[]::new); + return getCookieProcessor().parseCookieHeader(headerValue); } + /** * Given a map of key/values query string parameters from API Gateway, creates a query string as it would have * been in the original url. - * @param parameters A Map of query string parameters + * @param parameters A Map<String, String> of query string parameters + * @param encode Whether the key and values should be URL encoded + * @param encodeCharset Charset to use for encoding the query string * @return The generated query string for the URI */ - protected String generateQueryString(Map parameters) { - if (parameters == null || parameters.size() == 0) { + protected String generateQueryString(MultiValuedTreeMap parameters, boolean encode, String encodeCharset) + throws ServletException { + if (parameters == null || parameters.isEmpty()) { return null; } + if (queryString != null) { + return queryString; + } - return parameters.keySet().stream() - .map(key -> { - String newKey = key; - String newValue = parameters.get(key); - try { - if (!URLEncoder.encode(newKey, StandardCharsets.UTF_8.name()).equals(newKey)) { - newKey = URLEncoder.encode(key, StandardCharsets.UTF_8.name()); - } + StringBuilder queryStringBuilder = new StringBuilder(); - if (!URLEncoder.encode(newValue, StandardCharsets.UTF_8.name()).equals(newValue)) { - newValue = URLEncoder.encode(newValue, StandardCharsets.UTF_8.name()); + try { + for (String key : parameters.keySet()) { + for (String val : parameters.get(key)) { + queryStringBuilder.append("&"); + if (encode) { + queryStringBuilder.append(URLEncoder.encode(key, encodeCharset)); + } else { + queryStringBuilder.append(key); + } + queryStringBuilder.append("="); + if (val != null) { + if (encode) { + queryStringBuilder.append(URLEncoder.encode(val, encodeCharset)); + } else { + queryStringBuilder.append(val); } - } catch (UnsupportedEncodingException e) { - lambdaContext.getLogger().log("Could not URLEncode: " + newKey + "\n" + e.getLocalizedMessage()); - e.printStackTrace(); - } - return newKey + "=" + newValue; - }) - .collect(Collectors.joining("&")); + } + } + } catch (UnsupportedEncodingException e) { + throw new ServletException("Invalid charset passed for query string encoding", e); + } + + queryString = queryStringBuilder.toString(); + queryString = queryString.substring(1); // remove the first & - faster to do it here than adding logic in the Lambda + return queryString; + } + + protected String generateContextPath(ContainerConfig config, String apiStage) { + String contextPath = ""; + if (config.isUseStageAsServletContext() && apiStage != null) { + log.debug("Using stage as context path"); + contextPath = cleanUri(apiStage); + } + if (config.getServiceBasePath() != null) { + contextPath += cleanUri(config.getServiceBasePath()); + } + + return contextPath; + } + + protected StringBuffer generateRequestURL(String requestPath) { + String url = ""; + url += getServerName(); + url += cleanUri(getContextPath()); + url += cleanUri(requestPath); + + return new StringBuffer(getScheme() + "://" + url); + } + + + + + + protected ServletInputStream bodyStringToInputStream(String body, boolean isBase64Encoded) throws IOException { + if (body == null) { + return new AwsServletInputStream(new NullInputStream(0, false, false)); + } + byte[] bodyBytes; + if (isBase64Encoded) { + bodyBytes = Base64.getMimeDecoder().decode(body); + } else { + String encoding = getCharacterEncoding(); + if (encoding == null) { + encoding = Charset.defaultCharset().name(); + } + try { + bodyBytes = body.getBytes(encoding); + } catch (Exception e) { + log.error("Could not read request with character encoding: " + SecurityUtils.crlf(encoding), e); + bodyBytes = body.getBytes(Charset.defaultCharset()); + } + } + ByteArrayInputStream requestBodyStream = new ByteArrayInputStream(bodyBytes); + return new AwsServletInputStream(requestBodyStream); + } + + protected String getFirstQueryParamValue(MultiValuedTreeMap queryString, String key, boolean isCaseSensitive) { + if (queryString != null) { + if (isCaseSensitive) { + return queryString.getFirst(key); + } + + for (String k : queryString.keySet()) { + if (k.toLowerCase(Locale.getDefault()).equals(key.toLowerCase(Locale.getDefault()))) { + return queryString.getFirst(k); + } + } + } + + return null; + } + + protected String[] getFormBodyParameterCaseInsensitive(String key) { + List values = getFormUrlEncodedParametersMap().get(key); + if (values != null) { + String[] valuesArray = new String[values.size()]; + valuesArray = values.toArray(valuesArray); + return valuesArray; + } else { + return new String[0]; + } + } + + + protected Map> getFormUrlEncodedParametersMap() { + if (urlEncodedFormParameters != null) { + return urlEncodedFormParameters; + } + String contentType = getContentType(); + if (contentType == null) { + urlEncodedFormParameters = new HashMap<>(); + return urlEncodedFormParameters; + } + if (!contentType.startsWith(MediaType.APPLICATION_FORM_URLENCODED) || !getMethod().toLowerCase(Locale.ENGLISH).equals("post")) { + urlEncodedFormParameters = new HashMap<>(); + return urlEncodedFormParameters; + } + Timer.start("SERVLET_REQUEST_GET_FORM_PARAMS"); + String rawBodyContent = null; + try { + rawBodyContent = IOUtils.toString(getInputStream(), getCharacterEncoding()); + } catch (IOException e) { + throw new RuntimeException(e); + } + + urlEncodedFormParameters = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); + for (String parameter : rawBodyContent.split(FORM_DATA_SEPARATOR)) { + String[] parameterKeyValue = parameter.split(HEADER_KEY_VALUE_SEPARATOR); + if (parameterKeyValue.length < 1) { + continue; + } + List values = new ArrayList<>(); + if (urlEncodedFormParameters.containsKey(parameterKeyValue[0])) { + values = urlEncodedFormParameters.get(parameterKeyValue[0]); + } + if (parameterKeyValue.length > 1) { + values.add(decodeValueIfEncoded(parameterKeyValue[1])); + } + urlEncodedFormParameters.put(decodeValueIfEncoded(parameterKeyValue[0]), values); + } + Timer.stop("SERVLET_REQUEST_GET_FORM_PARAMS"); + return urlEncodedFormParameters; + } + + protected CookieProcessor getCookieProcessor(){ + if (cookieProcessor == null) { + cookieProcessor = new AwsCookieProcessor(); + } + return cookieProcessor; + } + + @Override + public Collection getParts() + throws IOException, ServletException { + List partList = + getMultipartFormParametersMap().values().stream() + .flatMap(List::stream) + .collect(Collectors.toList()); + return partList; + } + + @Override + public Part getPart(String s) + throws IOException, ServletException { + // In case there's multiple files with the same fieldName, we return the first one in the list + List values = getMultipartFormParametersMap().get(s); + if (Objects.isNull(values)) { + return null; + } + return getMultipartFormParametersMap().get(s).get(0); + } + + @SuppressFBWarnings({"FILE_UPLOAD_FILENAME", "WEAK_FILENAMEUTILS"}) + protected Map> getMultipartFormParametersMap() throws IOException { + if (multipartFormParameters != null) { + return multipartFormParameters; + } + if (!JakartaServletFileUpload.isMultipartContent(this)) { // isMultipartContent also checks the content type + multipartFormParameters = new HashMap<>(); + return multipartFormParameters; + } + Timer.start("SERVLET_REQUEST_GET_MULTIPART_PARAMS"); + multipartFormParameters = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); + + JakartaServletFileUpload upload = + new JakartaServletFileUpload<>(DiskFileItemFactory.builder().get()); + + try { + List items = upload.parseRequest(this); + for (FileItem item : items) { + String fileName = FilenameUtils.getName(item.getName()); + AwsProxyRequestPart newPart = new AwsProxyRequestPart(item.get()); + newPart.setName(item.getFieldName()); + newPart.setSubmittedFileName(fileName); + newPart.setContentType(item.getContentType()); + newPart.setSize(item.getSize()); + item.getHeaders().getHeaderNames().forEachRemaining(h -> { + newPart.addHeader(h, item.getHeaders().getHeader(h)); + }); + addPart(multipartFormParameters, item.getFieldName(), newPart); + } + } catch (FileUploadException e) { + Timer.stop("SERVLET_REQUEST_GET_MULTIPART_PARAMS"); + log.error("Could not read multipart upload file", e); + } + Timer.stop("SERVLET_REQUEST_GET_MULTIPART_PARAMS"); + return multipartFormParameters; + } + private void addPart(Map> params, String fieldName, Part newPart) { + List partList = params.get(fieldName); + if (Objects.isNull(partList)) { + partList = new ArrayList<>(); + params.put(fieldName, partList); + } + partList.add(newPart); + } + + protected String[] getQueryParamValues(MultiValuedTreeMap qs, String key, boolean isCaseSensitive) { + List value = getQueryParamValuesAsList(qs, key, isCaseSensitive); + if (value == null) { + return null; + } + return value.toArray(new String[0]); + } + + public static List getQueryParamValuesAsList(MultiValuedTreeMap qs, String key, boolean isCaseSensitive) { + if (qs != null) { + if (isCaseSensitive) { + return qs.get(key); + } + + for (String k : qs.keySet()) { + if (k.toLowerCase(Locale.getDefault()).equals(key.toLowerCase(Locale.getDefault()))) { + return qs.get(k); + } + } + } + + return Collections.emptyList(); } + protected Map generateParameterMap(MultiValuedTreeMap qs, ContainerConfig config) { + return generateParameterMap(qs, config, false); + } + + protected Map generateParameterMap(MultiValuedTreeMap qs, ContainerConfig config, boolean decodeQueryParams) { + Map output; + + Map> formEncodedParams = getFormUrlEncodedParametersMap(); + + if (qs == null) { + // Just transform the List values to String[] + return formEncodedParams.entrySet().stream() + .collect(Collectors.toMap(Map.Entry::getKey, (e) -> e.getValue().toArray(new String[0]))); + } + + // decode all keys and values in map + final MultiValuedTreeMap decodedQs = new MultiValuedTreeMap(); + if (decodeQueryParams) { + for (Map.Entry> entry : qs.entrySet()) { + String k = decodeValueIfEncoded(entry.getKey()); + List v = getQueryParamValuesAsList(qs, entry.getKey(), false).stream() + .map(AwsHttpServletRequest::decodeValueIfEncoded) + .collect(Collectors.toList()); + // addAll in case map has 2 keys that are identical once decoded + decodedQs.addAll(k, v); + } + } else { + decodedQs.putAll(qs); + } + + Map> queryStringParams; + if (config.isQueryStringCaseSensitive()) { + queryStringParams = decodedQs; + } else { + // If it's case insensitive, we check the entire map on every parameter + queryStringParams = decodedQs.entrySet().stream().collect( + Collectors.toMap( + Map.Entry::getKey, + e -> getQueryParamValuesAsList(decodedQs, e.getKey(), false) + )); + } + + // Merge formEncodedParams and queryStringParams Maps + output = Stream.of(formEncodedParams, queryStringParams).flatMap(m -> m.entrySet().stream()) + .collect( + Collectors.toMap( + Map.Entry::getKey, + e -> e.getValue().toArray(new String[0]), + // If a parameter is in both Maps, we merge the list of values (and ultimately transform to String[]) + (formParam, queryParam) -> Stream.of(formParam, queryParam).flatMap(Stream::of).toArray(String[]::new) + )); + + return output; + } + + protected String getSchemeFromHeader(Headers headers) { + // if we don't have any headers to deduce the value we assume HTTPS - API Gateway's default + if (headers == null) { + return "https"; + } + String cfScheme = headers.getFirst(CF_PROTOCOL_HEADER_NAME); + if (cfScheme != null && SecurityUtils.isValidScheme(cfScheme)) { + return cfScheme; + } + String gwScheme = headers.getFirst(PROTOCOL_HEADER_NAME); + if (gwScheme != null && SecurityUtils.isValidScheme(gwScheme)) { + return gwScheme; + } + // https is our default scheme + return "https"; + } + + /** + * Prases a header value using the default value separator "," and qualifier separator ";". + * @param headerValue The value to be parsed + * @return A list of SimpleMapEntry objects with all of the possible values for the header. + */ + protected List parseHeaderValue(String headerValue) { + return parseHeaderValue(headerValue, HEADER_VALUE_SEPARATOR, HEADER_QUALIFIER_SEPARATOR); + } /** * Generic method to parse an HTTP header value and split it into a list of key/values for all its components. @@ -273,23 +649,228 @@ protected String generateQueryString(Map parameters) { * key value pairs with key null and the value set to application/json and application/xml respectively. * * @param headerValue The string value for the HTTP header + * @param valueSeparator The separator to be used for parsing header values * @return A list of SimpleMapEntry objects with all of the possible values for the header. */ - protected List> parseHeaderValue(String headerValue) { - List> values = new ArrayList<>(); + protected List parseHeaderValue(String headerValue, String valueSeparator, String qualifierSeparator) { + // Accept: text/html, application/xhtml+xml, application/xml;q=0.9, */*;q=0.8 + // Accept-Language: fr-CH, fr;q=0.9, en;q=0.8, de;q=0.7, *;q=0.5 + // Cookie: name=value; name2=value2; name3=value3 + // X-Custom-Header: YQ== + + List values = new ArrayList<>(); if (headerValue == null) { return values; } - for (String kv : headerValue.split(HEADER_VALUE_SEPARATOR)) { - String[] kvSplit = kv.split(HEADER_KEY_VALUE_SEPARATOR); - - if (kvSplit.length != 2) { - values.add(new AbstractMap.SimpleEntry<>(null, kv.trim())); - } else { - values.add(new AbstractMap.SimpleEntry<>(kvSplit[0].trim(), kvSplit[1].trim())); + for (String curValue : headerValue.split(valueSeparator)) { + float curPreference = 1.0f; + HeaderValue newValue = new HeaderValue(); + newValue.setRawValue(curValue); + + for (String q : curValue.split(qualifierSeparator)) { + + String[] kv = q.split(HEADER_KEY_VALUE_SEPARATOR, 2); + String key = null; + String val = null; + // no separator, set the value only + if (kv.length == 1) { + val = q.trim(); + } + // we have a separator + if (kv.length == 2) { + // if the length of the value is 0 we assume that we are looking at a + // base64 encoded value with padding so we just set the value. This is because + // we assume that empty values in a key/value pair will contain at least a white space + if (kv[1].isEmpty()) { + val = q.trim(); + } + // this was a base64 string with an additional = for padding, set the value only + if ("=".equals(kv[1].trim())) { + val = q.trim(); + } else { // it's a proper key/value set both + key = kv[0].trim(); + val = ("".equals(kv[1].trim()) ? null : kv[1].trim()); + } + } + + if (newValue.getValue() == null) { + newValue.setKey(key); + newValue.setValue(val); + } else { + // special case for quality q= + if ("q".equals(key)) { + curPreference = Float.parseFloat(val); + } else { + newValue.addAttribute(key, val); + } + } } + newValue.setPriority(curPreference); + values.add(newValue); } + + // sort list by preference + values.sort((HeaderValue first, HeaderValue second) -> { + if ((first.getPriority() - second.getPriority()) < .001f) { + return 0; + } + if (first.getPriority() < second.getPriority()) { + return 1; + } + return -1; + }); return values; } + + protected List parseAcceptLanguageHeader(String headerValue) { + // Accept-Language: fr-CH, fr;q=0.9, en;q=0.8, de;q=0.7, *;q=0.5 + List values = this.parseHeaderValue( + headerValue, ",", ";" + ); + + List locales = new ArrayList<>(); + if (values.isEmpty()) { + locales.add(Locale.getDefault()); + } else { + for (HeaderValue locale : values) { + locales.add(parseLanguageTag(locale.getValue())); + } + } + + return locales; + } + + protected Locale parseLanguageTag(String languageTag) { + languageTag = languageTag.trim(); + String language; + String country = ""; + + int indexDash = languageTag.indexOf('-'); + if (indexDash > -1) { + country = languageTag.substring(indexDash + 1).trim(); + language = languageTag.substring(0, indexDash).trim(); + } else { + language = languageTag; + } + + return new Locale(language, country); + } + + static String decodeRequestPath(String requestPath, ContainerConfig config) { + try { + return URLDecoder.decode(requestPath, config.getUriEncoding()); + } catch (UnsupportedEncodingException ex) { + log.error("Could not URL decode the request path, configured encoding not supported: {}", SecurityUtils.encode(config.getUriEncoding())); + // we do not fail at this. + return requestPath; + } + + } + + static String cleanUri(String uri) { + String finalUri = (uri == null ? "/" : uri); + if (finalUri.equals("/")) { + return finalUri; + } + + if (!finalUri.startsWith("/")) { + finalUri = "/" + finalUri; + } + + if (finalUri.endsWith("/")) { + finalUri = finalUri.substring(0, finalUri.length() - 1); + } + + finalUri = finalUri.replaceAll("/+", "/"); + + return finalUri; + } + + public static String decodeValueIfEncoded(String value) { + if (value == null) { + return null; + } + + try { + return URLDecoder.decode(value, LambdaContainerHandler.getContainerConfig().getUriEncoding()); + } catch (UnsupportedEncodingException e) { + log.warn("Could not decode body content - proceeding as if it was already decoded", e); + return value; + } + } + + + /** + * Class that represents a header value. + */ + public static class HeaderValue { + private String key; + private String value; + private String rawValue; + private float priority; + private Map attributes; + + public HeaderValue() { + attributes = new HashMap<>(); + } + + + public String getKey() { + return key; + } + + + public void setKey(String key) { + this.key = key; + } + + + public String getValue() { + return value; + } + + + public void setValue(String value) { + this.value = value; + } + + + public String getRawValue() { + return rawValue; + } + + + public void setRawValue(String rawValue) { + this.rawValue = rawValue; + } + + + public float getPriority() { + return priority; + } + + + public void setPriority(float priority) { + this.priority = priority; + } + + + public Map getAttributes() { + return attributes; + } + + + public void setAttributes(Map attributes) { + this.attributes = attributes; + } + + public void addAttribute(String key, String value) { + attributes.put(key, value); + } + + public String getAttribute(String key) { + return attributes.get(key); + } + } } diff --git a/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/servlet/AwsHttpServletRequestWrapper.java b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/servlet/AwsHttpServletRequestWrapper.java new file mode 100644 index 00000000..58d7282b --- /dev/null +++ b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/servlet/AwsHttpServletRequestWrapper.java @@ -0,0 +1,410 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance + * with the License. A copy of the License is located at + * + * http://aws.amazon.com/apache2.0/ + * + * or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES + * OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions + * and limitations under the License. + */ +package com.amazonaws.serverless.proxy.internal.servlet; + +import com.amazonaws.serverless.proxy.internal.LambdaContainerHandler; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; + +import jakarta.servlet.*; +import jakarta.servlet.http.*; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.UnsupportedEncodingException; +import java.security.Principal; +import java.util.Collection; +import java.util.Enumeration; +import java.util.Locale; +import java.util.Map; + +import static com.amazonaws.serverless.proxy.internal.servlet.AwsProxyHttpServletRequest.cleanUri; + +public class AwsHttpServletRequestWrapper implements HttpServletRequest { + private HttpServletRequest originalRequest; + private String newPath; + private ServletContext ctx; + + public AwsHttpServletRequestWrapper(HttpServletRequest req, String path) { + originalRequest = req; + newPath = path; + ctx = originalRequest.getServletContext(); + } + + public void setServletContext(ServletContext newContext) { + ctx = newContext; + } + + @Override + public String getAuthType() { + return originalRequest.getAuthType(); + } + + @Override + public Cookie[] getCookies() { + return originalRequest.getCookies(); + } + + @Override + public long getDateHeader(String s) { + return originalRequest.getDateHeader(s); + } + + @Override + @SuppressFBWarnings("SERVLET_HEADER") + public String getHeader(String s) { + return originalRequest.getHeader(s); + } + + @Override + public Enumeration getHeaders(String s) { + return originalRequest.getHeaders(s); + } + + @Override + public Enumeration getHeaderNames() { + return originalRequest.getHeaderNames(); + } + + @Override + public int getIntHeader(String s) { + return originalRequest.getIntHeader(s); + } + + @Override + public String getMethod() { + return originalRequest.getMethod(); + } + + @Override + public String getPathInfo() { + String pathInfo = cleanUri(newPath); + return AwsHttpServletRequest.decodeRequestPath(pathInfo, LambdaContainerHandler.getContainerConfig()); + } + + @Override + public String getPathTranslated() { + return originalRequest.getPathTranslated(); + } + + @Override + public String getContextPath() { + return originalRequest.getContextPath(); + } + + @Override + @SuppressFBWarnings(value = "SERVLET_QUERY_STRING", justification = "Already Validated on AwsProxyHttpServletRequest") + public String getQueryString() { + return originalRequest.getQueryString(); + } + + @Override + public String getRemoteUser() { + return originalRequest.getRemoteUser(); + } + + @Override + public boolean isUserInRole(String s) { + return originalRequest.isUserInRole(s); + } + + @Override + public Principal getUserPrincipal() { + return originalRequest.getUserPrincipal(); + } + + @Override + @SuppressFBWarnings(value = "SERVLET_SESSION_ID", justification = "Already Validated on AwsProxyHttpServletRequest") + public String getRequestedSessionId() { + return originalRequest.getRequestedSessionId(); + } + + @Override + public String getRequestURI() { + return cleanUri(getContextPath()) + cleanUri(newPath); + } + + @Override + public StringBuffer getRequestURL() { + String url = ""; + url += getServerName(); + url += cleanUri(getContextPath()); + url += cleanUri(newPath); + + return new StringBuffer(getScheme() + "://" + url); + } + + @Override + public String getServletPath() { + return originalRequest.getServletPath(); + } + + @Override + public HttpSession getSession(boolean b) { + return originalRequest.getSession(b); + } + + @Override + public HttpSession getSession() { + return originalRequest.getSession(); + } + + @Override + public String changeSessionId() { + return originalRequest.changeSessionId(); + } + + @Override + public boolean isRequestedSessionIdValid() { + return originalRequest.isRequestedSessionIdValid(); + } + + @Override + public boolean isRequestedSessionIdFromCookie() { + return originalRequest.isRequestedSessionIdFromCookie(); + } + + @Override + public boolean isRequestedSessionIdFromURL() { + return originalRequest.isRequestedSessionIdFromURL(); + } + + @Override + public boolean authenticate(HttpServletResponse httpServletResponse) throws IOException, ServletException { + return originalRequest.authenticate(httpServletResponse); + } + + @Override + public void login(String s, String s1) throws ServletException { + originalRequest.login(s, s1); + } + + @Override + public void logout() throws ServletException { + originalRequest.logout(); + } + + @Override + public Collection getParts() throws IOException, ServletException { + return originalRequest.getParts(); + } + + @Override + public Part getPart(String s) throws IOException, ServletException { + return originalRequest.getPart(s); + } + + @Override + public T upgrade(Class aClass) throws IOException, ServletException { + return originalRequest.upgrade(aClass); + } + + @Override + public Object getAttribute(String s) { + return originalRequest.getAttribute(s); + } + + @Override + public Enumeration getAttributeNames() { + return originalRequest.getAttributeNames(); + } + + @Override + public String getCharacterEncoding() { + return originalRequest.getCharacterEncoding(); + } + + @Override + public void setCharacterEncoding(String s) throws UnsupportedEncodingException { + originalRequest.setCharacterEncoding(s); + } + + @Override + public int getContentLength() { + return originalRequest.getContentLength(); + } + + @Override + public long getContentLengthLong() { + return originalRequest.getContentLengthLong(); + } + + @Override + @SuppressFBWarnings(value = "SERVLET_CONTENT_TYPE", justification = "Already Validated on AwsProxyHttpServletRequest") + public String getContentType() { + return originalRequest.getContentType(); + } + + @Override + public ServletInputStream getInputStream() throws IOException { + return originalRequest.getInputStream(); + } + + @Override + @SuppressFBWarnings(value = "SERVLET_PARAMETER", justification = "Already Validated on AwsProxyHttpServletRequest") + public String getParameter(String s) { + return originalRequest.getParameter(s); + } + + @Override + @SuppressFBWarnings(value = "SERVLET_PARAMETER", justification = "Already Validated on AwsProxyHttpServletRequest") + public Enumeration getParameterNames() { + return originalRequest.getParameterNames(); + } + + @Override + @SuppressFBWarnings(value = "SERVLET_PARAMETER", justification = "Already Validated on AwsProxyHttpServletRequest") + public String[] getParameterValues(String s) { + return originalRequest.getParameterValues(s); + } + + @Override + @SuppressFBWarnings(value = "SERVLET_PARAMETER", justification = "Already Validated on AwsProxyHttpServletRequest") + public Map getParameterMap() { + return originalRequest.getParameterMap(); + } + + @Override + public String getProtocol() { + return originalRequest.getProtocol(); + } + + @Override + public String getScheme() { + return originalRequest.getScheme(); + } + + @Override + @SuppressFBWarnings(value = "SERVLET_SERVER_NAME", justification = "Already Validated on AwsProxyHttpServletRequest") + public String getServerName() { + return originalRequest.getServerName(); + } + + @Override + public int getServerPort() { + return originalRequest.getServerPort(); + } + + @Override + public BufferedReader getReader() throws IOException { + return originalRequest.getReader(); + } + + @Override + public String getRemoteAddr() { + return originalRequest.getRemoteAddr(); + } + + @Override + public String getRemoteHost() { + return originalRequest.getRemoteAddr(); + } + + @Override + public void setAttribute(String s, Object o) { + originalRequest.setAttribute(s, o); + } + + @Override + public void removeAttribute(String s) { + originalRequest.removeAttribute(s); + } + + @Override + public Locale getLocale() { + return originalRequest.getLocale(); + } + + @Override + public Enumeration getLocales() { + return originalRequest.getLocales(); + } + + @Override + public boolean isSecure() { + return originalRequest.isSecure(); + } + + @Override + public RequestDispatcher getRequestDispatcher(String s) { + return originalRequest.getRequestDispatcher(s); + } + + @Override + public int getRemotePort() { + return originalRequest.getRemotePort(); + } + + @Override + public String getLocalName() { + return originalRequest.getLocalName(); + } + + @Override + public String getLocalAddr() { + return originalRequest.getLocalAddr(); + } + + @Override + public int getLocalPort() { + return originalRequest.getLocalPort(); + } + + @Override + public ServletContext getServletContext() { + return ctx; + } + + @Override + public AsyncContext startAsync() throws IllegalStateException { + return originalRequest.startAsync(); + } + + @Override + public AsyncContext startAsync(ServletRequest servletRequest, ServletResponse servletResponse) throws IllegalStateException { + return originalRequest.startAsync(servletRequest, servletResponse); + } + + @Override + public boolean isAsyncStarted() { + return originalRequest.isAsyncStarted(); + } + + @Override + public boolean isAsyncSupported() { + return originalRequest.isAsyncSupported(); + } + + @Override + public AsyncContext getAsyncContext() { + return originalRequest.getAsyncContext(); + } + + @Override + public DispatcherType getDispatcherType() { + return originalRequest.getDispatcherType(); + } + + @Override + public String getRequestId() { + return originalRequest.getRequestId(); + } + + @Override + public String getProtocolRequestId() { + return originalRequest.getProtocolRequestId(); + } + + @Override + public ServletConnection getServletConnection() { + return originalRequest.getServletConnection(); + } +} diff --git a/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/servlet/AwsHttpServletResponse.java b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/servlet/AwsHttpServletResponse.java index 871f7975..2e1d4827 100644 --- a/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/servlet/AwsHttpServletResponse.java +++ b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/servlet/AwsHttpServletResponse.java @@ -12,20 +12,36 @@ */ package com.amazonaws.serverless.proxy.internal.servlet; -import javax.servlet.ServletOutputStream; -import javax.servlet.WriteListener; -import javax.servlet.http.Cookie; -import javax.servlet.http.HttpServletResponse; -import javax.ws.rs.core.HttpHeaders; -import javax.ws.rs.core.MultivaluedHashMap; +import com.amazonaws.serverless.proxy.internal.LambdaContainerHandler; +import com.amazonaws.serverless.proxy.internal.SecurityUtils; +import com.amazonaws.serverless.proxy.model.AwsProxyRequest; +import com.amazonaws.serverless.proxy.model.Headers; + +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import jakarta.servlet.DispatcherType; +import jakarta.servlet.ServletOutputStream; +import jakarta.servlet.WriteListener; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.ws.rs.core.HttpHeaders; +import java.nio.ByteBuffer; +import jakarta.ws.rs.core.MediaType; import java.io.ByteArrayOutputStream; import java.io.IOException; +import java.io.OutputStreamWriter; import java.io.PrintWriter; -import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; import java.text.SimpleDateFormat; import java.util.*; import java.util.concurrent.CountDownLatch; +import static com.amazonaws.serverless.proxy.RequestReader.API_GATEWAY_EVENT_PROPERTY; + + /** * Basic implementation of the HttpServletResponse object. This is used by the AwsProxyHttpServletResponseWriter * to generate an AwsProxyResponse object. We have an additional getAwsResponseHeaders() method @@ -38,20 +54,26 @@ public class AwsHttpServletResponse // Constants //------------------------------------------------------------- - private static final String HEADER_DATE_FORMAT = "EEE, d MMM yyyy HH:mm:ss z"; - + static final String HEADER_DATE_PATTERN = "EEE, d MMM yyyy HH:mm:ss z"; + static final String COOKIE_DEFAULT_TIME_ZONE = "GMT"; //------------------------------------------------------------- // Variables - Private //------------------------------------------------------------- - private MultivaluedHashMap headers = new MultivaluedHashMap<>(); + private Headers headers = new Headers(); private int statusCode; private String statusMessage; private String responseBody; + private String characterEncoding; + private PrintWriter writer; private ByteArrayOutputStream bodyOutputStream = new ByteArrayOutputStream(); private CountDownLatch writersCountDownLatch; + private HttpServletRequest request; private boolean isCommitted = false; + private CookieProcessor cookieProcessor; + + private Logger log = LoggerFactory.getLogger(AwsHttpServletResponse.class); //------------------------------------------------------------- @@ -63,8 +85,11 @@ public class AwsHttpServletResponse * function while the response is asynchronously written by the underlying container/application * @param latch A latch used to inform the ContainerHandler that we are done receiving the response data */ - public AwsHttpServletResponse(CountDownLatch latch) { + public AwsHttpServletResponse(HttpServletRequest req, CountDownLatch latch) { writersCountDownLatch = latch; + characterEncoding = null; + request = req; + statusCode = 0; } @@ -73,13 +98,13 @@ public AwsHttpServletResponse(CountDownLatch latch) { //------------------------------------------------------------- + @SuppressFBWarnings("COOKIE_USAGE") @Override public void addCookie(Cookie cookie) { - String cookieData = cookie.getName() + "=" + cookie.getValue(); - if (cookie.getPath() != null) { - cookieData += "; Path=" + cookie.getPath(); + if (request != null && request.getDispatcherType() == DispatcherType.INCLUDE && isCommitted()) { + throw new IllegalStateException("Cannot add Cookies for include request when response is committed"); } - setHeader(HttpHeaders.SET_COOKIE, cookieData, false); + setHeader(HttpHeaders.SET_COOKIE, getCookieProcessor().generateHeader(cookie), false); } @@ -103,29 +128,17 @@ public String encodeRedirectURL(String s) { } - @Override - @Deprecated - public String encodeUrl(String s) { - return this.encodeURL(s); - } - - - @Override - @Deprecated - public String encodeRedirectUrl(String s) { - return this.encodeRedirectURL(s); - } - - @Override public void sendError(int i, String s) throws IOException { - setStatus(i, s); + request.setAttribute(AwsHttpServletRequest.DISPATCHER_TYPE_ATTRIBUTE, DispatcherType.ERROR); + setStatus(i); flushBuffer(); } @Override public void sendError(int i) throws IOException { + request.setAttribute(AwsHttpServletRequest.DISPATCHER_TYPE_ATTRIBUTE, DispatcherType.ERROR); setStatus(i); flushBuffer(); } @@ -133,15 +146,26 @@ public void sendError(int i) throws IOException { @Override public void sendRedirect(String s) throws IOException { - setStatus(SC_MOVED_PERMANENTLY); + setStatus(SC_MOVED_TEMPORARILY); addHeader(HttpHeaders.LOCATION, s); flushBuffer(); } + @Override + public void sendRedirect(String location, int sc, boolean clearBuffer) throws IOException { + setStatus(sc); + addHeader(HttpHeaders.LOCATION, location); + if (clearBuffer) { + resetBuffer(); + } + flushBuffer(); + } + @Override public void setDateHeader(String s, long l) { - SimpleDateFormat sdf = new SimpleDateFormat(HEADER_DATE_FORMAT); + if (!canSetHeader()) return; + SimpleDateFormat sdf = new SimpleDateFormat(HEADER_DATE_PATTERN); Date responseDate = new Date(); responseDate.setTime(l); setHeader(s, sdf.format(responseDate), true); @@ -150,7 +174,8 @@ public void setDateHeader(String s, long l) { @Override public void addDateHeader(String s, long l) { - SimpleDateFormat sdf = new SimpleDateFormat(HEADER_DATE_FORMAT); + if (!canSetHeader()) return; + SimpleDateFormat sdf = new SimpleDateFormat(HEADER_DATE_PATTERN); Date responseDate = new Date(); responseDate.setTime(l); setHeader(s, sdf.format(responseDate), false); @@ -159,44 +184,55 @@ public void addDateHeader(String s, long l) { @Override public void setHeader(String s, String s1) { - setHeader(s, s1, true); + if (!canSetHeader()) return; + if (isContentTypeHeader(s)) { + setContentType(s1); + } else { + setHeader(s, s1, true); + } + } + + + private boolean isContentTypeHeader(String s) { + return s.toLowerCase(Locale.getDefault()).equals(HttpHeaders.CONTENT_TYPE.toLowerCase(Locale.getDefault())); } @Override public void addHeader(String s, String s1) { - setHeader(s, s1, false); + if (!canSetHeader()) return; + // TODO: We should probably have a list of headers that we are not allowed to have multiple values for + if (isContentTypeHeader(s)) { + setContentType(s1); + } else { + setHeader(s, s1, false); + } } @Override public void setIntHeader(String s, int i) { + if (!canSetHeader()) return; setHeader(s, "" + i, true); } @Override public void addIntHeader(String s, int i) { + if (!canSetHeader()) return; setHeader(s, "" + i, false); } @Override public void setStatus(int i) { + if (!canSetHeader()) return; statusCode = i; } - - @Override - public void setStatus(int i, String s) { - statusCode = i; - statusMessage = s; - } - - @Override public int getStatus() { - return statusCode; + return (statusCode <= 0?SC_OK:statusCode); } @@ -227,14 +263,12 @@ public Collection getHeaderNames() { @Override public String getCharacterEncoding() { - return headers.getFirst(HttpHeaders.CONTENT_ENCODING); + return characterEncoding; } @Override - public String getContentType() { - return headers.getFirst(HttpHeaders.CONTENT_TYPE); - } + public String getContentType() { return getHeader(HttpHeaders.CONTENT_TYPE); } @Override @@ -254,7 +288,7 @@ public void setWriteListener(WriteListener writeListener) { try { writeListener.onWritePossible(); } catch (IOException e) { - e.printStackTrace(); + log.error("Output stream is not writable", e); } listener = writeListener; @@ -267,10 +301,36 @@ public void write(int b) throws IOException { try { bodyOutputStream.write(b); } catch (Exception e) { - listener.onError(e); + log.error("Cannot write to output stream", e); + if (listener != null) { + listener.onError(e); + } + } + } + + @Override + public void write(ByteBuffer b) throws IOException { + try { + if (b.hasArray()) { + bodyOutputStream.write(b.array(), b.arrayOffset() + b.position(), b.remaining()); + b.position(b.limit()); + } else { + byte[] buf = new byte[b.remaining()]; + b.get(buf); + bodyOutputStream.write(buf); + } + } catch (Exception e) { + log.error("Cannot write to output stream", e); + if (listener != null) { + listener.onError(e); + } } } + @Override + public void flush() throws IOException { + flushBuffer(); + } @Override public void close() @@ -284,31 +344,64 @@ public void close() @Override public PrintWriter getWriter() throws IOException { - return new PrintWriter(bodyOutputStream); + if (null == writer) { + writer = new PrintWriter(new OutputStreamWriter(bodyOutputStream, StandardCharsets.UTF_8)); + } + return writer; } @Override public void setCharacterEncoding(String s) { - setHeader(HttpHeaders.CONTENT_ENCODING, s, true); + if (!canSetHeader()) return; + characterEncoding = s.toUpperCase(Locale.getDefault()); + // The char encoding is being forced, if we already have a content-type header we recreate it + if (headers.getFirst(HttpHeaders.CONTENT_TYPE) != null) { + setContentType(headers.getFirst(HttpHeaders.CONTENT_TYPE)); + } } @Override public void setContentLength(int i) { + if (!canSetHeader()) return; setHeader(HttpHeaders.CONTENT_LENGTH, "" + i, true); } @Override public void setContentLengthLong(long l) { + if (!canSetHeader()) return; setHeader(HttpHeaders.CONTENT_LENGTH, "" + l, true); } @Override public void setContentType(String s) { - setHeader(HttpHeaders.CONTENT_TYPE, s, true); + if (!canSetHeader()) return; + if (s == null) { + return; + } + String contentType = s; + String charEncoding = characterEncoding; + + // TODO: Make the utilities to parse header values from the request object generic and reuse them here + if (s.contains("charset=")) { // we have a forced charset + int charsetIndex = s.indexOf("charset=") + 8; + int endCharsetIndex = s.indexOf(" ", charsetIndex); + if (endCharsetIndex == -1) { + endCharsetIndex = s.length(); + } + charEncoding = s.substring(charsetIndex, endCharsetIndex).toUpperCase(Locale.getDefault()); + contentType = s.split(";")[0]; + } + + if (charEncoding == null) { + setHeader(HttpHeaders.CONTENT_TYPE, String.format("%s", contentType), true); + return; + } + characterEncoding = charEncoding; + setHeader(HttpHeaders.CONTENT_TYPE, String.format("%s; charset=%s", contentType, charEncoding), true); } @@ -326,7 +419,29 @@ public int getBufferSize() { @Override public void flushBuffer() throws IOException { - responseBody = new String(bodyOutputStream.toByteArray()); + if (null != writer) { + writer.flush(); + } + String charset = characterEncoding; + + byte[] respBody = bodyOutputStream.toByteArray(); + + // The content type is json but we have no encoding specified, according to the RFC (https://tools.ietf.org/html/rfc4627#section-3) + // we should attempt to detect the encoding. However, since we are running in Lambda we shouldn't even consider + // big endian systems and it's highly unlikely we'll have apps using UTF-16/32 we simply force UTF-8 + if (headers != null && headers.getFirst(HttpHeaders.CONTENT_TYPE) != null && + headers.getFirst(HttpHeaders.CONTENT_TYPE).toLowerCase(Locale.getDefault()).trim().equals(MediaType.APPLICATION_JSON) && + charset == null) { + charset = "UTF-8"; + } + + // if at this point we are still null, we set the default + if (charset == null) { + charset = LambdaContainerHandler.getContainerConfig().getDefaultContentCharset(); + } + + responseBody = new String(respBody, charset); + log.debug("Response buffer flushed with {} bytes, latch={}", responseBody.length(), writersCountDownLatch.getCount()); isCommitted = true; writersCountDownLatch.countDown(); } @@ -346,14 +461,16 @@ public boolean isCommitted() { @Override public void reset() { - headers = new MultivaluedHashMap<>(); + headers = new Headers(); responseBody = null; + writer = null; bodyOutputStream = new ByteArrayOutputStream(); } @Override public void setLocale(Locale locale) { + if (!canSetHeader()) return; setHeader(HttpHeaders.CONTENT_LANGUAGE, locale.getLanguage(), true); } @@ -380,29 +497,44 @@ byte[] getAwsResponseBodyBytes() { } - Map getAwsResponseHeaders() { - Map responseHeaders = new HashMap<>(); - for (String header : getHeaderNames()) { - responseHeaders.put(header, headers.getFirst(header)); - } + Headers getAwsResponseHeaders() { + return headers; + } - return responseHeaders; + AwsProxyRequest getAwsProxyRequest() { + return (AwsProxyRequest)request.getAttribute(API_GATEWAY_EVENT_PROPERTY); } + CookieProcessor getCookieProcessor(){ + if (cookieProcessor == null) { + cookieProcessor = new AwsCookieProcessor(); + } + return cookieProcessor; + } //------------------------------------------------------------- // Methods - Private //------------------------------------------------------------- private void setHeader(String key, String value, boolean overwrite) { - List values = headers.get(key); + if (!canSetHeader()) return; + String encodedKey = SecurityUtils.crlf(key); + String encodedValue = SecurityUtils.crlf(value); + List values = headers.get(encodedKey); if (values == null || overwrite) { values = new ArrayList<>(); } - values.add(value); + if (value == null && overwrite) { + headers.remove(encodedKey); + } else if (value != null) { + values.add(encodedValue); + headers.put(encodedKey, values); + } + } - headers.put(key, values); + private boolean canSetHeader() { + return request == null || request.getDispatcherType() != DispatcherType.INCLUDE; } } diff --git a/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/servlet/AwsHttpSession.java b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/servlet/AwsHttpSession.java new file mode 100644 index 00000000..2163320d --- /dev/null +++ b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/servlet/AwsHttpSession.java @@ -0,0 +1,135 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance + * with the License. A copy of the License is located at + * + * http://aws.amazon.com/apache2.0/ + * + * or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES + * OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions + * and limitations under the License. + */ +package com.amazonaws.serverless.proxy.internal.servlet; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import jakarta.servlet.ServletContext; +import jakarta.servlet.http.HttpSession; + +import java.time.Instant; +import java.util.Collections; +import java.util.Enumeration; +import java.util.HashMap; +import java.util.Map; + +/** + * This class emulates the behavior of an HTTP session. At the moment a new instance of this class + * is created for each request/event. In the future, we may define a session id resolver interface + * allowing clients to store a map of sessions within a Lambda container. + */ +public class AwsHttpSession implements HttpSession { + + public static final int SESSION_DURATION_SEC = 60 * 30; + + private static final Logger log = LoggerFactory.getLogger(AwsHttpSession.class); + private Map attributes; + private String id; + private long creationTime; + private int maxInactiveInterval; + private long lastAccessedTime; + private boolean valid; + + /** + * @param id A unique session identifier + */ + public AwsHttpSession(String id) { + if (null == id) { + throw new RuntimeException("HTTP session id (from request ID) cannot be null"); + } + this.id = id; + attributes = new HashMap<>(); + creationTime = Instant.now().getEpochSecond(); + maxInactiveInterval = SESSION_DURATION_SEC; + lastAccessedTime = creationTime; + valid = true; + } + + @Override + public long getCreationTime() { + return creationTime; + } + + @Override + public String getId() { + return id; + } + + @Override + public long getLastAccessedTime() { + return lastAccessedTime; + } + + @Override + public ServletContext getServletContext() { + return null; + } + + @Override + public void setMaxInactiveInterval(int interval) { + maxInactiveInterval = interval; + } + + @Override + public int getMaxInactiveInterval() { + return maxInactiveInterval; + } + + @Override + public Object getAttribute(String name) { + touch(); + return attributes.get(name); + } + + @Override + public Enumeration getAttributeNames() { + touch(); + return Collections.enumeration(attributes.keySet()); + } + + @Override + public void setAttribute(String name, Object value) { + touch(); + attributes.put(name, value); + } + + @Override + public void removeAttribute(String name) { + touch(); + attributes.remove(name); + } + + @Override + public void invalidate() { + valid = false; + attributes.clear(); + } + + @Override + public boolean isNew() { + return lastAccessedTime == creationTime; + } + + private void touch() { + lastAccessedTime = Instant.now().getEpochSecond(); + } + + boolean isValid() { + if (lastAccessedTime - creationTime < maxInactiveInterval) { + return valid; + } else { + return false; + } + } +} diff --git a/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/servlet/AwsLambdaServletContainerHandler.java b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/servlet/AwsLambdaServletContainerHandler.java index 82d73b20..7437449e 100644 --- a/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/servlet/AwsLambdaServletContainerHandler.java +++ b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/servlet/AwsLambdaServletContainerHandler.java @@ -12,26 +12,35 @@ */ package com.amazonaws.serverless.proxy.internal.servlet; -import com.amazonaws.serverless.proxy.internal.ExceptionHandler; +import com.amazonaws.serverless.exceptions.ContainerInitializationException; +import com.amazonaws.serverless.proxy.ExceptionHandler; import com.amazonaws.serverless.proxy.internal.LambdaContainerHandler; -import com.amazonaws.serverless.proxy.internal.RequestReader; -import com.amazonaws.serverless.proxy.internal.ResponseWriter; -import com.amazonaws.serverless.proxy.internal.SecurityContextWriter; - -import javax.servlet.ServletContext; -import javax.servlet.ServletException; -import javax.servlet.http.HttpServletRequest; -import javax.servlet.http.HttpServletResponse; +import com.amazonaws.serverless.proxy.RequestReader; +import com.amazonaws.serverless.proxy.ResponseWriter; +import com.amazonaws.serverless.proxy.SecurityContextWriter; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import jakarta.servlet.*; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpServletResponseWrapper; + import java.io.IOException; +import java.util.*; + /** * Abstract extension of the code LambdaContainerHandler object that adds protected variables for the * ServletContext and FilterChainManager. This object should be extended by the framework-specific * implementations that want to support the servlet 3.1 specs. - * @param - * @param - * @param - * @param + * + * Because Lambda only allows one event per container at a time, this object also acts as the RequestDispatcher + * @param The expected request object. This is the model class that the event JSON is de-serialized to + * @param The expected Lambda function response object. Responses from the container will be written to this model object + * @param The request type for the wrapped Java container + * @param The response or response writer type for the wrapped Java container */ public abstract class AwsLambdaServletContainerHandler filterChainManager; //------------------------------------------------------------- // Variables - Protected //------------------------------------------------------------- - - protected ServletContext servletContext; protected StartupHandler startupHandler; + protected ServletContext servletContext; //------------------------------------------------------------- // Constructors //------------------------------------------------------------- - protected AwsLambdaServletContainerHandler(RequestReader requestReader, + protected AwsLambdaServletContainerHandler(Class requestTypeClass, + Class responseTypeClass, + RequestReader requestReader, ResponseWriter responseWriter, SecurityContextWriter securityContextWriter, ExceptionHandler exceptionHandler) { - super(requestReader, responseWriter, securityContextWriter, exceptionHandler); + super(requestTypeClass, responseTypeClass, requestReader, responseWriter, securityContextWriter, exceptionHandler); + // set the default log formatter for servlet implementations + setLogFormatter(new ApacheCombinedServletLogFormatter<>()); + setServletContext(new AwsServletContext(this)); + } + + //------------------------------------------------------------- + // Methods - Public + //------------------------------------------------------------- + + /** + * You can use the onStartup to intercept the ServletContext as the Spring application is + * initialized and inject custom values. The StartupHandler is called after the onStartup method + * of the LambdaSpringApplicationinitializer implementation. For example, you can use this method to + * add custom filters to the servlet context: + * + *
+     * {@code
+     *      handler = SpringLambdaContainerHandler.getAwsProxyHandler(EchoSpringAppConfig.class);
+     *      handler.onStartup(c -> {
+     *      // the "c" parameter to this function is the initialized servlet context
+     *      c.addFilter("CustomHeaderFilter", CustomHeaderFilter.class);
+     *      });
+     * }
+     * 
+ * @param h A lambda expression that implements the StartupHandler functional interface + */ + public void onStartup(final StartupHandler h) { + startupHandler = h; + startupHandler.onStartup(getServletContext()); } @@ -86,29 +125,11 @@ protected void setServletContext(final ServletContext context) { servletContext = context; // We assume custom implementations of the RequestWriter for HttpServletRequest will reuse // the existing AwsServletContext object since it has no dependencies other than the Lambda context - filterChainManager = new AwsFilterChainManager((AwsServletContext)context); + filterChainManager = new AwsFilterChainManager((AwsServletContext)servletContext); } - - /** - * You can use the onStartup to intercept the ServletContext as the Spring application is - * initialized and inject custom values. The StartupHandler is called after the onStartup method - * of the LambdaSpringApplicationinitializer implementation. For example, you can use this method to - * add custom filters to the servlet context: - * - *
-     * {@code
-     *      handler = SpringLambdaContainerHandler.getAwsProxyHandler(EchoSpringAppConfig.class);
-     *      handler.onStartup(c -> {
-     *      // the "c" parameter to this function is the initialized servlet context
-     *      c.addFilter("CustomHeaderFilter", CustomHeaderFilter.class);
-     *      });
-     * }
-     * 
- * @param h A lambda expression that implements the StartupHandler functional interface - */ - public void onStartup(final StartupHandler h) { - startupHandler = h; + protected FilterChain getFilterChain(HttpServletRequest req, Servlet servlet) { + return filterChainManager.getFilterChain(req, servlet); } @@ -120,12 +141,54 @@ public void onStartup(final StartupHandler h) { * Applies the filter chain in the request lifecycle * @param request The Request object. This must be an implementation of HttpServletRequest * @param response The response object. This must be an implementation of HttpServletResponse + * @param servlet Servlet at the end of the chain (optional). + * @throws IOException + * @throws ServletException */ - protected void doFilter(ContainerRequestType request, ContainerResponseType response) throws IOException, ServletException { - FilterChainHolder chain = filterChainManager.getFilterChain(request); + protected void doFilter(HttpServletRequest request, HttpServletResponse response, Servlet servlet) throws IOException, ServletException { + if (AwsHttpServletRequest.class.isAssignableFrom(request.getClass())) { + ((AwsHttpServletRequest)request).setContainerHandler(this); + } + + FilterChain chain = getFilterChain(request, servlet); chain.doFilter(request, response); + if(requiresAsyncReDispatch(request)) { + chain = getFilterChain(request, servlet); + chain.doFilter(request, response); + } + // if for some reason the response wasn't flushed yet, we force it here unless it's being processed asynchronously (WebFlux) + if (!response.isCommitted() && request.getDispatcherType() != DispatcherType.ASYNC) { + response.flushBuffer(); + } } + private boolean requiresAsyncReDispatch(HttpServletRequest request) { + if (request.isAsyncStarted()) { + AsyncContext asyncContext = request.getAsyncContext(); + return asyncContext instanceof AwsAsyncContext + && ((AwsAsyncContext) asyncContext).isDispatchStarted(); + } + return false; + } + + @Override + public void initialize() throws ContainerInitializationException { + // we expect all servlets to be wrapped in an AwsServletRegistration + ArrayList registrations = new ArrayList<>((Collection)getServletContext().getServletRegistrations().values()); + registrations.sort(AwsServletRegistration::compareTo); + for (AwsServletRegistration r : registrations) { + if (r.getLoadOnStartup() == -1) { // skip Servlets that can be lazily loaded + continue; + } + try { + if (r.getServlet() != null) { + r.getServlet().init(r.getServletConfig()); + } + } catch (ServletException e) { + throw new ContainerInitializationException("Could not initialize servlet " + r.getName(), e); + } + } + } //------------------------------------------------------------- // Inner Class - diff --git a/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/servlet/AwsProxyHttpServletRequest.java b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/servlet/AwsProxyHttpServletRequest.java index c11111a7..c2a257d3 100644 --- a/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/servlet/AwsProxyHttpServletRequest.java +++ b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/servlet/AwsProxyHttpServletRequest.java @@ -13,51 +13,37 @@ package com.amazonaws.serverless.proxy.internal.servlet; -import com.amazonaws.serverless.proxy.internal.model.AwsProxyRequest; +import com.amazonaws.serverless.proxy.internal.HttpUtils; +import com.amazonaws.serverless.proxy.internal.LambdaContainerHandler; +import com.amazonaws.serverless.proxy.internal.SecurityUtils; +import com.amazonaws.serverless.proxy.model.AwsProxyRequest; +import com.amazonaws.serverless.proxy.model.ContainerConfig; +import com.amazonaws.serverless.proxy.model.Headers; +import com.amazonaws.serverless.proxy.model.RequestSource; import com.amazonaws.services.lambda.runtime.Context; - -import org.apache.commons.fileupload.FileItem; -import org.apache.commons.fileupload.FileUploadException; -import org.apache.commons.fileupload.servlet.ServletFileUpload; - -import javax.servlet.AsyncContext; -import javax.servlet.ReadListener; -import javax.servlet.RequestDispatcher; -import javax.servlet.ServletException; -import javax.servlet.ServletInputStream; -import javax.servlet.ServletRequest; -import javax.servlet.ServletResponse; -import javax.servlet.http.Cookie; -import javax.servlet.http.HttpServletResponse; -import javax.servlet.http.HttpUpgradeHandler; -import javax.servlet.http.Part; -import javax.ws.rs.core.HttpHeaders; -import javax.ws.rs.core.MediaType; -import javax.ws.rs.core.SecurityContext; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import jakarta.servlet.*; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpUpgradeHandler; +import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.core.SecurityContext; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import java.io.BufferedReader; -import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.StringReader; import java.io.UnsupportedEncodingException; -import java.net.URLDecoder; +import java.nio.charset.Charset; import java.security.Principal; -import java.text.ParseException; -import java.text.SimpleDateFormat; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Base64; -import java.util.Collection; -import java.util.Collections; -import java.util.Date; -import java.util.Enumeration; -import java.util.HashMap; -import java.util.Iterator; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.TreeMap; - +import java.time.Instant; +import java.time.ZonedDateTime; +import java.time.format.DateTimeParseException; +import java.util.*; +import java.util.stream.Collectors; +import java.util.stream.Stream; /** * Implementation of the HttpServletRequest interface that supports AwsProxyRequest object. @@ -72,28 +58,36 @@ public class AwsProxyHttpServletRequest extends AwsHttpServletRequest { private AwsProxyRequest request; private SecurityContext securityContext; - private Map> urlEncodedFormParameters; - private Map multipartFormParameters; - + private AwsAsyncContext asyncContext; + private static Logger log = LoggerFactory.getLogger(AwsProxyHttpServletRequest.class); + private ContainerConfig config; //------------------------------------------------------------- // Constructors //------------------------------------------------------------- + public AwsProxyHttpServletRequest(AwsProxyRequest awsProxyRequest, Context lambdaContext, SecurityContext awsSecurityContext) { + this(awsProxyRequest, lambdaContext, awsSecurityContext, LambdaContainerHandler.getContainerConfig()); + } + + + public AwsProxyHttpServletRequest(AwsProxyRequest awsProxyRequest, Context lambdaContext, SecurityContext awsSecurityContext, ContainerConfig config) { super(lambdaContext); this.request = awsProxyRequest; this.securityContext = awsSecurityContext; - - this.urlEncodedFormParameters = getFormUrlEncodedParametersMap(); - this.multipartFormParameters = getMultipartFormParametersMap(); + this.config = config; } + public AwsProxyRequest getAwsProxyRequest() { + return this.request; + } //------------------------------------------------------------- // Implementation - HttpServletRequest //------------------------------------------------------------- + @Override public String getAuthType() { return securityContext.getAuthenticationScheme(); @@ -102,7 +96,10 @@ public String getAuthType() { @Override public Cookie[] getCookies() { - String cookieHeader = getHeaderCaseInsensitive(HttpHeaders.COOKIE); + if (request.getMultiValueHeaders() == null) { + return new Cookie[0]; + } + String cookieHeader = request.getMultiValueHeaders().getFirst(HttpHeaders.COOKIE); if (cookieHeader == null) { return new Cookie[0]; } @@ -112,50 +109,56 @@ public Cookie[] getCookies() { @Override public long getDateHeader(String s) { - String dateString = getHeaderCaseInsensitive(HttpHeaders.DATE); + if (request.getMultiValueHeaders() == null) { + return -1L; + } + String dateString = request.getMultiValueHeaders().getFirst(s); if (dateString == null) { - return new Date().getTime(); + return -1L; } - SimpleDateFormat dateFormatter = new SimpleDateFormat(HEADER_DATE_FORMAT); try { - return dateFormatter.parse(dateString).getTime(); - } catch (ParseException e) { - e.printStackTrace(); - return new Date().getTime(); + return Instant.from(ZonedDateTime.parse(dateString, dateFormatter)).toEpochMilli(); + } catch (DateTimeParseException e) { + log.warn("Invalid date header in request" + SecurityUtils.crlf(dateString)); + return -1L; } } @Override public String getHeader(String s) { - return getHeaderCaseInsensitive(s); + List values = getHeaderValues(s); + if (values == null || values.size() == 0) { + return null; + } + return values.get(0); } @Override public Enumeration getHeaders(String s) { - String headerValue = getHeaderCaseInsensitive(s); - if (headerValue == null) { - return Collections.enumeration(new ArrayList()); + if (request.getMultiValueHeaders() == null || request.getMultiValueHeaders().get(s) == null) { + return Collections.emptyEnumeration(); } - List valueCollection = new ArrayList<>(); - valueCollection.add(headerValue); - return Collections.enumeration(valueCollection); + return Collections.enumeration(request.getMultiValueHeaders().get(s)); } @Override public Enumeration getHeaderNames() { - if (request.getHeaders() == null) { + if (request.getMultiValueHeaders() == null) { return Collections.emptyEnumeration(); } - return Collections.enumeration(request.getHeaders().keySet()); + return Collections.enumeration(request.getMultiValueHeaders().keySet()); } @Override public int getIntHeader(String s) { - String headerValue = getHeaderCaseInsensitive(s); + if (request.getMultiValueHeaders() == null) { + return -1; + } + String headerValue = request.getMultiValueHeaders().getFirst(s); if (headerValue == null) { return -1; } @@ -172,11 +175,8 @@ public String getMethod() { @Override public String getPathInfo() { - String pathInfo = getServletPath().replace(getContextPath(), ""); - if (!pathInfo.startsWith("/")) { - pathInfo = "/" + pathInfo; - } - return pathInfo; + String pathInfo = cleanUri(request.getPath()); + return decodeRequestPath(pathInfo, LambdaContainerHandler.getContainerConfig()); } @@ -189,13 +189,22 @@ public String getPathTranslated() { @Override public String getContextPath() { - return request.getRequestContext().getStage(); + return generateContextPath(config, request.getRequestContext().getStage()); } @Override public String getQueryString() { - return this.generateQueryString(request.getQueryStringParameters()); + try { + return this.generateQueryString( + request.getMultiValueQueryStringParameters(), + // ALB does not automatically decode parameters, so we don't want to re-encode them + request.getRequestSource() != RequestSource.ALB, + config.getUriEncoding()); + } catch (ServletException e) { + log.error("Could not generate query string", e); + return null; + } } @@ -217,32 +226,19 @@ public Principal getUserPrincipal() { return securityContext.getUserPrincipal(); } + @Override public String getRequestURI() { - return request.getPath(); + return cleanUri(getContextPath()) + cleanUri(request.getPath()); } @Override public StringBuffer getRequestURL() { - String url = ""; - url += getServerName(); - url += "/"; - url += getContextPath(); - url += "/"; - url += request.getPath(); - - url = url.replaceAll("/+", "/"); - - return new StringBuffer(getScheme() + "://" + url); + return generateRequestURL(request.getPath()); } - @Override - public String getServletPath() { - return request.getPath(); - } - @Override public boolean authenticate(HttpServletResponse httpServletResponse) throws IOException, ServletException { @@ -256,98 +252,53 @@ public void login(String s, String s1) throw new UnsupportedOperationException(); } + @Override public void logout() throws ServletException { throw new UnsupportedOperationException(); } - - @Override - public Collection getParts() - throws IOException, ServletException { - return multipartFormParameters.values(); - } - - - @Override - public Part getPart(String s) - throws IOException, ServletException { - return multipartFormParameters.get(s); - } - - @Override public T upgrade(Class aClass) throws IOException, ServletException { - return null; + throw new UnsupportedOperationException(); } - //------------------------------------------------------------- // Implementation - ServletRequest //------------------------------------------------------------- + @Override public String getCharacterEncoding() { - // we only look at content-type because content-encoding should only be used for - // "binary" requests such as gzip/deflate. - String contentTypeHeader = getHeaderCaseInsensitive(HttpHeaders.CONTENT_TYPE); - if (contentTypeHeader == null) { - return null; - } - - String[] contentTypeValues = contentTypeHeader.split(HEADER_VALUE_SEPARATOR); - if (contentTypeValues.length <= 1) { - return null; + if (request.getMultiValueHeaders() == null) { + return config.getDefaultContentCharset(); } - - for (String contentTypeValue : contentTypeValues) { - if (contentTypeValue.trim().startsWith(ENCODING_VALUE_KEY)) { - String[] encodingValues = contentTypeValue.split(HEADER_KEY_VALUE_SEPARATOR); - if (encodingValues.length <= 1) { - return null; - } - return encodingValues[1]; - } - } - return null; + Charset charset = HttpUtils.parseCharacterEncoding(request.getMultiValueHeaders().getFirst(HttpHeaders.CONTENT_TYPE),null); + return charset != null ? charset.name() : null; } @Override - public void setCharacterEncoding(String s) throws UnsupportedEncodingException { - String currentContentType = request.getHeaders().get(HttpHeaders.CONTENT_TYPE); - if (currentContentType == null) { - request.getHeaders().put( - HttpHeaders.CONTENT_TYPE, - HEADER_VALUE_SEPARATOR + " " + ENCODING_VALUE_KEY + HEADER_KEY_VALUE_SEPARATOR + s); + public void setCharacterEncoding(String s) + throws UnsupportedEncodingException { + if (request.getMultiValueHeaders() == null) { + request.setMultiValueHeaders(new Headers()); + } + String currentContentType = request.getMultiValueHeaders().getFirst(HttpHeaders.CONTENT_TYPE); + if (currentContentType == null || currentContentType.isEmpty()) { + log.debug("Called set character encoding to " + SecurityUtils.crlf(s) + " on a request without a content type. Character encoding will not be set"); return; } - if (currentContentType.contains(HEADER_VALUE_SEPARATOR)) { - String[] contentTypeValues = currentContentType.split(HEADER_VALUE_SEPARATOR); - StringBuilder contentType = new StringBuilder(contentTypeValues[0]); - - for (String contentTypeValue : contentTypeValues) { - String contentTypeString = HEADER_VALUE_SEPARATOR + " " + contentTypeValue; - if (contentTypeValue.trim().startsWith(ENCODING_VALUE_KEY)) { - contentTypeString = HEADER_VALUE_SEPARATOR + " " + ENCODING_VALUE_KEY + HEADER_KEY_VALUE_SEPARATOR + s; - } - contentType.append(contentTypeString); - } - - request.getHeaders().put(HttpHeaders.CONTENT_TYPE, contentType.toString()); - } else { - request.getHeaders().put( - HttpHeaders.CONTENT_TYPE, - currentContentType + HEADER_VALUE_SEPARATOR + " " + ENCODING_VALUE_KEY + HEADER_KEY_VALUE_SEPARATOR + s); - } + request.getMultiValueHeaders().putSingle(HttpHeaders.CONTENT_TYPE, HttpUtils.appendCharacterEncoding(currentContentType, s)); } + @Override public int getContentLength() { - String headerValue = getHeaderCaseInsensitive(HttpHeaders.CONTENT_LENGTH); + String headerValue = request.getMultiValueHeaders().getFirst(HttpHeaders.CONTENT_LENGTH); if (headerValue == null) { return -1; } @@ -357,7 +308,7 @@ public int getContentLength() { @Override public long getContentLengthLong() { - String headerValue = getHeaderCaseInsensitive(HttpHeaders.CONTENT_LENGTH); + String headerValue = request.getMultiValueHeaders().getFirst(HttpHeaders.CONTENT_LENGTH); if (headerValue == null) { return -1; } @@ -367,162 +318,150 @@ public long getContentLengthLong() { @Override public String getContentType() { - return getHeaderCaseInsensitive(HttpHeaders.CONTENT_TYPE); - } - - - @Override - public ServletInputStream getInputStream() throws IOException { - byte[] bodyBytes = request.getBody().getBytes(); - if (request.isBase64Encoded()) { - bodyBytes = Base64.getDecoder().decode(request.getBody()); + String contentTypeHeader = request.getMultiValueHeaders().getFirst(HttpHeaders.CONTENT_TYPE); + if (contentTypeHeader == null || "".equals(contentTypeHeader.trim())) { + return null; } - ByteArrayInputStream requestBodyStream = new ByteArrayInputStream(bodyBytes); - return new ServletInputStream() { - - private ReadListener listener; - - @Override - public boolean isFinished() { - return true; - } - - - @Override - public boolean isReady() { - return true; - } - - - @Override - public void setReadListener(ReadListener readListener) { - listener = readListener; - try { - listener.onDataAvailable(); - } catch (IOException e) { - e.printStackTrace(); - } - } - - @Override - public int read() throws IOException { - int readByte = requestBodyStream.read(); - if (requestBodyStream.available() == 0 && listener != null) { - listener.onAllDataRead(); - } - return readByte; - } - }; + return contentTypeHeader; } - @Override public String getParameter(String s) { - String queryStringParameter = getQueryStringParameterCaseInsensitive(s); - if (queryStringParameter != null) { - return queryStringParameter; - } - String[] bodyParams = getFormBodyParameterCaseInsensitive(s); - if (bodyParams == null || bodyParams.length == 0) { - return null; - } else { - return bodyParams[0]; + // decode key if ALB + if (request.getRequestSource() == RequestSource.ALB) { + s = decodeValueIfEncoded(s); } + + String queryStringParameter = getFirstQueryParamValue(request.getMultiValueQueryStringParameters(), s, config.isQueryStringCaseSensitive()); + if (queryStringParameter != null) { + if (request.getRequestSource() == RequestSource.ALB) { + queryStringParameter = decodeValueIfEncoded(queryStringParameter); + } + return queryStringParameter; + } + + String[] bodyParams = getFormBodyParameterCaseInsensitive(s); + if (bodyParams.length == 0) { + return null; + } else { + return bodyParams[0]; + } } @Override public Enumeration getParameterNames() { - List paramNames = new ArrayList<>(); - if (request.getQueryStringParameters() != null) { - paramNames.addAll(request.getQueryStringParameters().keySet()); + Set formParameterNames = getFormUrlEncodedParametersMap().keySet(); + if (request.getMultiValueQueryStringParameters() == null) { + return Collections.enumeration(formParameterNames); + } + + Set paramNames = request.getMultiValueQueryStringParameters().keySet(); + if (request.getRequestSource() == RequestSource.ALB) { + paramNames = paramNames.stream().map(AwsProxyHttpServletRequest::decodeValueIfEncoded).collect(Collectors.toSet()); } - paramNames.addAll(urlEncodedFormParameters.keySet()); - return Collections.enumeration(paramNames); + + return Collections.enumeration( + Stream.concat(formParameterNames.stream(), paramNames.stream()) + .collect(Collectors.toSet())); } @Override + @SuppressFBWarnings("PZLA_PREFER_ZERO_LENGTH_ARRAYS") // suppressing this as according to the specs we should be returning null here if we can't find params public String[] getParameterValues(String s) { - List values = new ArrayList<>(); - String queryStringValue = getQueryStringParameterCaseInsensitive(s); - if (queryStringValue != null) { - values.add(queryStringValue); + + // decode key if ALB + if (request.getRequestSource() == RequestSource.ALB) { + s = decodeValueIfEncoded(s); } - String[] formBodyValues = getFormBodyParameterCaseInsensitive(s); - if (formBodyValues != null) { - values.addAll(Arrays.asList(formBodyValues)); + List values = getQueryParamValuesAsList(request.getMultiValueQueryStringParameters(), s, config.isQueryStringCaseSensitive()); + + // copy list so we don't modifying the underlying multi-value query params + if (values != null) { + values = new ArrayList<>(values); + } else { + values = new ArrayList<>(); + } + + // decode values if ALB + if (values != null && request.getRequestSource() == RequestSource.ALB) { + values = values.stream().map(AwsHttpServletRequest::decodeValueIfEncoded).collect(Collectors.toList()); } + values.addAll(Arrays.asList(getFormBodyParameterCaseInsensitive(s))); + if (values.size() == 0) { return null; } else { - String[] valuesArray = new String[values.size()]; - valuesArray = values.toArray(valuesArray); - return valuesArray; + return values.toArray(new String[0]); } } @Override public Map getParameterMap() { - Map output = new HashMap<>(); - - Map> params = urlEncodedFormParameters; - if (params == null) { - params = new HashMap<>(); - } - - if (request.getQueryStringParameters() != null) { - for (Map.Entry entry : request.getQueryStringParameters().entrySet()) { - if (params.containsKey(entry.getKey())) { - params.get(entry.getKey()).add(entry.getValue()); - } else { - List valueList = new ArrayList<>(); - valueList.add(entry.getValue()); - params.put(entry.getKey(), valueList); - } - } - } - - for (Map.Entry> entry : params.entrySet()) { - String[] valuesArray = new String[entry.getValue().size()]; - valuesArray = entry.getValue().toArray(valuesArray); - output.put(entry.getKey(), valuesArray); - } - return output; + return generateParameterMap(request.getMultiValueQueryStringParameters(), config, request.getRequestSource() == RequestSource.ALB); } @Override public String getProtocol() { - // TODO: We should have a cloudfront protocol header - return null; + return request.getRequestContext().getProtocol(); } @Override public String getScheme() { - String headerValue = getHeaderCaseInsensitive(CF_PROTOCOL_HEADER_NAME); - if (headerValue == null) { - return "https"; - } - return headerValue; + return getSchemeFromHeader(request.getMultiValueHeaders()); } @Override public String getServerName() { - String name = getHeaderCaseInsensitive(HttpHeaders.HOST); + String region = System.getenv("AWS_REGION"); + if (region == null) { + // this is not a critical failure, we just put a static region in the URI + region = "us-east-1"; + } + + if (request.getMultiValueHeaders() != null && request.getMultiValueHeaders().containsKey(HOST_HEADER_NAME)) { + String hostHeader = request.getMultiValueHeaders().getFirst(HOST_HEADER_NAME); + if (SecurityUtils.isValidHost(hostHeader, request.getRequestContext().getApiId(), request.getRequestContext().getElb(), region)) { + return hostHeader; + } + } + + return new StringBuilder().append(request.getRequestContext().getApiId()) + .append(".execute-api.") + .append(region) + .append(".amazonaws.com").toString(); + } + + @Override + public int getServerPort() { + if (request.getMultiValueHeaders() == null) { + return 443; + } + String port = request.getMultiValueHeaders().getFirst(PORT_HEADER_NAME); + if (SecurityUtils.isValidPort(port)) { + return Integer.parseInt(port); + } else { + return 443; // default port + } + } - if (name == null || name.length() == 0) { - name = "lambda.amazonaws.com"; + @Override + public ServletInputStream getInputStream() throws IOException { + if (requestInputStream == null) { + requestInputStream = new AwsServletInputStream(bodyStringToInputStream(request.getBody(), request.isBase64Encoded())); } - return name; + return requestInputStream; } + @Override public BufferedReader getReader() throws IOException { @@ -532,45 +471,45 @@ public BufferedReader getReader() @Override public String getRemoteAddr() { + if (request.getRequestContext() == null || request.getRequestContext().getIdentity() == null) { + return "127.0.0.1"; + } + if (request.getRequestSource().equals(RequestSource.ALB)) { + return Objects.nonNull(request.getHeaders()) ? + request.getHeaders().get(CLIENT_IP_HEADER) : + request.getMultiValueHeaders().getFirst(CLIENT_IP_HEADER); + } return request.getRequestContext().getIdentity().getSourceIp(); } @Override public String getRemoteHost() { - return getHeaderCaseInsensitive(HttpHeaders.HOST); + String hostHeader; + if (request.getRequestSource().equals(RequestSource.ALB)) { + hostHeader = Objects.nonNull(request.getHeaders()) ? + request.getHeaders().get(HttpHeaders.HOST) : + request.getMultiValueHeaders().getFirst(HttpHeaders.HOST); + } else { + hostHeader = request.getMultiValueHeaders().getFirst(HttpHeaders.HOST); + } + // the host header has the form host:port, so we split the string to get the host part + return Arrays.asList(hostHeader.split(":")).get(0); } + @Override public Locale getLocale() { - List> values = this.parseHeaderValue( - getHeaderCaseInsensitive(HttpHeaders.ACCEPT_LANGUAGE) - ); - if (values.size() == 0) { - return Locale.getDefault(); - } - return new Locale(values.get(0).getValue()); + List locales = parseAcceptLanguageHeader(request.getMultiValueHeaders().getFirst(HttpHeaders.ACCEPT_LANGUAGE)); + return locales.size() == 0 ? Locale.getDefault() : locales.get(0); } - @Override public Enumeration getLocales() { - List> values = this.parseHeaderValue( - getHeaderCaseInsensitive(HttpHeaders.ACCEPT_LANGUAGE) - ); - List locales = new ArrayList<>(); - if (values.size() == 0) { - locales.add(Locale.getDefault()); - } else { - for (Map.Entry locale : values) { - locales.add(new Locale(locale.getValue())); - } - } - + List locales = parseAcceptLanguageHeader(request.getMultiValueHeaders().getFirst(HttpHeaders.ACCEPT_LANGUAGE)); return Collections.enumeration(locales); } - @Override public boolean isSecure() { return securityContext.isSecure(); @@ -579,141 +518,120 @@ public boolean isSecure() { @Override public RequestDispatcher getRequestDispatcher(String s) { - return null; + return getServletContext().getRequestDispatcher(s); } - @Override - public String getRealPath(String s) { - // we are in an archive on a remote server - return null; - } - @Override public int getRemotePort() { + if (request.getRequestSource().equals(RequestSource.ALB)) { + String portHeader; + portHeader = Objects.nonNull(request.getHeaders()) ? + request.getHeaders().get(PORT_HEADER_NAME) : + request.getMultiValueHeaders().getFirst(PORT_HEADER_NAME); + if (Objects.nonNull(portHeader)) { + return Integer.parseInt(portHeader); + } + } return 0; } + @Override - public AsyncContext startAsync() throws IllegalStateException { - return null; + public boolean isAsyncSupported() { + return true; } - @Override - public AsyncContext startAsync(ServletRequest servletRequest, ServletResponse servletResponse) throws IllegalStateException { - return null; + public boolean isAsyncStarted() { + if (asyncContext == null) { + return false; + } + if (asyncContext.isCompleted() || asyncContext.isDispatched()) { + return false; + } + return true; } - //------------------------------------------------------------- - // Methods - Private - //------------------------------------------------------------- - private String getHeaderCaseInsensitive(String key) { - if (request.getHeaders() == null) { - return null; - } - for (String requestHeaderKey : request.getHeaders().keySet()) { - if (key.toLowerCase().equals(requestHeaderKey.toLowerCase())) { - return request.getHeaders().get(requestHeaderKey); - } - } - return null; + @Override + public AsyncContext startAsync() + throws IllegalStateException { + asyncContext = new AwsAsyncContext(this, response); + setAttribute(DISPATCHER_TYPE_ATTRIBUTE, DispatcherType.ASYNC); + log.debug("Starting async context for request: " + SecurityUtils.crlf(request.getRequestContext().getRequestId())); + return asyncContext; } - private String getQueryStringParameterCaseInsensitive(String key) { - if (request.getQueryStringParameters() == null) { - return null; - } + @Override + public AsyncContext startAsync(ServletRequest servletRequest, ServletResponse servletResponse) + throws IllegalStateException { + servletRequest.setAttribute(DISPATCHER_TYPE_ATTRIBUTE, DispatcherType.ASYNC); + asyncContext = new AwsAsyncContext((HttpServletRequest) servletRequest, (HttpServletResponse) servletResponse); + log.debug("Starting async context for request: " + SecurityUtils.crlf(request.getRequestContext().getRequestId())); + return asyncContext; + } - for (String requestParamKey : request.getQueryStringParameters().keySet()) { - if (key.toLowerCase().equals(requestParamKey.toLowerCase())) { - return request.getQueryStringParameters().get(requestParamKey); - } + @Override + public AsyncContext getAsyncContext() { + if (asyncContext == null) { + throw new IllegalStateException("Request " + SecurityUtils.crlf(request.getRequestContext().getRequestId()) + + " is not in asynchronous mode. Call startAsync before attempting to get the async context."); } - return null; + return asyncContext; } + @Override + public String getRequestId() { + return request.getRequestContext().getRequestId(); + } - private String[] getFormBodyParameterCaseInsensitive(String key) { - List values = urlEncodedFormParameters.get(key); - if (values != null) { - String[] valuesArray = new String[values.size()]; - valuesArray = values.toArray(valuesArray); - return valuesArray; - } else { - return null; - } - } + @Override + public String getProtocolRequestId() { + return ""; + } + @Override + public ServletConnection getServletConnection() { + return null; + } - private Map getMultipartFormParametersMap() { - if (!ServletFileUpload.isMultipartContent(this)) { // isMultipartContent also checks the content type - return new HashMap<>(); - } + //------------------------------------------------------------- + // Methods - Private + //------------------------------------------------------------- - Map output = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); + private List getHeaderValues(String key) { + // special cases for referer and user agent headers + List values = new ArrayList<>(); - ServletFileUpload upload = new ServletFileUpload(); - try { - List items = upload.parseRequest(this); - for (FileItem item : items) { - AwsProxyRequestPart newPart = new AwsProxyRequestPart(item.get()); - newPart.setName(item.getName()); - newPart.setSubmittedFileName(item.getFieldName()); - newPart.setContentType(item.getContentType()); - newPart.setSize(item.getSize()); - - Iterator headerNamesIterator = item.getHeaders().getHeaderNames(); - while (headerNamesIterator.hasNext()) { - String headerName = headerNamesIterator.next(); - Iterator headerValuesIterator = item.getHeaders().getHeaders(headerName); - while (headerValuesIterator.hasNext()) { - newPart.addHeader(headerName, headerValuesIterator.next()); + if (request.getRequestSource() == RequestSource.API_GATEWAY) { + if ("referer".equals(key.toLowerCase(Locale.ENGLISH))) { + if (request.getRequestContext() != null && request.getRequestContext().getIdentity() != null) { + String caller = request.getRequestContext().getIdentity().getCaller(); + if (caller != null) { + values.add(caller); + return values; + } + } + } + if ("user-agent".equals(key.toLowerCase(Locale.ENGLISH))) { + if (request.getRequestContext() != null && request.getRequestContext().getIdentity() != null) { + String userAgent = request.getRequestContext().getIdentity().getUserAgent(); + if (userAgent != null) { + values.add(userAgent); + return values; } } - - output.put(item.getFieldName(), newPart); } - } catch (FileUploadException e) { - // TODO: Should we swallaw this? - e.printStackTrace(); - } - return output; - } - - - private Map> getFormUrlEncodedParametersMap() { - String contentType = getContentType(); - if (contentType == null) { - return new HashMap<>(); - } - if (!contentType.startsWith(MediaType.APPLICATION_FORM_URLENCODED) || !getMethod().toLowerCase().equals("post")) { - return new HashMap<>(); - } - String rawBodyContent; - try { - rawBodyContent = URLDecoder.decode(request.getBody(), DEFAULT_CHARACTER_ENCODING); - } catch (UnsupportedEncodingException e) { - e.printStackTrace(); - rawBodyContent = request.getBody(); } - Map> output = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); - for (String parameter : rawBodyContent.split(FORM_DATA_SEPARATOR)) { - String[] parameterKeyValue = parameter.split(HEADER_KEY_VALUE_SEPARATOR); - if (parameterKeyValue.length < 2) { - continue; - } - List values = new ArrayList<>(); - if (output.containsKey(parameterKeyValue[0])) { - values = output.get(parameterKeyValue[0]); - } - values.add(parameterKeyValue[1]); - output.put(parameterKeyValue[0], values); + if (request.getMultiValueHeaders() == null) { + return null; } - return output; + return request.getMultiValueHeaders().get(key); } + + } diff --git a/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/servlet/AwsProxyHttpServletRequestReader.java b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/servlet/AwsProxyHttpServletRequestReader.java index 3358e270..ec56285f 100644 --- a/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/servlet/AwsProxyHttpServletRequestReader.java +++ b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/servlet/AwsProxyHttpServletRequestReader.java @@ -13,35 +13,87 @@ package com.amazonaws.serverless.proxy.internal.servlet; import com.amazonaws.serverless.exceptions.InvalidRequestEventException; -import com.amazonaws.serverless.proxy.internal.RequestReader; -import com.amazonaws.serverless.proxy.internal.model.AwsProxyRequest; +import com.amazonaws.serverless.proxy.RequestReader; +import com.amazonaws.serverless.proxy.model.AwsProxyRequest; +import com.amazonaws.serverless.proxy.model.ContainerConfig; import com.amazonaws.services.lambda.runtime.Context; -import javax.ws.rs.core.SecurityContext; +import jakarta.servlet.ServletContext; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.core.SecurityContext; /** * Simple implementation of the RequestReader interface that receives an AwsProxyRequest * object and uses it to initialize a AwsProxyHttpServletRequest object. */ -public class AwsProxyHttpServletRequestReader extends RequestReader { +public class AwsProxyHttpServletRequestReader extends RequestReader { + static final String INVALID_REQUEST_ERROR = "The incoming event is not a valid request from Amazon API Gateway or an Application Load Balancer"; + private ServletContext servletContext; //------------------------------------------------------------- // Methods - Implementation //------------------------------------------------------------- + public void setServletContext(ServletContext ctx) { + servletContext = ctx; + } + @Override - public AwsProxyHttpServletRequest readRequest(AwsProxyRequest request, SecurityContext securityContext, Context lambdaContext) + public HttpServletRequest readRequest(AwsProxyRequest request, SecurityContext securityContext, Context lambdaContext, ContainerConfig config) throws InvalidRequestEventException { - AwsProxyHttpServletRequest servletRequest = new AwsProxyHttpServletRequest(request, lambdaContext, securityContext); + // Expect the HTTP method and context to be populated. If they are not, we are handling an + // unsupported event type. + if (request.getHttpMethod() == null || request.getHttpMethod().equals("") || request.getRequestContext() == null) { + throw new InvalidRequestEventException(INVALID_REQUEST_ERROR); + } + + request.setPath(stripBasePath(request.getPath(), config)); + if (request.getMultiValueHeaders() != null && request.getMultiValueHeaders().getFirst(HttpHeaders.CONTENT_TYPE) != null) { + String contentType = request.getMultiValueHeaders().getFirst(HttpHeaders.CONTENT_TYPE); + // put single as we always expect to have one and only one content type in a request. + request.getMultiValueHeaders().putSingle(HttpHeaders.CONTENT_TYPE, getContentTypeWithCharset(contentType, config)); + } + AwsProxyHttpServletRequest servletRequest = new AwsProxyHttpServletRequest(request, lambdaContext, securityContext, config); + servletRequest.setServletContext(servletContext); servletRequest.setAttribute(API_GATEWAY_CONTEXT_PROPERTY, request.getRequestContext()); servletRequest.setAttribute(API_GATEWAY_STAGE_VARS_PROPERTY, request.getStageVariables()); + servletRequest.setAttribute(API_GATEWAY_EVENT_PROPERTY, request); + servletRequest.setAttribute(ALB_CONTEXT_PROPERTY, request.getRequestContext().getElb()); servletRequest.setAttribute(LAMBDA_CONTEXT_PROPERTY, lambdaContext); + servletRequest.setAttribute(JAX_SECURITY_CONTEXT_PROPERTY, securityContext); + return servletRequest; } + //------------------------------------------------------------- + // Methods - Protected + //------------------------------------------------------------- @Override protected Class getRequestClass() { return AwsProxyRequest.class; } + + //------------------------------------------------------------- + // Methods - Private + //------------------------------------------------------------- + + private String getContentTypeWithCharset(String headerValue, ContainerConfig config) { + if (headerValue == null || "".equals(headerValue.trim())) { + return headerValue; + } + + if (headerValue.contains("charset=")) { + return headerValue; + } + + String newValue = headerValue; + if (!headerValue.trim().endsWith(";")) { + newValue += "; "; + } + + newValue += "charset=" + config.getDefaultContentCharset(); + return newValue; + } } diff --git a/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/servlet/AwsProxyHttpServletResponseWriter.java b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/servlet/AwsProxyHttpServletResponseWriter.java index 232bd15e..0e230e9a 100644 --- a/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/servlet/AwsProxyHttpServletResponseWriter.java +++ b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/servlet/AwsProxyHttpServletResponseWriter.java @@ -14,12 +14,21 @@ import com.amazonaws.serverless.exceptions.InvalidResponseObjectException; -import com.amazonaws.serverless.proxy.internal.ResponseWriter; -import com.amazonaws.serverless.proxy.internal.model.AwsProxyResponse; +import com.amazonaws.serverless.proxy.ResponseWriter; +import com.amazonaws.serverless.proxy.internal.LambdaContainerHandler; +import com.amazonaws.serverless.proxy.internal.testutils.Timer; +import com.amazonaws.serverless.proxy.model.AwsProxyResponse; +import com.amazonaws.serverless.proxy.model.Headers; +import com.amazonaws.serverless.proxy.model.RequestSource; import com.amazonaws.services.lambda.runtime.Context; -import java.io.IOException; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.Response.Status; + import java.util.Base64; +import java.util.HashMap; +import java.util.Map; + /** * Creates an AwsProxyResponse object given an AwsHttpServletResponse object. If the @@ -27,6 +36,16 @@ */ public class AwsProxyHttpServletResponseWriter extends ResponseWriter { + private boolean writeSingleValueHeaders; + + public AwsProxyHttpServletResponseWriter() { + this(false); + } + + public AwsProxyHttpServletResponseWriter(boolean singleValueHeaders) { + writeSingleValueHeaders = singleValueHeaders; + } + //------------------------------------------------------------- // Methods - Implementation //------------------------------------------------------------- @@ -34,27 +53,59 @@ public class AwsProxyHttpServletResponseWriter extends ResponseWriter toSingleValueHeaders(Headers h) { + Map out = new HashMap<>(); + if (h == null || h.isEmpty()) { + return out; + } + for (String k : h.keySet()) { + out.put(k, h.getFirst(k)); + } + return out; + } + + private boolean isBinary(String contentType) { + if(contentType != null) { + int semidx = contentType.indexOf(';'); + if(semidx >= 0) { + return LambdaContainerHandler.getContainerConfig().isBinaryContentType(contentType.substring(0, semidx)); + } + else { + return LambdaContainerHandler.getContainerConfig().isBinaryContentType(contentType); + } + } + return false; + } } diff --git a/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/servlet/AwsProxyRequestDispatcher.java b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/servlet/AwsProxyRequestDispatcher.java new file mode 100644 index 00000000..f314ba0d --- /dev/null +++ b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/servlet/AwsProxyRequestDispatcher.java @@ -0,0 +1,152 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance + * with the License. A copy of the License is located at + * + * http://aws.amazon.com/apache2.0/ + * + * or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES + * OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions + * and limitations under the License. + */ +package com.amazonaws.serverless.proxy.internal.servlet; + +import com.amazonaws.serverless.proxy.internal.SecurityUtils; +import com.amazonaws.serverless.proxy.model.AwsProxyRequest; +import com.amazonaws.serverless.proxy.model.HttpApiV2ProxyRequest; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import jakarta.servlet.*; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import java.io.IOException; + +import static com.amazonaws.serverless.proxy.RequestReader.API_GATEWAY_EVENT_PROPERTY; +import static com.amazonaws.serverless.proxy.RequestReader.HTTP_API_EVENT_PROPERTY; +import static com.amazonaws.serverless.proxy.internal.servlet.AwsHttpServletRequest.DISPATCHER_TYPE_ATTRIBUTE; + +/** + * Default RequestDispatcher implementation for the AwsProxyHttpServletRequest type. A new + * instance of this object is created each time a framework gets the RequestDispatcher from a servlet request. Behind + * the scenes, this object uses the AwsLambdaServletContainerHandler to send FORWARD and INCLUDE requests + * to the framework. + */ +public class AwsProxyRequestDispatcher implements RequestDispatcher { + + //------------------------------------------------------------- + // Variables - Private + //------------------------------------------------------------- + private static final Logger log = LoggerFactory.getLogger(AwsHttpSession.class); + private String dispatchTo; + private boolean isNamedDispatcher; + private AwsLambdaServletContainerHandler lambdaContainerHandler; + + //------------------------------------------------------------- + // Constructors + //------------------------------------------------------------- + + + public AwsProxyRequestDispatcher(final String target, final boolean namedDispatcher, final AwsLambdaServletContainerHandler handler) { + isNamedDispatcher = namedDispatcher; + dispatchTo = target; + lambdaContainerHandler = handler; + } + + //------------------------------------------------------------- + // Implementation - RequestDispatcher + //------------------------------------------------------------- + + + @Override + @SuppressWarnings("unchecked") + public void forward(ServletRequest servletRequest, ServletResponse servletResponse) + throws ServletException, IOException { + if (lambdaContainerHandler == null) { + throw new IllegalStateException("Null container handler in dispatcher"); + } + if (servletResponse.isCommitted()) { + throw new IllegalStateException("Cannot forward request with committed response"); + } + + try { + // Reset any output that has been buffered, but keep headers/cookies + servletResponse.resetBuffer(); + } catch (IllegalStateException e) { + throw e; + } + + if (isNamedDispatcher) { + lambdaContainerHandler.doFilter((HttpServletRequest) servletRequest, (HttpServletResponse) servletResponse, ((AwsServletRegistration)servletRequest.getServletContext().getServletRegistration(dispatchTo)).getServlet()); + return; + } + + servletRequest.setAttribute(DISPATCHER_TYPE_ATTRIBUTE, DispatcherType.FORWARD); + setRequestPath(servletRequest, dispatchTo); + lambdaContainerHandler.doFilter((HttpServletRequest) servletRequest, (HttpServletResponse) servletResponse, getServlet((HttpServletRequest)servletRequest)); + } + + + @Override + @SuppressWarnings("unchecked") + @SuppressFBWarnings("SERVLET_QUERY_STRING") + public void include(ServletRequest servletRequest, ServletResponse servletResponse) + throws ServletException, IOException { + if (lambdaContainerHandler == null) { + throw new IllegalStateException("Null container handler in dispatcher"); + } + if (servletResponse.isCommitted()) { + throw new IllegalStateException("Cannot forward request with committed response"); + } + servletRequest.setAttribute(DISPATCHER_TYPE_ATTRIBUTE, DispatcherType.INCLUDE); + if (!isNamedDispatcher) { + servletRequest.setAttribute("javax.servlet.include.request_uri", ((HttpServletRequest)servletRequest).getRequestURI()); + servletRequest.setAttribute("javax.servlet.include.context_path", ((HttpServletRequest) servletRequest).getContextPath()); + servletRequest.setAttribute("javax.servlet.include.servlet_path", ((HttpServletRequest) servletRequest).getServletPath()); + servletRequest.setAttribute("javax.servlet.include.path_info", ((HttpServletRequest) servletRequest).getPathInfo()); + servletRequest.setAttribute("javax.servlet.include.query_string", + SecurityUtils.encode(SecurityUtils.crlf(((HttpServletRequest) servletRequest).getQueryString()))); + setRequestPath(servletRequest, dispatchTo); + } + lambdaContainerHandler.doFilter((HttpServletRequest) servletRequest, (HttpServletResponse) servletResponse, getServlet((HttpServletRequest)servletRequest)); + } + + /** + * Sets the destination path in the given request. Uses the AwsProxyRequest.setPath method which + * is in turn read by the HttpServletRequest implementation. + * @param req The request object to be modified + * @param destinationPath The new path for the request + * @throws IllegalStateException If the given request object does not include the API_GATEWAY_EVENT_PROPERTY + * attribute or the value for the attribute is not of the correct type: AwsProxyRequest. + */ + void setRequestPath(ServletRequest req, final String destinationPath) { + if (req instanceof AwsProxyHttpServletRequest) { + ((AwsProxyHttpServletRequest) req).getAwsProxyRequest().setPath(dispatchTo); + return; + } + if (req instanceof AwsHttpApiV2ProxyHttpServletRequest) { + ((AwsHttpApiV2ProxyHttpServletRequest) req).getRequest().setRawPath(destinationPath); + return; + } + + log.debug("Request is not an proxy request generated by this library, attempting to extract the proxy event type from the request attributes"); + if (req.getAttribute(API_GATEWAY_EVENT_PROPERTY) != null && req.getAttribute(API_GATEWAY_EVENT_PROPERTY) instanceof AwsProxyRequest) { + ((AwsProxyRequest)req.getAttribute(API_GATEWAY_EVENT_PROPERTY)).setPath(dispatchTo); + return; + } + if (req.getAttribute(HTTP_API_EVENT_PROPERTY) != null && req.getAttribute(HTTP_API_EVENT_PROPERTY) instanceof HttpApiV2ProxyRequest) { + ((HttpApiV2ProxyRequest)req.getAttribute(HTTP_API_EVENT_PROPERTY)).setRawPath(destinationPath); + return; + } + + throw new IllegalStateException("Could not set new target path for the given ServletRequest object"); + } + + private Servlet getServlet(HttpServletRequest req) { + return ((AwsServletContext)lambdaContainerHandler.getServletContext()).getServletForPath(req.getPathInfo()); + } + +} diff --git a/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/servlet/AwsProxyRequestPart.java b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/servlet/AwsProxyRequestPart.java index 18012850..c2d1fa62 100644 --- a/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/servlet/AwsProxyRequestPart.java +++ b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/servlet/AwsProxyRequestPart.java @@ -12,13 +12,19 @@ */ package com.amazonaws.serverless.proxy.internal.servlet; -import javax.servlet.http.Part; -import javax.ws.rs.core.MultivaluedHashMap; +import com.amazonaws.serverless.proxy.internal.SecurityUtils; +import com.amazonaws.serverless.proxy.model.MultiValuedTreeMap; + +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; + +import jakarta.servlet.http.Part; import java.io.ByteArrayInputStream; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.util.Collection; +import java.util.Collections; + public class AwsProxyRequestPart implements Part { @@ -31,7 +37,7 @@ public class AwsProxyRequestPart private String submittedFileName; private long size; private String contentType; - private MultivaluedHashMap headers; + private MultiValuedTreeMap headers; private byte[] content; @@ -40,7 +46,7 @@ public class AwsProxyRequestPart //------------------------------------------------------------- public AwsProxyRequestPart(byte[] content) { - this.content = content; + this.content = content.clone(); } @@ -79,9 +85,11 @@ public long getSize() { } + @SuppressFBWarnings("PATH_TRAVERSAL_OUT") @Override public void write(String s) throws IOException { - FileOutputStream fos = new FileOutputStream(s); + String canonicalFilePath = SecurityUtils.getValidFilePath(s); + FileOutputStream fos = new FileOutputStream(canonicalFilePath); try { fos.write(content); } finally { @@ -98,18 +106,27 @@ public void delete() throws IOException { @Override public String getHeader(String s) { + if (headers == null) { + return null; + } return headers.getFirst(s); } @Override public Collection getHeaders(String s) { + if (headers == null) { + return Collections.emptyList(); + } return headers.get(s); } @Override public Collection getHeaderNames() { + if (headers == null) { + return Collections.emptyList(); + } return headers.keySet(); } @@ -120,7 +137,7 @@ public Collection getHeaderNames() { public void addHeader(String key, String value) { if (headers == null) { - headers = new MultivaluedHashMap<>(); + headers = new MultiValuedTreeMap<>(String.CASE_INSENSITIVE_ORDER); } if (headers.containsKey(key)) { @@ -155,7 +172,7 @@ public void setContentType(String contentType) { } - public void setHeaders(MultivaluedHashMap headers) { + public void setHeaders(MultiValuedTreeMap headers) { this.headers = headers; } } diff --git a/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/servlet/AwsServletContext.java b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/servlet/AwsServletContext.java index fdadce13..94dcaf44 100644 --- a/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/servlet/AwsServletContext.java +++ b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/servlet/AwsServletContext.java @@ -14,18 +14,16 @@ import com.amazonaws.serverless.proxy.internal.LambdaContainerHandler; -import com.amazonaws.services.lambda.runtime.Context; - -import javax.servlet.Filter; -import javax.servlet.FilterRegistration; -import javax.servlet.RequestDispatcher; -import javax.servlet.Servlet; -import javax.servlet.ServletContext; -import javax.servlet.ServletException; -import javax.servlet.ServletRegistration; -import javax.servlet.SessionCookieConfig; -import javax.servlet.SessionTrackingMode; -import javax.servlet.descriptor.JspConfigDescriptor; +import com.amazonaws.serverless.proxy.internal.SecurityUtils; + +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import jakarta.servlet.*; +import jakarta.servlet.ServletContext; +import jakarta.servlet.descriptor.JspConfigDescriptor; import java.io.File; import java.io.IOException; @@ -33,15 +31,11 @@ import java.net.MalformedURLException; import java.net.URISyntaxException; import java.net.URL; +import java.net.URLConnection; import java.nio.file.Files; +import java.nio.file.InvalidPathException; import java.nio.file.Paths; -import java.util.Collections; -import java.util.Enumeration; -import java.util.EventListener; -import java.util.HashMap; -import java.util.LinkedHashMap; -import java.util.Map; -import java.util.Set; +import java.util.*; /** @@ -55,8 +49,8 @@ public class AwsServletContext //------------------------------------------------------------- // Constants - Public // ------------------------------------------------------------- - public static final int SERVLET_API_MAJOR_VERSION = 3; - public static final int SERVLET_API_MINOR_VERSION = 1; + public static final int SERVLET_API_MAJOR_VERSION = 6; + public static final int SERVLET_API_MINOR_VERSION = 0; public static final String SERVER_INFO = LambdaContainerHandler.SERVER_INFO + "/" + SERVLET_API_MAJOR_VERSION + "." + SERVLET_API_MINOR_VERSION; @@ -64,9 +58,11 @@ public class AwsServletContext // Variables - Private //------------------------------------------------------------- private Map filters; - private Context lambdaContext; + private Map servletRegistrations; private Map attributes; private Map initParameters; + private AwsLambdaServletContainerHandler containerHandler; + private Logger log = LoggerFactory.getLogger(AwsServletContext.class); //------------------------------------------------------------- @@ -79,38 +75,57 @@ public class AwsServletContext // Constructors //------------------------------------------------------------- - private AwsServletContext(Context lambdaContext) { - this.lambdaContext = lambdaContext; - + public AwsServletContext(AwsLambdaServletContainerHandler containerHandler) { + this.containerHandler = containerHandler; this.attributes = new HashMap<>(); this.initParameters = new HashMap<>(); this.filters = new LinkedHashMap<>(); + this.servletRegistrations = new HashMap<>(); } - //------------------------------------------------------------- // Implementation - ServletContext //------------------------------------------------------------- + public static void clearServletContextCache() { + instance = null; + } - public static ServletContext getInstance(Context lambdaContext) { - if (instance == null) { - instance = new AwsServletContext(lambdaContext); - } - return instance; + @Override + public String getContextPath() { + // servlets are always at the root. + return ""; } + @Override + public void setResponseCharacterEncoding(String encoding) { + throw new UnsupportedOperationException(); + } - public static void clearServletContextCache() { - instance = null; + @Override + public String getResponseCharacterEncoding() { + return null; } + @Override + public void setRequestCharacterEncoding(String encoding) { + throw new UnsupportedOperationException(); + } @Override - public String getContextPath() { - // servlets are always at the root. - return "/"; + public String getRequestCharacterEncoding() { + return null; + } + + @Override + public void setSessionTimeout(int sessionTimeout) { + throw new UnsupportedOperationException(); + } + + @Override + public int getSessionTimeout() { + return 0; } @@ -146,13 +161,33 @@ public int getEffectiveMinorVersion() { @Override - public String getMimeType(String s) { - try { - return Files.probeContentType(Paths.get(s)); - } catch (IOException e) { - e.printStackTrace(); + @SuppressFBWarnings("PATH_TRAVERSAL_IN") // suppressing because we are using the getValidFilePath + public String getMimeType(String file) { + if (file == null || !file.contains(".")) { return null; } + + String mimeType = null; + + // may not work on Lambda until mailcap package is present https://github.com/aws/serverless-java-container/pull/504 + try { + mimeType = Files.probeContentType(Paths.get(file)); + } catch (IOException | InvalidPathException e) { + log("unable to probe for content type, will use fallback", e); + } + + if (mimeType == null) { + try { + String mimeTypeGuess = URLConnection.guessContentTypeFromName(new File(file).getName()); + if (mimeTypeGuess !=null) { + mimeType = mimeTypeGuess; + } + } catch (Exception e) { + log("couldn't find a better contentType than " + mimeType + " for file " + file, e); + } + } + + return mimeType; } @@ -166,65 +201,61 @@ public Set getResourcePaths(String s) { @Override public URL getResource(String s) throws MalformedURLException { - return getClass().getResource(s); + return AwsServletContext.class.getResource(s); } @Override public InputStream getResourceAsStream(String s) { - return getClass().getResourceAsStream(s); + return AwsServletContext.class.getResourceAsStream(s); } @Override public RequestDispatcher getRequestDispatcher(String s) { - // TODO: This should be part of the reader interface described in the getResourcePaths method - return null; + return new AwsProxyRequestDispatcher(s, false, containerHandler); } @Override public RequestDispatcher getNamedDispatcher(String s) { - // TODO: This should be part of the reader interface described in the getResourcePaths method - return null; - } - - - @Override - public Servlet getServlet(String s) throws ServletException { - return null; - } - - - @Override - public Enumeration getServlets() { - return null; - } - - - @Override - public Enumeration getServletNames() { + return new AwsProxyRequestDispatcher(s, true, containerHandler); + } + + public Servlet getServletForPath(String path) { + String[] pathParts = path.split("/"); + for (AwsServletRegistration reg : servletRegistrations.values()) { + for (String p : reg.getMappings()) { + if ("".equals(p) || "/".equals(p) || "/*".equals(p)) { + return reg.getServlet(); + } + // if I have no path and I haven't matched something now I'll just move on to the next + if ("".equals(path) || "/".equals(path)) { + continue; + } + String[] regParts = p.split("/"); + for (int i = 0; i < regParts.length; i++) { + if (!regParts[i].equals(pathParts[i]) && !"*".equals(regParts[i])) { + break; + } + if (i == regParts.length - 1 && (regParts[i].equals(pathParts[i]) || "*".equals(regParts[i]))) { + return reg.getServlet(); + } + } + } + } return null; } - @Override public void log(String s) { - lambdaContext.getLogger().log(s); - } - - - @Override - public void log(Exception e, String s) { - lambdaContext.getLogger().log(s); - lambdaContext.getLogger().log(e.getMessage()); + log.info(SecurityUtils.encode(s)); } @Override public void log(String s, Throwable throwable) { - lambdaContext.getLogger().log(s); - lambdaContext.getLogger().log(throwable.getMessage()); + log.error(SecurityUtils.encode(s), throwable); } @@ -236,7 +267,7 @@ public String getRealPath(String s) { try { absPath = new File(fileUrl.toURI()).getAbsolutePath(); } catch (URISyntaxException e) { - lambdaContext.getLogger().log("Error while looking for real path: " + s + "\n" + e.getMessage()); + log.error("Error while looking for real path {}: {}", SecurityUtils.encode(s), SecurityUtils.encode(e.getMessage())); } } return absPath; @@ -294,54 +325,72 @@ public void removeAttribute(String s) { @Override public String getServletContextName() { - // TODO: This can also come from a reader interface - throw new UnsupportedOperationException(); + return null; } @Override public ServletRegistration.Dynamic addServlet(String s, String s1) { - log("Called addServlet: " + s1); - log("Implemented frameworks are responsible for registering servlets"); - throw new UnsupportedOperationException(); + try { + Class servletClass = (Class) this.getClassLoader().loadClass(s1); + Servlet servlet = createServlet(servletClass); + servletRegistrations.put(s, new AwsServletRegistration(s, servlet, this)); + return servletRegistrations.get(s); + } catch (ServletException | ClassNotFoundException e) { + throw new RuntimeException(e); + } } @Override public ServletRegistration.Dynamic addServlet(String s, Servlet servlet) { - log("Called addServlet: " + servlet.getClass().getName()); - log("Implemented frameworks are responsible for registering servlets"); - throw new UnsupportedOperationException(); + servletRegistrations.put(s, new AwsServletRegistration(s, servlet, this)); + return servletRegistrations.get(s); } @Override public ServletRegistration.Dynamic addServlet(String s, Class aClass) { - log("Called addServlet: " + aClass.getName()); - log("Implemented frameworks are responsible for registering servlets"); + try { + Servlet servlet = createServlet(aClass); + servletRegistrations.put(s, new AwsServletRegistration(s, servlet, this)); + return servletRegistrations.get(s); + } catch (ServletException e) { + throw new RuntimeException(e); + } + } + + @Override + public ServletRegistration.Dynamic addJspFile(String s, String s1) { throw new UnsupportedOperationException(); } @Override public T createServlet(Class aClass) throws ServletException { - log("Called createServlet: " + aClass.getName()); - log("Implemented frameworks are responsible for creating servlets"); - throw new UnsupportedOperationException(); + /*log("Called createServlet: " + aClass.getName()); + log("Implemented frameworks are responsible for creating servlets");*/ + // TODO: This method introspects the given clazz for the following annotations: ServletSecurity, MultipartConfig, + // javax.annotation.security.RunAs, and javax.annotation.security.DeclareRoles. In addition, this method supports + // resource injection if the given clazz represents a Managed Bean. See the Java EE platform and JSR 299 specifications + // for additional details about Managed Beans and resource injection. + try { + return aClass.newInstance(); + } catch (InstantiationException | IllegalAccessException e) { + throw new ServletException(e); + } } @Override public ServletRegistration getServletRegistration(String s) { - // TODO: This could come from the reader interface - return null; + return servletRegistrations.get(s); } @Override public Map getServletRegistrations() { - // TODO: This could come from the reader interface - return null; + return servletRegistrations; } @@ -349,7 +398,7 @@ public ServletRegistration getServletRegistration(String s) { public FilterRegistration.Dynamic addFilter(String name, String filterClass) { try { Class newFilterClass = getClassLoader().loadClass(filterClass); - if (!newFilterClass.isAssignableFrom(Filter.class)) { + if (!Filter.class.isAssignableFrom(newFilterClass)) { throw new IllegalArgumentException(filterClass + " does not implement Filter"); } @SuppressWarnings("unchecked") @@ -357,7 +406,7 @@ public FilterRegistration.Dynamic addFilter(String name, String filterClass) { return addFilter(name, filterCastClass); } catch (ClassNotFoundException e) { - e.printStackTrace(); + log.error("Could not find filter class", e); throw new IllegalStateException("Filter class " + filterClass + " not found"); } } @@ -371,6 +420,8 @@ public FilterRegistration.Dynamic addFilter(String name, Filter filter) { // filter already exists, we do nothing if (filters.containsKey(name)) { return null; + } else { + log.debug("Adding filter '{}' from {}", SecurityUtils.encode(name), SecurityUtils.encode(filter.toString())); } FilterHolder newFilter = new FilterHolder(name, filter, this); @@ -383,12 +434,13 @@ public FilterRegistration.Dynamic addFilter(String name, Filter filter) { @Override public FilterRegistration.Dynamic addFilter(String name, Class filterClass) { try { + log.debug("Adding filter '{}' from {}", SecurityUtils.encode(name), SecurityUtils.encode(filterClass.getName())); Filter newFilter = createFilter(filterClass); return addFilter(name, newFilter); } catch (ServletException e) { // TODO: There is no clear indication in the servlet specs on whether we should throw an exception here. // See JavaDoc here: http://docs.oracle.com/javaee/7/api/javax/servlet/ServletContext.html#addFilter-java.lang.String-java.lang.Class- - lambdaContext.getLogger().log("Could not register filter: " + e.getMessage()); + log.error("Could not register filter: ", e); } return null; } @@ -399,7 +451,7 @@ public T createFilter(Class aClass) throws ServletExceptio try { return aClass.newInstance(); } catch (InstantiationException | IllegalAccessException e) { - lambdaContext.getLogger().log("Could not initialize filter class " + aClass.getName() + "\n" + e.getMessage()); + log.error("Could not initialize filter class " + aClass.getName(), e); throw new ServletException(); } } @@ -418,9 +470,10 @@ public FilterRegistration getFilterRegistration(String s) { @Override public Map getFilterRegistrations() { Map registrations = new LinkedHashMap<>(); - for (String filter : filters.keySet()) { - registrations.put(filter, filters.get(filter).getRegistration()); + for (Map.Entry entry : filters.entrySet()) { + registrations.put(entry.getKey(), entry.getValue().getRegistration()); } + return registrations; } @@ -486,9 +539,7 @@ public JspConfigDescriptor getJspConfigDescriptor() { @Override public ClassLoader getClassLoader() { - // for the time being we return the default class loader. We may want to let developers override this int the - // future. - return ClassLoader.getSystemClassLoader(); + return getClass().getClassLoader(); } diff --git a/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/servlet/AwsServletInputStream.java b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/servlet/AwsServletInputStream.java new file mode 100644 index 00000000..220ca602 --- /dev/null +++ b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/servlet/AwsServletInputStream.java @@ -0,0 +1,96 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance + * with the License. A copy of the License is located at + * + * http://aws.amazon.com/apache2.0/ + * + * or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES + * OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions + * and limitations under the License. + */ +package com.amazonaws.serverless.proxy.internal.servlet; + +import org.apache.commons.io.input.NullInputStream; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import jakarta.servlet.ReadListener; +import jakarta.servlet.ServletInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.ByteBuffer; + +public class AwsServletInputStream extends ServletInputStream { + private static Logger log = LoggerFactory.getLogger(AwsServletInputStream.class); + private InputStream bodyStream; + private ReadListener listener; + private boolean finished; + + public AwsServletInputStream(InputStream body) { + bodyStream = body; + finished = false; + } + + @Override + public boolean isFinished() { + return finished; + } + + @Override + public boolean isReady() { + if (finished && listener != null) { + try { + listener.onAllDataRead(); + } catch (IOException e) { + log.error("Could not notify listeners that input stream data is ready", e); + throw new RuntimeException(e); + } + } + return !finished; + } + + @Override + public void setReadListener(ReadListener readListener) { + listener = readListener; + try { + listener.onDataAvailable(); + } catch (IOException e) { + log.error("Could not notify listeners that data is available", e); + throw new RuntimeException(e); + } + } + + @Override + public int read() + throws IOException { + if (bodyStream == null || bodyStream instanceof NullInputStream) { + return -1; + } + int readByte = bodyStream.read(); + if (readByte == -1) { + finished = true; + } + return readByte; + } + + @Override + public int read(ByteBuffer b) throws IOException { + if (bodyStream == null || bodyStream instanceof NullInputStream) { + return -1; + } + if (!b.hasRemaining()) { + return 0; + } + byte[] buf = new byte[b.remaining()]; + int bytesRead = bodyStream.read(buf); + if (bytesRead > 0) { + b.put(buf, 0, bytesRead); + } + if (bytesRead == -1) { + finished = true; + } + return bytesRead; + } +} diff --git a/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/servlet/AwsServletRegistration.java b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/servlet/AwsServletRegistration.java new file mode 100644 index 00000000..9fdc3b3b --- /dev/null +++ b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/servlet/AwsServletRegistration.java @@ -0,0 +1,182 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance + * with the License. A copy of the License is located at + * + * http://aws.amazon.com/apache2.0/ + * + * or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES + * OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions + * and limitations under the License. + */ +package com.amazonaws.serverless.proxy.internal.servlet; + +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; + +import jakarta.servlet.*; +import java.util.*; + +/** + * Stores information about a servlet registered with Serverless Java Container's ServletContext. + */ +public class AwsServletRegistration implements ServletRegistration, ServletRegistration.Dynamic, Comparable { + private String servletName; + private Servlet servlet; + private AwsServletContext ctx; + private Map initParameters; + private int loadOnStartup; + private String runAsRole; + private boolean asyncSupported; + private Map servletPathMappings; + + + public AwsServletRegistration(String name, Servlet s, AwsServletContext context) { + servletName = name; + servlet = s; + ctx = context; + initParameters = new HashMap<>(); + servletPathMappings = new HashMap<>(); + loadOnStartup = -1; + asyncSupported = true; + } + + @Override + public Set addMapping(String... strings) { + Set failedMappings = new HashSet<>(); + for (String s : strings) { + if (servletPathMappings.containsKey(s)) { + failedMappings.add(s); + continue; + } + servletPathMappings.put(s, this); + } + return failedMappings; + } + + @Override + public Collection getMappings() { + return servletPathMappings.keySet(); + } + + @Override + public String getRunAsRole() { + return runAsRole; + } + + @Override + public String getName() { + return servletName; + } + + @Override + public String getClassName() { + return servlet.getClass().getName(); + } + + @Override + public boolean setInitParameter(String s, String s1) { + if (initParameters.containsKey(s)) { + return false; + } + initParameters.put(s, s1); + return true; + } + + @Override + public String getInitParameter(String s) { + return initParameters.get(s); + } + + @Override + public Set setInitParameters(Map map) { + Set failedParameters = new HashSet<>(); + for (Map.Entry param : map.entrySet()) { + if (initParameters.containsKey(param.getKey())) { + failedParameters.add(param.getKey()); + } + initParameters.put(param.getKey(), param.getValue()); + } + return failedParameters; + } + + @Override + public Map getInitParameters() { + return initParameters; + } + + public Servlet getServlet() { + return servlet; + } + + @Override + public void setLoadOnStartup(int i) { + loadOnStartup = i; + } + + public int getLoadOnStartup() { + return loadOnStartup; + } + + @Override + public Set setServletSecurity(ServletSecurityElement servletSecurityElement) { + return null; + } + + @Override + public void setMultipartConfig(MultipartConfigElement multipartConfigElement) { + + } + + @Override + public void setRunAsRole(String s) { + runAsRole = s; + } + + @Override + public void setAsyncSupported(boolean b) { + asyncSupported = b; + } + + @Override + public int compareTo(AwsServletRegistration r) { + return Integer.compare(loadOnStartup, r.getLoadOnStartup()); + } + + @Override + @SuppressFBWarnings("HE_EQUALS_USE_HASHCODE") + public boolean equals(Object r) { + if (r == null || !AwsServletRegistration.class.isAssignableFrom(r.getClass())) { + return false; + } + return ((AwsServletRegistration)r).getName().equals(getName()) && ((AwsServletRegistration)r).getServlet() == getServlet(); + } + + public boolean isAsyncSupported() { + return asyncSupported; + } + + public ServletConfig getServletConfig() { + return new ServletConfig() { + @Override + public String getServletName() { + return servletName; + } + + @Override + public ServletContext getServletContext() { + return ctx; + } + + @Override + public String getInitParameter(String s) { + return initParameters.get(s); + } + + @Override + public Enumeration getInitParameterNames() { + return Collections.enumeration(initParameters.keySet()); + } + }; + } +} diff --git a/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/servlet/CookieProcessor.java b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/servlet/CookieProcessor.java new file mode 100644 index 00000000..c59dc806 --- /dev/null +++ b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/servlet/CookieProcessor.java @@ -0,0 +1,23 @@ +package com.amazonaws.serverless.proxy.internal.servlet; + +import jakarta.servlet.http.Cookie; + +public interface CookieProcessor { + /** + * Parse the provided cookie header value into an array of Cookie objects. + * + * @param cookieHeader The cookie header value string to parse, e.g., "SID=31d4d96e407aad42; lang=en-US" + * @return An array of Cookie objects parsed from the cookie header value + */ + Cookie[] parseCookieHeader(String cookieHeader); + + /** + * Generate the Set-Cookie HTTP header value for the given Cookie. + * + * @param cookie The cookie for which the header will be generated + * @return The header value in a form that can be added directly to the response + */ + String generateHeader(Cookie cookie); + + +} diff --git a/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/servlet/FilterChainHolder.java b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/servlet/FilterChainHolder.java index caa460bf..b45eed4a 100644 --- a/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/servlet/FilterChainHolder.java +++ b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/servlet/FilterChainHolder.java @@ -12,10 +12,11 @@ */ package com.amazonaws.serverless.proxy.internal.servlet; -import javax.servlet.FilterChain; -import javax.servlet.ServletException; -import javax.servlet.ServletRequest; -import javax.servlet.ServletResponse; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import jakarta.servlet.*; import java.io.IOException; import java.util.ArrayList; @@ -23,7 +24,7 @@ /** * Implementation of the FilterChain interface. FilterChainHolder objects should be accessed through the - * FilterChainManager. Once a filter chain is loaded, use the doFilter emthod to run the chain + * FilterChainManager. Once a filter chain is loaded, use the doFilter method to run the chain * during a request lifecycle */ public class FilterChainHolder implements FilterChain { @@ -33,7 +34,9 @@ public class FilterChainHolder implements FilterChain { //------------------------------------------------------------- private List filters; - private int currentFilter; + int currentFilter; + + private Logger log = LoggerFactory.getLogger(FilterChainHolder.class); //------------------------------------------------------------- @@ -43,7 +46,7 @@ public class FilterChainHolder implements FilterChain { /** * Creates a new empty FilterChainHolder */ - public FilterChainHolder() { + FilterChainHolder() { this(new ArrayList<>()); } @@ -52,9 +55,9 @@ public FilterChainHolder() { * Creates a new instance of a filter chain holder * @param allFilters A populated list of FilterHolder objects */ - public FilterChainHolder(List allFilters) { + FilterChainHolder(List allFilters) { filters = allFilters; - currentFilter = -1; + resetHolder(); } @@ -62,20 +65,32 @@ public FilterChainHolder(List allFilters) { // Implementation - FilterChain //------------------------------------------------------------- + @SuppressFBWarnings("CRLF_INJECTION_LOGS") @Override public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse) throws IOException, ServletException { currentFilter++; - if (filters == null || filters.size() == 0 || currentFilter > filters.size() - 1) { - return; - } // TODO: We do not check for async filters here - FilterHolder holder = filters.get(currentFilter); - - if (!holder.isFilterInitialized()) { - holder.init(); + // if we still have filters, keep running through the chain + if (currentFilter <= filters.size() - 1) { + FilterHolder holder = filters.get(currentFilter); + + // confirm that this filter needs to be executed + if (!holder.getRegistration().getDispatcherTypes().contains(servletRequest.getDispatcherType())) { + // skip to the next filter - we have already incremented the currentFilter + doFilter(servletRequest, servletResponse); + } + + // lazily initialize filters when they are needed + if (!holder.isFilterInitialized()) { + holder.init(); + } + log.debug("Starting {}: filter {}-{}", servletRequest.getDispatcherType(), + currentFilter, holder.getFilterName()); + holder.getFilter().doFilter(servletRequest, servletResponse, this); + log.debug("Executed {}: filter {}-{}", servletRequest.getDispatcherType(), + currentFilter, holder.getFilterName()); } - holder.getFilter().doFilter(servletRequest, servletResponse, this); } @@ -87,7 +102,7 @@ public void doFilter(ServletRequest servletRequest, ServletResponse servletRespo * Add a filter to the chain. * @param newFilter The filter to be added at the end of the chain */ - public void addFilter(FilterHolder newFilter) { + void addFilter(FilterHolder newFilter) { filters.add(newFilter); } @@ -96,7 +111,7 @@ public void addFilter(FilterHolder newFilter) { * Returns the number of filters loaded in the chain holder * @return The number of filters in the chain holder. If the filter chain is null then this will return 0 */ - public int filterCount() { + int filterCount() { if (filters == null) { return 0; } else { @@ -110,11 +125,34 @@ public int filterCount() { * @param idx The index in the chain. Use the filterCount method to get the filter count * @return A populated FilterHolder object */ - public FilterHolder getFilter(int idx) { + FilterHolder getFilter(int idx) { if (filters == null) { return null; } else { return filters.get(idx); } } + + + /** + * Returns the list of filters in this chain. + * @return The list of filters + */ + public List getFilters() { + return filters; + } + + + /** + * Resets the chain holder to the beginning of the filter chain. This method is used from the constructor as well as when + * the {@link FilterChainManager} return a holder from the cache. + */ + private void resetHolder() { + currentFilter = -1; + } + + @Override + public String toString() { + return "filters=" + filters; + } } diff --git a/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/servlet/FilterChainManager.java b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/servlet/FilterChainManager.java index 2cffe10a..4917b4aa 100644 --- a/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/servlet/FilterChainManager.java +++ b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/servlet/FilterChainManager.java @@ -12,27 +12,34 @@ */ package com.amazonaws.serverless.proxy.internal.servlet; -import javax.servlet.DispatcherType; -import javax.servlet.http.HttpServletRequest; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; + +import jakarta.servlet.*; +import jakarta.servlet.http.HttpServletRequest; + +import java.io.IOException; import java.util.Collections; import java.util.HashMap; +import java.util.List; +import java.util.Locale; import java.util.Map; +import java.util.function.Predicate; /** - * This object in in charge of matching a servlet request to a set of filter, creating the filter chain for a request, + * This object is in charge of matching a servlet request to a set of filters, creating the filter chain for a request, * and cache filter chains that were already loaded for re-use. This object should be used by the framework-specific * implementations that use the HttpServletRequest and HttpServletResponse objects. * * For example, the Spring implementation creates the ServletContext when the application is initialized the first time - * and creates a FitlerChainManager to execute its filters for each request. + * and creates a FilterChainManager to execute its filters for each request. */ -public abstract class FilterChainManager { +public abstract class FilterChainManager { //------------------------------------------------------------- // Variables - Protected //------------------------------------------------------------- - protected static final String PATH_PART_SEPARATOR = "/"; + static final String PATH_PART_SEPARATOR = "/"; //------------------------------------------------------------- @@ -41,7 +48,7 @@ public abstract class FilterChainManager { // we use the synchronizedMap because we do not expect high concurrency on this object. Lambda only allows one // event at a time per container - private Map filterCache = Collections.synchronizedMap(new HashMap()); + private Map> filterCache = Collections.synchronizedMap(new HashMap>()); private int filtersSize = -1; protected ServletContextType servletContext; @@ -50,7 +57,7 @@ public abstract class FilterChainManager { // Constructors //------------------------------------------------------------- - protected FilterChainManager(ServletContextType context) { + FilterChainManager(ServletContextType context) { servletContext = context; } @@ -78,25 +85,34 @@ protected FilterChainManager(ServletContextType context) { * * This method currently does not filter on servlet name because the library assumes we are using a single servlet. * @param request The incoming servlet request + * @param servlet The final servlet in the filter chain (if any) * @return A FilterChainHolder object that can be used to apply the filters to the request */ - public FilterChainHolder getFilterChain(final HttpServletRequest request) { - String targetPath = request.getServletPath(); + FilterChainHolder getFilterChain(final HttpServletRequest request, Servlet servlet) { + String targetPath = request.getRequestURI(); DispatcherType type = request.getDispatcherType(); // only return the cached result if the filter list hasn't changed in the meanwhile - if (getFilterHolders().size() == filtersSize && getFilterChainCache(type, targetPath) != null) { - return getFilterChainCache(type, targetPath); + if (getFilterHolders().size() == filtersSize && getFilterChainCache(type, targetPath, servlet) != null) { + return getFilterChainCache(type, targetPath, servlet); } + AwsServletRegistration servletRegistration = (AwsServletRegistration)servletContext.getServletRegistrations() + .values().stream() + .filter((Predicate) servletRegistration1 -> ((AwsServletRegistration) servletRegistration1).getServlet().equals(servlet)) + .findFirst().orElse(null); + FilterChainHolder chainHolder = new FilterChainHolder(); Map registrations = getFilterHolders(); if (registrations == null || registrations.size() == 0) { + if (servletRegistration != null) { + chainHolder.addFilter(new FilterHolder(new ServletExecutionFilter(servletRegistration), servletContext)); + } return chainHolder; } - for (String name : registrations.keySet()) { - FilterHolder holder = registrations.get(name); + for (Map.Entry entry : registrations.entrySet()) { + FilterHolder holder = entry.getValue(); // we only check the dispatcher type if it's not empty. Otherwise we assume it's a REQUEST as per section 6.2.5 // of servlet specs if (holder.getRegistration().getDispatcherTypes().size() > 0 && !holder.getRegistration().getDispatcherTypes().contains(type)) { @@ -112,7 +128,11 @@ public FilterChainHolder getFilterChain(final HttpServletRequest request) { // we assume we only ever have one servlet. } - putFilterChainCache(type, targetPath, chainHolder); + if (servletRegistration != null) { + chainHolder.addFilter(new FilterHolder(new ServletExecutionFilter(servletRegistration), servletContext)); + } + + putFilterChainCache(type, targetPath, servlet, chainHolder); // update total filter size if (filtersSize != registrations.size()) { filtersSize = registrations.size(); @@ -127,17 +147,26 @@ public FilterChainHolder getFilterChain(final HttpServletRequest request) { /** * Retrieves a filter chain from the cache. The cache is lazily loaded as filter chains are requested. If the chain - * is not available in the cache, the method returns null. + * is not available in the cache, the method returns null. This method returns a new instance of FilterChainHolder + * initialized with the cached list of {@link FilterHolder} objects * @param type The dispatcher type for the incoming request * @param targetPath The request path - this is extracted with the getPath method of the request object + * @param servlet The final servlet in the filter chain (if any) * @return A populated FilterChainHolder */ - protected FilterChainHolder getFilterChainCache(final DispatcherType type, final String targetPath) { + private FilterChainHolder getFilterChainCache(final DispatcherType type, final String targetPath, Servlet servlet) { TargetCacheKey key = new TargetCacheKey(); key.setDispatcherType(type); key.setTargetPath(targetPath); + if (servlet != null) { + key.setServletName(servlet.getServletConfig().getServletName()); + } + + if (!filterCache.containsKey(key)) { + return null; + } - return filterCache.get(key); + return new FilterChainHolder(filterCache.get(key)); } @@ -148,19 +177,23 @@ protected FilterChainHolder getFilterChainCache(final DispatcherType type, final * method to retry this. * @param type DispatcherType from the incoming request * @param targetPath The target path in the API - * @param holder The FilterChainHolder object to save in the cache + * @param servlet The final servlet in the filter chain (if any) + * @param holder The FilterChainHolder object to save in the cache */ - protected void putFilterChainCache(final DispatcherType type, final String targetPath, final FilterChainHolder holder) { + private void putFilterChainCache(final DispatcherType type, final String targetPath, Servlet servlet, final FilterChainHolder holder) { TargetCacheKey key = new TargetCacheKey(); key.setDispatcherType(type); key.setTargetPath(targetPath); + if (servlet != null) { + key.setServletName(servlet.getServletConfig().getServletName()); + } // we couldn't compute the hash code because either the target path or dispatcher type were null if (key.hashCode() == -1) { return; } + filterCache.put(key, holder.getFilters()); - filterCache.put(key, holder); } @@ -171,9 +204,9 @@ protected void putFilterChainCache(final DispatcherType type, final String targe * @param mapping The mapping path stored in the filter registration * @return true if the given mapping path can apply to the target, false otherwise. */ - protected boolean pathMatches(final String target, final String mapping) { + boolean pathMatches(final String target, final String mapping) { // easiest case, they are exactly the same - if (target.toLowerCase().equals(mapping.toLowerCase())) { + if (target.toLowerCase(Locale.ENGLISH).equals(mapping.toLowerCase(Locale.ENGLISH))) { return true; } @@ -230,6 +263,7 @@ protected static class TargetCacheKey { private String targetPath; private DispatcherType dispatcherType; + private String servletName; //------------------------------------------------------------- @@ -269,17 +303,25 @@ public int hashCode() { } hashString += ":" + hashDispatcher; + if (servletName != null) { + hashString += ":" + servletName; + } + return hashString.hashCode(); } + @Override public boolean equals(Object key) { - if (!key.getClass().isAssignableFrom(TargetCacheKey.class)) { + if (key == null) { return false; - } else { - return hashCode() == key.hashCode(); } + if (key.getClass().equals(this.getClass())) { + return this.hashCode() == key.hashCode(); + } + + return false; } @@ -287,23 +329,56 @@ public boolean equals(Object key) { // Methods - Getter/Setter //------------------------------------------------------------- - public String getTargetPath() { - return targetPath; + void setTargetPath(String targetPath) { + this.targetPath = targetPath; } - public void setTargetPath(String targetPath) { - this.targetPath = targetPath; + void setDispatcherType(DispatcherType dispatcherType) { + this.dispatcherType = dispatcherType; + } + public void setServletName(String servletName) { + this.servletName = servletName; } - public DispatcherType getDispatcherType() { - return dispatcherType; + } + + @SuppressFBWarnings("URF_UNREAD_FIELD") + private class ServletExecutionFilter implements Filter { + + private FilterConfig config; + private AwsServletRegistration handlerServlet; + private boolean initialized; + + public ServletExecutionFilter(AwsServletRegistration servletReg) { + handlerServlet = servletReg; + initialized = handlerServlet.getServlet().getServletInfo() != null; } + @Override + public void init(FilterConfig filterConfig) + throws ServletException { + if (initialized) { + return; + } + config = filterConfig; + handlerServlet.getServlet().init(handlerServlet.getServletConfig()); + initialized = true; + } + + + @Override + public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) + throws IOException, ServletException { + handlerServlet.getServlet().service(servletRequest, servletResponse); + filterChain.doFilter(servletRequest, servletResponse); + } + + + @Override + public void destroy() { - public void setDispatcherType(DispatcherType dispatcherType) { - this.dispatcherType = dispatcherType; } } } diff --git a/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/servlet/FilterHolder.java b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/servlet/FilterHolder.java index 7eac6deb..c3a4cdb8 100644 --- a/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/servlet/FilterHolder.java +++ b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/servlet/FilterHolder.java @@ -12,9 +12,11 @@ */ package com.amazonaws.serverless.proxy.internal.servlet; -import javax.servlet.*; -import javax.servlet.annotation.WebFilter; -import javax.servlet.annotation.WebInitParam; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; + +import jakarta.servlet.*; +import jakarta.servlet.annotation.WebFilter; +import jakarta.servlet.annotation.WebInitParam; import java.util.*; /** @@ -29,7 +31,7 @@ public class FilterHolder { //------------------------------------------------------------- private Filter filter; - private FilterConfig filterConfig; + private FilterConfig filterConfig = new Config(); private Registration registration; private String filterName; private Map initParameters; @@ -81,6 +83,9 @@ public FilterHolder(String name, Filter newFilter, ServletContext context) { * @throws ServletException Propagates any servlet exception thrown by the filter initialization */ public void init() throws ServletException { + if (this.filterInitialized) { + return; + } this.getFilter().init(filterConfig); this.filterInitialized = true; } @@ -121,7 +126,7 @@ public FilterConfig getFilterConfig() { /** * Returns the Registration object for the filter. The Registration object defined in this file implements * both FilterRegistration and FilterRegistration.Dynamic - * @return The registration obejct + * @return The registration object */ public Registration getRegistration() { return registration; @@ -130,7 +135,7 @@ public Registration getRegistration() { /** * The name associated with the filter - * @return + * @return The name for the filter */ public String getFilterName() { return filterName; @@ -144,7 +149,7 @@ public void setFilterName(String filterName) { /** * The map of initialization parameters passed to the filter - * @return + * @return The Map of initialization parameters used for the filter */ public Map getInitParameters() { return initParameters; @@ -153,7 +158,7 @@ public Map getInitParameters() { /** * The servlet context that initialized the filter - * @return + * @return The servlet context the filter was initialized on */ public ServletContext getServletContext() { return servletContext; @@ -213,6 +218,7 @@ private WebFilter getAnnotation() { * Registration class for the filter. This object stores the servlet names and the url patterns the filter is * associated with. */ + @SuppressFBWarnings("URF_UNREAD_FIELD") protected class Registration implements FilterRegistration.Dynamic { //------------------------------------------------------------- @@ -286,6 +292,7 @@ public Collection getServletNameMappings() { public void addMappingForUrlPatterns(EnumSet types, boolean isLast, String... patterns) { if (types == null) { dispatcherTypes.add(DispatcherType.REQUEST); + dispatcherTypes.add(DispatcherType.ASYNC); } else { dispatcherTypes.addAll(types); } @@ -350,11 +357,11 @@ public String getInitParameter(String s) { @Override public Set setInitParameters(Map map) { Set conflicts = new LinkedHashSet<>(); - for (String newParamKey : map.keySet()) { - if (initParameters.get(newParamKey) != null) { - conflicts.add(newParamKey); + for (Map.Entry entry : map.entrySet()) { + if (initParameters.get(entry.getKey()) != null) { + conflicts.add(entry.getKey()); } else { - initParameters.put(newParamKey, map.get(newParamKey)); + initParameters.put(entry.getKey(), entry.getValue()); } } return conflicts; diff --git a/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/servlet/ServletLambdaContainerHandlerBuilder.java b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/servlet/ServletLambdaContainerHandlerBuilder.java new file mode 100644 index 00000000..a2c1c73f --- /dev/null +++ b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/servlet/ServletLambdaContainerHandlerBuilder.java @@ -0,0 +1,201 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance + * with the License. A copy of the License is located at + * + * http://aws.amazon.com/apache2.0/ + * + * or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES + * OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions + * and limitations under the License. + */ +package com.amazonaws.serverless.proxy.internal.servlet; + +import com.amazonaws.serverless.exceptions.ContainerInitializationException; +import com.amazonaws.serverless.proxy.*; +import com.amazonaws.serverless.proxy.model.AwsProxyRequest; +import com.amazonaws.serverless.proxy.model.AwsProxyResponse; +import com.amazonaws.serverless.proxy.model.HttpApiV2ProxyRequest; + +import jakarta.servlet.http.HttpServletRequest; +import java.util.ArrayList; +import java.util.List; + +/** + * Base builder class for {@link AwsLambdaServletContainerHandler}. Implmentations can extend this class to have setters + * for the basic parameters. + * @param The event object class + * @param The output object class + * @param The container request type. For proxy implementations, this is {@link AwsProxyHttpServletRequest}. + * The response type is hardcoded to {@link AwsHttpServletResponse} since it is a generic + * servlet response implementation. + * @param The type of the handler we are building + * @param The builder object itself. This is used to allow implementations to re-use the setter method from this + * abstract class through + * "curiously recurring generic patterns" + */ +public abstract class ServletLambdaContainerHandlerBuilder< + RequestType, + ResponseType, + ContainerRequestType extends HttpServletRequest, + HandlerType extends AwsLambdaServletContainerHandler, + Builder extends ServletLambdaContainerHandlerBuilder> +{ + private static final String MISSING_FIELD_ERROR = "Missing %s in lambda container handler builder"; + + protected InitializationWrapper initializationWrapper; + protected RequestReader requestReader; + protected ResponseWriter responseWriter; + protected SecurityContextWriter securityContextWriter; + protected ExceptionHandler exceptionHandler; + protected Class requestTypeClass; + protected Class responseTypeClass; + + /** + * Validates that all of the required fields are populated. + * @throws ContainerInitializationException If values have not been set on the builder. The message in the exception + * contains a standard error message {@link ServletLambdaContainerHandlerBuilder#MISSING_FIELD_ERROR} populated with + * the list of missing fields. + */ + protected void validate() throws ContainerInitializationException { + List errFields = new ArrayList<>(); + if (requestTypeClass == null) { + errFields.add("request type class"); + } + if (responseTypeClass == null) { + errFields.add("response type class"); + } + if (requestReader == null) { + errFields.add("request reader"); + } + if (responseWriter == null) { + errFields.add("response writer"); + } + if (securityContextWriter == null) { + errFields.add("security context writer"); + } + if (exceptionHandler == null) { + errFields.add("exception handler"); + } + if (initializationWrapper == null) { + errFields.add("initialization wrapper"); + } + if (!errFields.isEmpty()) { + throw new ContainerInitializationException(String.format(MISSING_FIELD_ERROR, String.join(", ", errFields)), null); + } + } + + /** + * Sets all of the required fields in the builder to the default settings for a Servlet-compatible framework that wants + * to support AWS proxy event and output types. + * @return A populated builder + */ + public Builder defaultProxy() { + initializationWrapper(new AsyncInitializationWrapper()) + .requestReader((RequestReader) new AwsProxyHttpServletRequestReader()) + .responseWriter((ResponseWriter) new AwsProxyHttpServletResponseWriter()) + .securityContextWriter((SecurityContextWriter) new AwsProxySecurityContextWriter()) + .exceptionHandler(defaultExceptionHandler()) + .requestTypeClass((Class) AwsProxyRequest.class) + .responseTypeClass((Class) AwsProxyResponse.class); + return self(); + } + + /** + * Sets all of the required fields in the builder to the default settings for a Servlet-compatible framework that wants + * to support HTTP API's v2 proxy event + * @return A populated builder + */ + public Builder defaultHttpApiV2Proxy() { + initializationWrapper(new AsyncInitializationWrapper()) + .requestReader((RequestReader) new AwsHttpApiV2HttpServletRequestReader()) + .responseWriter((ResponseWriter) new AwsProxyHttpServletResponseWriter(true)) + .securityContextWriter((SecurityContextWriter) new AwsHttpApiV2SecurityContextWriter()) + .exceptionHandler(defaultExceptionHandler()) + .requestTypeClass((Class) HttpApiV2ProxyRequest.class) + .responseTypeClass((Class) AwsProxyResponse.class); + return self(); + + } + + protected ExceptionHandler defaultExceptionHandler() { + return (ExceptionHandler) new AwsProxyExceptionHandler(); + } + + /** + * Sets the initialization wrapper to be used by the {@link ServletLambdaContainerHandlerBuilder#buildAndInitialize()} + * method to start the framework implementations + * @param initializationWrapper An implementation of InitializationWrapper. In most cases, this will be + * set to {@link InitializationWrapper}. The {@link ServletLambdaContainerHandlerBuilder#asyncInit(long)} + * method sets this to {@link AsyncInitializationWrapper}. + * @return This builder object + */ + public Builder initializationWrapper(InitializationWrapper initializationWrapper) { + this.initializationWrapper = initializationWrapper; + return self(); + } + + public Builder requestReader(RequestReader requestReader) { + this.requestReader = requestReader; + return self(); + } + + public Builder responseWriter(ResponseWriter responseWriter) { + this.responseWriter = responseWriter; + return self(); + } + + public Builder securityContextWriter(SecurityContextWriter securityContextWriter) { + this.securityContextWriter = securityContextWriter; + return self(); + } + + public Builder exceptionHandler(ExceptionHandler exceptionHandler) { + this.exceptionHandler = exceptionHandler; + return self(); + } + + public Builder requestTypeClass(Class requestType) { + this.requestTypeClass = requestType; + return self(); + } + + public Builder responseTypeClass(Class responseType) { + this.responseTypeClass = responseType; + return self(); + } + + /** + * Uses an async initializer with the given start time to calculate the 10 seconds timeout. + * + * @deprecated As of release 2.0.0 this method is deprecated. Initializer is always async if running in on-demand. + * @param actualStartTime An epoch in milliseconds that should be used to calculate the 10 seconds timeout since the start of the application + * @return A builder configured to use the async initializer + */ + @Deprecated + public Builder asyncInit(long actualStartTime) { + this.initializationWrapper = new AsyncInitializationWrapper(actualStartTime); + return self(); + } + + /** + * Uses a new {@link AsyncInitializationWrapper} with the no-parameter constructor that takes the actual JVM + * start time + * @deprecated As of release 2.0.0 this method is deprecated. Initializer is always async if running in on-demand. + * @return A builder configured to use an async initializer + */ + public Builder asyncInit() { + this.initializationWrapper = new AsyncInitializationWrapper(); + return self(); + } + + /** + * Implementations should implement this method to return their type. All of the builder methods in this abstract + * class use this method to return the correct builder type. + * @return The current builder. + */ + protected abstract Builder self(); + public abstract HandlerType build() throws ContainerInitializationException; + public abstract HandlerType buildAndInitialize() throws ContainerInitializationException; +} diff --git a/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/servlet/filters/UrlPathValidator.java b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/servlet/filters/UrlPathValidator.java index a488ce9a..aadb26ef 100644 --- a/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/servlet/filters/UrlPathValidator.java +++ b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/servlet/filters/UrlPathValidator.java @@ -12,12 +12,16 @@ */ package com.amazonaws.serverless.proxy.internal.servlet.filters; -import javax.servlet.*; -import javax.servlet.annotation.WebFilter; -import javax.servlet.http.HttpServletRequest; -import javax.servlet.http.HttpServletResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import jakarta.servlet.*; +import jakarta.servlet.annotation.WebFilter; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; -import java.util.regex.Pattern; +import java.net.URI; +import java.net.URISyntaxException; /** * Simple path validator filter. This is a default implementation to prevent malformed paths from hitting the framework @@ -31,7 +35,7 @@ public class UrlPathValidator implements Filter { //------------------------------------------------------------- public static final int DEFAULT_ERROR_CODE = 404; - public static final Pattern PATH_PATTERN = Pattern.compile("^(/[-\\w:@&?=+,.!/~*'%$_;]*)?$"); + //public static final Pattern PATH_PATTERN = Pattern.compile("^(/[-\\w:@&?=+,.!/~*'%$_;]*)?$"); public static final String PARAM_INVALID_STATUS_CODE = "invalid_status_code"; @@ -40,6 +44,7 @@ public class UrlPathValidator implements Filter { //------------------------------------------------------------- private int invalidStatusCode; + private Logger log = LoggerFactory.getLogger(UrlPathValidator.class); //------------------------------------------------------------- @@ -49,12 +54,16 @@ public class UrlPathValidator implements Filter { @Override public void init(FilterConfig filterConfig) throws ServletException { + if (filterConfig == null) { + invalidStatusCode = DEFAULT_ERROR_CODE; + return; + } if (filterConfig.getInitParameter(PARAM_INVALID_STATUS_CODE) != null) { String statusCode = filterConfig.getInitParameter(PARAM_INVALID_STATUS_CODE); try { invalidStatusCode = Integer.parseInt(statusCode); } catch (NumberFormatException e) { - // TODO: Log + log.error("Could not parse status code from filter config", e); invalidStatusCode = DEFAULT_ERROR_CODE; } } @@ -63,20 +72,25 @@ public void init(FilterConfig filterConfig) throws ServletException { @Override public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException { - // the getServletPath method of the AwsProxyHttpServletRequest returns the request path - String path = ((HttpServletRequest)servletRequest).getServletPath(); + // the getPathInfo method of the AwsProxyHttpServletRequest returns the request path with the correct base path stripped + String path = ((HttpServletRequest)servletRequest).getPathInfo(); if (path == null) { setErrorResponse(servletResponse); return; } - if (!PATH_PATTERN.matcher(path).matches()) { + // switching to this mechanism to avoid ReDOS attacks on the path pattern regex + try { + new URI(path); + } catch (URISyntaxException e) { + log.error("Invalid uri path in doFilter", e); setErrorResponse(servletResponse); return; } // Logic taken from the Apache UrlValidator. I opted not to include Apache lib as a dependency to save space // in the final Lambda function package + // https://github.com/apache/commons-validator/blob/trunk/src/main/java/org/apache/commons/validator/UrlValidator.java int slashCount = countStrings("/", path); int dot2Count = countStrings("..", path); int slash2Count = countStrings("//", path); @@ -94,6 +108,14 @@ public void destroy() { } + /** + * Returns the status code used in the errors generated by this filter. + * @return The default status code the filter will use + */ + public int getInvalidStatusCode() { + return invalidStatusCode; + } + //------------------------------------------------------------- // Methods - Private diff --git a/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/testutils/AwsProxyRequestBuilder.java b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/testutils/AwsProxyRequestBuilder.java deleted file mode 100644 index 065b28f9..00000000 --- a/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/testutils/AwsProxyRequestBuilder.java +++ /dev/null @@ -1,238 +0,0 @@ -/* - * Copyright 2016 Amazon.com, Inc. or its affiliates. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance - * with the License. A copy of the License is located at - * - * http://aws.amazon.com/apache2.0/ - * - * or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES - * OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions - * and limitations under the License. - */ -package com.amazonaws.serverless.proxy.internal.testutils; - -import com.amazonaws.serverless.proxy.internal.model.ApiGatewayAuthorizerContext; -import com.amazonaws.serverless.proxy.internal.model.ApiGatewayRequestContext; -import com.amazonaws.serverless.proxy.internal.model.ApiGatewayRequestIdentity; -import com.amazonaws.serverless.proxy.internal.model.AwsProxyRequest; -import com.amazonaws.serverless.proxy.internal.model.CognitoAuthorizerClaims; - -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; - -import javax.ws.rs.core.HttpHeaders; -import javax.ws.rs.core.MediaType; - -import java.io.File; -import java.io.IOException; -import java.util.HashMap; - -/** - * Request builder object. This is used by unit proxy to quickly create an AWS_PROXY request object - */ -public class AwsProxyRequestBuilder { - - //------------------------------------------------------------- - // Variables - Private - //------------------------------------------------------------- - - private AwsProxyRequest request; - private ObjectMapper mapper; - - - //------------------------------------------------------------- - // Constructors - //------------------------------------------------------------- - - public AwsProxyRequestBuilder() { - this(null, null); - } - - - public AwsProxyRequestBuilder(String path) { - this(path, null); - } - - - public AwsProxyRequestBuilder(String path, String httpMethod) { - this.mapper = new ObjectMapper(); - - this.request = new AwsProxyRequest(); - this.request.setHttpMethod(httpMethod); - this.request.setPath(path); - this.request.setQueryStringParameters(new HashMap<>()); - this.request.setRequestContext(new ApiGatewayRequestContext()); - this.request.getRequestContext().setStage("test"); - ApiGatewayRequestIdentity identity = new ApiGatewayRequestIdentity(); - identity.setSourceIp("127.0.0.1"); - this.request.getRequestContext().setIdentity(identity); - } - - - //------------------------------------------------------------- - // Methods - Public - //------------------------------------------------------------- - - public AwsProxyRequestBuilder stage(String stageName) { - this.request.getRequestContext().setStage(stageName); - return this; - } - - public AwsProxyRequestBuilder method(String httpMethod) { - this.request.setHttpMethod(httpMethod); - return this; - } - - - public AwsProxyRequestBuilder path(String path) { - this.request.setPath(path); - return this; - } - - - public AwsProxyRequestBuilder json() { - return this.header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON); - } - - - public AwsProxyRequestBuilder form(String key, String value) { - if (request.getHeaders() == null) { - request.setHeaders(new HashMap<>()); - } - request.getHeaders().put(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_FORM_URLENCODED); - String body = request.getBody(); - if (body == null) { - body = ""; - } - body += (body.equals("")?"":"&") + key + "=" + value; - request.setBody(body); - return this; - } - - - public AwsProxyRequestBuilder header(String key, String value) { - if (this.request.getHeaders() == null) { - this.request.setHeaders(new HashMap<>()); - } - - this.request.getHeaders().put(key, value); - return this; - } - - - public AwsProxyRequestBuilder queryString(String key, String value) { - if (this.request.getQueryStringParameters() == null) { - this.request.setQueryStringParameters(new HashMap<>()); - } - - this.request.getQueryStringParameters().put(key, value); - return this; - } - - - public AwsProxyRequestBuilder body(String body) { - this.request.setBody(body); - return this; - } - - public AwsProxyRequestBuilder body(Object body) { - if (request.getHeaders() != null && request.getHeaders().get(HttpHeaders.CONTENT_TYPE).equals(MediaType.APPLICATION_JSON)) { - try { - return body(mapper.writeValueAsString(body)); - } catch (JsonProcessingException e) { - throw new UnsupportedOperationException("Could not serialize object: " + e.getMessage()); - } - } else { - throw new UnsupportedOperationException("Unsupported content type in request"); - } - } - - - public AwsProxyRequestBuilder authorizerPrincipal(String principal) { - if (this.request.getRequestContext().getAuthorizer() == null) { - this.request.getRequestContext().setAuthorizer(new ApiGatewayAuthorizerContext()); - } - this.request.getRequestContext().getAuthorizer().setPrincipalId(principal); - return this; - } - - public AwsProxyRequestBuilder authorizerContextValue(String key, String value) { - if (this.request.getRequestContext().getAuthorizer() == null) { - this.request.getRequestContext().setAuthorizer(new ApiGatewayAuthorizerContext()); - } - this.request.getRequestContext().getAuthorizer().setContextValue(key, value); - return this; - } - - - public AwsProxyRequestBuilder cognitoUserPool(String identityId) { - this.request.getRequestContext().getIdentity().setCognitoAuthenticationType("POOL"); - this.request.getRequestContext().getIdentity().setCognitoIdentityId(identityId); - if (this.request.getRequestContext().getAuthorizer() == null) { - this.request.getRequestContext().setAuthorizer(new ApiGatewayAuthorizerContext()); - } - this.request.getRequestContext().getAuthorizer().setClaims(new CognitoAuthorizerClaims()); - this.request.getRequestContext().getAuthorizer().getClaims().setSubject(identityId); - - return this; - } - - - public AwsProxyRequestBuilder cognitoIdentity(String identityId, String identityPoolId) { - this.request.getRequestContext().getIdentity().setCognitoAuthenticationType("IDENTITY"); - this.request.getRequestContext().getIdentity().setCognitoIdentityId(identityId); - this.request.getRequestContext().getIdentity().setCognitoIdentityPoolId(identityPoolId); - return this; - } - - - public AwsProxyRequestBuilder cookie(String name, String value) { - if (request.getHeaders() == null) { - request.setHeaders(new HashMap<>()); - } - - String cookies = request.getHeaders().get(HttpHeaders.COOKIE); - if (cookies == null) { - cookies = ""; - } - - cookies += (cookies.equals("")?"":"; ") + name + "=" + value; - request.getHeaders().put(HttpHeaders.COOKIE, cookies); - return this; - } - - public AwsProxyRequestBuilder scheme(String scheme) { - if (request.getHeaders() == null) { - request.setHeaders(new HashMap<>()); - } - - request.getHeaders().put("CloudFront-Forwarded-Proto", scheme); - return this; - } - - public AwsProxyRequestBuilder serverName(String serverName) { - if (request.getHeaders() == null) { - request.setHeaders(new HashMap<>()); - } - - request.getHeaders().put("Host", serverName); - return this; - } - - public AwsProxyRequestBuilder fromJsonString(String jsonContent) - throws IOException { - request = mapper.readValue(jsonContent, AwsProxyRequest.class); - return this; - } - - public AwsProxyRequestBuilder fromJsonPath(String filePath) - throws IOException { - request = mapper.readValue(new File(filePath), AwsProxyRequest.class); - return this; - } - - public AwsProxyRequest build() { - return this.request; - } -} diff --git a/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/testutils/Timer.java b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/testutils/Timer.java new file mode 100644 index 00000000..b89ca934 --- /dev/null +++ b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/testutils/Timer.java @@ -0,0 +1,90 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance + * with the License. A copy of the License is located at + * + * http://aws.amazon.com/apache2.0/ + * + * or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES + * OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions + * and limitations under the License. + */ +package com.amazonaws.serverless.proxy.internal.testutils; + +import java.util.LinkedHashMap; +import java.util.Map; + +public final class Timer { + private volatile static Map timers = new LinkedHashMap<>(); + private volatile static boolean enabled = false; + + public static void start(String timerName) { + if (!enabled) { + return; + } + + timers.put(timerName, new TimerInfo(System.currentTimeMillis())); + } + + public static long stop(String timerName) { + if (!enabled) { + return 0L; + } + + TimerInfo info = timers.get(timerName); + if (info == null) { + throw new IllegalArgumentException("Could not find timer " + timerName); + } + + long stopTime = System.currentTimeMillis(); + info.stop(stopTime); + + return stopTime; + } + + + public static Map getTimers() { + return timers; + } + + public static TimerInfo getTimer(String timerName) { + return timers.get(timerName); + } + + public static void enable() { + enabled = true; + } + + public static void disable() { + enabled = false; + } + + private static class TimerInfo { + private long startTime; + private long stopTime; + private long duration; + + public TimerInfo(long start) { + startTime = start; + } + + public void stop(long stop) { + stopTime = stop; + duration = stopTime - startTime; + } + + public long getStartTime() { + return startTime; + } + + public long getStopTime() { + return stopTime; + } + + + public long getDuration() { + return duration; + } + } +} diff --git a/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/model/AlbContext.java b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/model/AlbContext.java new file mode 100644 index 00000000..527fc222 --- /dev/null +++ b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/model/AlbContext.java @@ -0,0 +1,30 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance + * with the License. A copy of the License is located at + * + * http://aws.amazon.com/apache2.0/ + * + * or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES + * OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions + * and limitations under the License. + */ +package com.amazonaws.serverless.proxy.model; + +/*** + * Context passed by ALB proxy events + */ +public class AlbContext { + private String targetGroupArn; + + + public String getTargetGroupArn() { + return targetGroupArn; + } + + + public void setTargetGroupArn(String targetGroupArn) { + this.targetGroupArn = targetGroupArn; + } +} diff --git a/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/model/ApiGatewayAuthorizerContext.java b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/model/ApiGatewayAuthorizerContext.java similarity index 83% rename from aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/model/ApiGatewayAuthorizerContext.java rename to aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/model/ApiGatewayAuthorizerContext.java index 9cf189a7..c2001954 100644 --- a/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/model/ApiGatewayAuthorizerContext.java +++ b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/model/ApiGatewayAuthorizerContext.java @@ -10,10 +10,11 @@ * OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions * and limitations under the License. */ -package com.amazonaws.serverless.proxy.internal.model; +package com.amazonaws.serverless.proxy.model; import com.fasterxml.jackson.annotation.JsonAnyGetter; import com.fasterxml.jackson.annotation.JsonAnySetter; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import java.util.HashMap; import java.util.Map; @@ -21,14 +22,13 @@ /** * Context object used for custom authorizers and Cognito User Pool authorizers. - *

- * Custom authorizers populate the principalId field. All other custom values - * returned by the authorizer are accessible via the getContextValue method. - *

- *

- * Cognito User Pool authorizers populate the

claims
object. - *

+ * + * Custom authorizers populate the principalId field. All other custom values + * returned by the authorizer are accessible via the getContextValue method. + * + * Cognito User Pool authorizers populate the claims object. */ +@JsonIgnoreProperties(ignoreUnknown = true) public class ApiGatewayAuthorizerContext { //------------------------------------------------------------- diff --git a/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/model/ApiGatewayRequestIdentity.java b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/model/ApiGatewayRequestIdentity.java similarity index 88% rename from aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/model/ApiGatewayRequestIdentity.java rename to aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/model/ApiGatewayRequestIdentity.java index 206b9439..89b1ea2a 100644 --- a/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/model/ApiGatewayRequestIdentity.java +++ b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/model/ApiGatewayRequestIdentity.java @@ -10,15 +10,22 @@ * OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions * and limitations under the License. */ -package com.amazonaws.serverless.proxy.internal.model; +package com.amazonaws.serverless.proxy.model; + + +import com.amazonaws.serverless.proxy.RequestReader; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + /** * Identity model for the API Gateway request context. This is used in the default AwsProxyRequest object. Contains - * all of the properties declared in the $context.identity API Gateway object so could be re-used for other implemnetations + * all of the properties declared in the $context.identity API Gateway object so could be re-used for other implementations * * @see AwsProxyRequest - * @see com.amazonaws.serverless.proxy.internal.RequestReader + * @see RequestReader */ +@JsonIgnoreProperties(ignoreUnknown = true) public class ApiGatewayRequestIdentity { //------------------------------------------------------------- @@ -26,6 +33,7 @@ public class ApiGatewayRequestIdentity { //------------------------------------------------------------- private String apiKey; + private String apiKeyId; private String userArn; private String cognitoAuthenticationType; private String caller; @@ -53,6 +61,16 @@ public void setApiKey(String apiKey) { } + public String getApiKeyId() { + return apiKeyId; + } + + + public void setApiKeyId(String apiKeyId) { + this.apiKeyId = apiKeyId; + } + + public String getUserArn() { return userArn; } diff --git a/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/model/AwsProxyRequest.java b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/model/AwsProxyRequest.java similarity index 59% rename from aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/model/AwsProxyRequest.java rename to aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/model/AwsProxyRequest.java index 39ae6c58..eeaaf4a6 100644 --- a/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/model/AwsProxyRequest.java +++ b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/model/AwsProxyRequest.java @@ -10,15 +10,18 @@ * OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions * and limitations under the License. */ -package com.amazonaws.serverless.proxy.internal.model; - -import com.fasterxml.jackson.annotation.JsonIgnore; +package com.amazonaws.serverless.proxy.model; +import java.util.HashMap; import java.util.Map; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; /** * Default implementation of the request object from an API Gateway AWS_PROXY integration */ +@JsonIgnoreProperties(ignoreUnknown = true) public class AwsProxyRequest { //------------------------------------------------------------- @@ -26,16 +29,26 @@ public class AwsProxyRequest { //------------------------------------------------------------- private String body; + private String version; private String resource; - private ApiGatewayRequestContext requestContext; + private AwsProxyRequestContext requestContext; + private MultiValuedTreeMap multiValueQueryStringParameters; private Map queryStringParameters; - private Map headers; + private Headers multiValueHeaders; + private SingleValueHeaders headers; private Map pathParameters; private String httpMethod; private Map stageVariables; private String path; private boolean isBase64Encoded; + public AwsProxyRequest() { + multiValueHeaders = new Headers(); + multiValueQueryStringParameters = new MultiValuedTreeMap<>(); + pathParameters = new HashMap<>(); + stageVariables = new HashMap<>(); + } + //------------------------------------------------------------- // Methods - Getter/Setter @@ -45,17 +58,29 @@ public class AwsProxyRequest { public String getQueryString() { StringBuilder params = new StringBuilder(""); - if (this.getQueryStringParameters() != null && this.getQueryStringParameters().size() > 0) { - for (String key : this.getQueryStringParameters().keySet()) { + if (this.getMultiValueQueryStringParameters() == null) { + return ""; + } + + for (String key : this.getMultiValueQueryStringParameters().keySet()) { + for (String val : this.getMultiValueQueryStringParameters().get(key)) { String separator = params.length() == 0 ? "?" : "&"; - params.append(separator + key + "=" + this.getQueryStringParameters().get(key)); + params.append(separator).append(key).append("=").append(val); } } return params.toString(); } + public RequestSource getRequestSource() { + if (getRequestContext() != null && getRequestContext().getElb() != null) { + return RequestSource.ALB; + } + + return RequestSource.API_GATEWAY; + } + public String getBody() { return body; @@ -71,38 +96,58 @@ public String getResource() { return resource; } + public String getVersion() { + return version; + } + + public void setVersion(String version) { + this.version = version; + } public void setResource(String resource) { this.resource = resource; } - public ApiGatewayRequestContext getRequestContext() { + public AwsProxyRequestContext getRequestContext() { return requestContext; } - public void setRequestContext(ApiGatewayRequestContext requestContext) { + public void setRequestContext(AwsProxyRequestContext requestContext) { this.requestContext = requestContext; } + public MultiValuedTreeMap getMultiValueQueryStringParameters() { + return multiValueQueryStringParameters; + } + + public void setMultiValueQueryStringParameters( + MultiValuedTreeMap multiValueQueryStringParameters) { + this.multiValueQueryStringParameters = multiValueQueryStringParameters; + } public Map getQueryStringParameters() { return queryStringParameters; } - public void setQueryStringParameters(Map queryStringParameters) { this.queryStringParameters = queryStringParameters; } + public Headers getMultiValueHeaders() { + return multiValueHeaders; + } - public Map getHeaders() { - return headers; + public void setMultiValueHeaders(Headers multiValueHeaders) { + this.multiValueHeaders = multiValueHeaders; } + public SingleValueHeaders getHeaders() { + return headers; + } - public void setHeaders(Map headers) { + public void setHeaders(SingleValueHeaders headers) { this.headers = headers; } @@ -146,7 +191,7 @@ public void setPath(String path) { this.path = path; } - + @JsonProperty("isBase64Encoded") public boolean isBase64Encoded() { return isBase64Encoded; } diff --git a/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/model/ApiGatewayRequestContext.java b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/model/AwsProxyRequestContext.java similarity index 67% rename from aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/model/ApiGatewayRequestContext.java rename to aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/model/AwsProxyRequestContext.java index 406fbad0..e7cb1bf6 100644 --- a/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/model/ApiGatewayRequestContext.java +++ b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/model/AwsProxyRequestContext.java @@ -10,7 +10,13 @@ * OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions * and limitations under the License. */ -package com.amazonaws.serverless.proxy.internal.model; +package com.amazonaws.serverless.proxy.model; + + +import com.amazonaws.serverless.proxy.RequestReader; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + /** * The API Gateway request context object. This is used by the default implementation of the AWS_PROXY integration type. @@ -18,9 +24,10 @@ * readers. * * @see AwsProxyRequest - * @see com.amazonaws.serverless.proxy.internal.RequestReader + * @see RequestReader */ -public class ApiGatewayRequestContext { +@JsonIgnoreProperties(ignoreUnknown = true) +public class AwsProxyRequestContext { //------------------------------------------------------------- // Variables - Private @@ -31,10 +38,17 @@ public class ApiGatewayRequestContext { private String resourcePath; private String httpMethod; private String requestId; + private String extendedRequestId; private String accountId; private ApiGatewayRequestIdentity identity; private ApiGatewayAuthorizerContext authorizer; private String stage; + private String path; + private String protocol; + private String requestTime; + private long requestTimeEpoch; + + private AlbContext elb; //------------------------------------------------------------- @@ -91,6 +105,16 @@ public void setRequestId(String requestId) { } + public String getExtendedRequestId() { + return extendedRequestId; + } + + + public void setExtendedRequestId(String extendedRequestId) { + this.extendedRequestId = extendedRequestId; + } + + public String getAccountId() { return accountId; } @@ -119,6 +143,15 @@ public String getStage() { public void setStage(String stage) { this.stage = stage; } + + public String getPath() { + return path; + } + + + public void setPath(String path) { + this.path = path; + } public ApiGatewayAuthorizerContext getAuthorizer() { @@ -129,4 +162,44 @@ public ApiGatewayAuthorizerContext getAuthorizer() { public void setAuthorizer(ApiGatewayAuthorizerContext authorizer) { this.authorizer = authorizer; } + + + public String getProtocol() { + return protocol; + } + + + public void setProtocol(String protocol) { + this.protocol = protocol; + } + + + public String getRequestTime() { + return requestTime; + } + + + public void setRequestTime(String requestTime) { + this.requestTime = requestTime; + } + + + public long getRequestTimeEpoch() { + return requestTimeEpoch; + } + + + public void setRequestTimeEpoch(long requestTimeEpoch) { + this.requestTimeEpoch = requestTimeEpoch; + } + + + public AlbContext getElb() { + return elb; + } + + + public void setElb(AlbContext elb) { + this.elb = elb; + } } diff --git a/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/model/AwsProxyResponse.java b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/model/AwsProxyResponse.java similarity index 69% rename from aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/model/AwsProxyResponse.java rename to aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/model/AwsProxyResponse.java index 43393fda..5f70d2a4 100644 --- a/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/model/AwsProxyResponse.java +++ b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/model/AwsProxyResponse.java @@ -10,14 +10,19 @@ * OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions * and limitations under the License. */ -package com.amazonaws.serverless.proxy.internal.model; +package com.amazonaws.serverless.proxy.model; + + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; -import java.util.HashMap; import java.util.Map; + /** * Response object for an API Gateway method using AWS_PROXY integrations */ +@JsonInclude(JsonInclude.Include.NON_NULL) public class AwsProxyResponse { //------------------------------------------------------------- @@ -25,7 +30,9 @@ public class AwsProxyResponse { //------------------------------------------------------------- private int statusCode; + private String statusDescription; private Map headers; + private Headers multiValueHeaders; private String body; private boolean isBase64Encoded; @@ -44,14 +51,14 @@ public AwsProxyResponse(int statusCode) { } - public AwsProxyResponse(int statusCode, Map headers) { + public AwsProxyResponse(int statusCode, Headers headers) { this(statusCode, headers, null); } - public AwsProxyResponse(int statusCode, Map headers, String body) { + public AwsProxyResponse(int statusCode, Headers headers, String body) { this.statusCode = statusCode; - this.headers = headers; + this.multiValueHeaders = headers; this.body = body; } @@ -61,11 +68,11 @@ public AwsProxyResponse(int statusCode, Map headers, String body //------------------------------------------------------------- public void addHeader(String key, String value) { - if (this.headers == null) { - this.headers = new HashMap(); + if (this.multiValueHeaders == null) { + this.multiValueHeaders = new Headers(); } - this.headers.put(key, value); + this.multiValueHeaders.add(key, value); } @@ -93,6 +100,16 @@ public void setHeaders(Map headers) { } + public Headers getMultiValueHeaders() { + return multiValueHeaders; + } + + + public void setMultiValueHeaders(Headers multiValueHeaders) { + this.multiValueHeaders = multiValueHeaders; + } + + public String getBody() { return body; } @@ -102,6 +119,7 @@ public void setBody(String body) { this.body = body; } + @JsonProperty("isBase64Encoded") public boolean isBase64Encoded() { return isBase64Encoded; } @@ -109,4 +127,14 @@ public boolean isBase64Encoded() { public void setBase64Encoded(boolean base64Encoded) { isBase64Encoded = base64Encoded; } + + + public String getStatusDescription() { + return statusDescription; + } + + + public void setStatusDescription(String statusDescription) { + this.statusDescription = statusDescription; + } } diff --git a/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/model/CognitoAuthorizerClaims.java b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/model/CognitoAuthorizerClaims.java similarity index 86% rename from aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/model/CognitoAuthorizerClaims.java rename to aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/model/CognitoAuthorizerClaims.java index 552644df..34e2e47a 100644 --- a/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/model/CognitoAuthorizerClaims.java +++ b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/model/CognitoAuthorizerClaims.java @@ -10,12 +10,16 @@ * OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions * and limitations under the License. */ -package com.amazonaws.serverless.proxy.internal.model; +package com.amazonaws.serverless.proxy.model; +import com.fasterxml.jackson.annotation.JsonAnyGetter; +import com.fasterxml.jackson.annotation.JsonAnySetter; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; -import java.time.format.DateTimeFormatter; +import java.util.HashMap; +import java.util.Map; /** @@ -36,12 +40,15 @@ * } * */ +@JsonIgnoreProperties(ignoreUnknown = true) public class CognitoAuthorizerClaims { //------------------------------------------------------------- // Variables - Private //------------------------------------------------------------- + private Map claims = new HashMap<>(); + @JsonProperty(value = "sub") private String subject; @JsonProperty(value = "aud") @@ -67,6 +74,16 @@ public class CognitoAuthorizerClaims { // Methods - Getter/Setter //------------------------------------------------------------- + @JsonAnyGetter + public String getClaim(String claim) { + return claims.get(claim); + } + + @JsonAnySetter + public void setClaim(String claim, String value) { + claims.put(claim, value); + } + public String getSubject() { return this.subject; } diff --git a/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/model/ContainerConfig.java b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/model/ContainerConfig.java new file mode 100644 index 00000000..941109b7 --- /dev/null +++ b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/model/ContainerConfig.java @@ -0,0 +1,334 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance + * with the License. A copy of the License is located at + * + * http://aws.amazon.com/apache2.0/ + * + * or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES + * OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions + * and limitations under the License. + */ +package com.amazonaws.serverless.proxy.model; + +import com.amazonaws.serverless.proxy.internal.servlet.AwsProxyHttpServletRequest; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; + +/** + * Configuration parameters for the framework + */ +public class ContainerConfig { + public static final String DEFAULT_URI_ENCODING = "UTF-8"; + public static final String DEFAULT_CONTENT_CHARSET = "ISO-8859-1"; + private static final List DEFAULT_FILE_PATHS = new ArrayList() {{ add("/tmp"); add("/var/task"); }}; + private static final int DEFAULT_MAX_INIT_TIMEOUT_MS = 20_000; + private static final String MAX_INIT_TIMEOUT_ENVIRONMENT_VARIABLE_NAME = "AWS_SERVERLESS_JAVA_CONTAINER_MAX_INIT_TIMEOUT"; + private static final int MAX_INIT_TIMEOUT_MS = Integer.parseInt(System.getenv().getOrDefault( + MAX_INIT_TIMEOUT_ENVIRONMENT_VARIABLE_NAME, Integer.toString(DEFAULT_MAX_INIT_TIMEOUT_MS))); + + public static ContainerConfig defaultConfig() { + ContainerConfig configuration = new ContainerConfig(); + configuration.setStripBasePath(false); + configuration.setUriEncoding(DEFAULT_URI_ENCODING); + configuration.setConsolidateSetCookieHeaders(false); + configuration.setUseStageAsServletContext(false); + configuration.setValidFilePaths(DEFAULT_FILE_PATHS); + configuration.setQueryStringCaseSensitive(false); + configuration.addBinaryContentTypes("application/octet-stream", "image/jpeg", "image/png", "image/gif"); + configuration.setDefaultContentCharset(DEFAULT_CONTENT_CHARSET); + configuration.setInitializationTimeout(MAX_INIT_TIMEOUT_MS); + configuration.setDisableExceptionMapper(false); + + return configuration; + } + + //------------------------------------------------------------- + // Variables - Private + //------------------------------------------------------------- + + private String serviceBasePath; + private boolean stripBasePath; + private String uriEncoding; + private String defaultContentCharset; + private boolean consolidateSetCookieHeaders; + private boolean useStageAsServletContext; + private List validFilePaths; + private List customDomainNames; + private boolean queryStringCaseSensitive; + private final HashSet binaryContentTypes; + private int initializationTimeout; + private boolean disableExceptionMapper; + + public ContainerConfig() { + validFilePaths = new ArrayList<>(); + customDomainNames = new ArrayList<>(); + binaryContentTypes = new HashSet<>(); + } + + + //------------------------------------------------------------- + // Methods - Getter/Setter + //------------------------------------------------------------- + + + /** + * Returns the base path configured in the container. This configuration variable is used in conjuction with {@link #setStripBasePath(boolean)} to route + * the request. When requesting the context path from an HttpServletRequest: {@link AwsProxyHttpServletRequest#getContextPath()} this base path is added + * to the context even though it was initially stripped for the purpose of routing the request. We decided to add it to the context to address GitHub issue + * #84 and allow framework's link builders to it. + * + * @return The base path configured for the container + */ + public String getServiceBasePath() { + return serviceBasePath; + } + + + /** + * Configures a base path that can be stripped from the request path before passing it to the framework-specific implementation. This can be used to + * remove API Gateway's base path mappings from the request. + * @param serviceBasePath The base path mapping to be removed. + */ + public void setServiceBasePath(String serviceBasePath) { + if (serviceBasePath == null) { + this.serviceBasePath = null; + return; + } + // clean up base path before setting it, we want a "/" at the beginning but not at the end. + String finalBasePath = serviceBasePath; + if (!finalBasePath.startsWith("/")) { + finalBasePath = "/" + serviceBasePath; + } + if (finalBasePath.endsWith("/")) { + finalBasePath = finalBasePath.substring(0, finalBasePath.length() - 1); + } + this.serviceBasePath = finalBasePath; + } + + + public boolean isStripBasePath() { + return stripBasePath; + } + + + /** + * Whether this framework should strip the base path mapping specified with the {@link #setServiceBasePath(String)} method from a request before + * passing it to the framework-specific implementations + * @param stripBasePath + */ + public void setStripBasePath(boolean stripBasePath) { + this.stripBasePath = stripBasePath; + } + + + public String getUriEncoding() { + return uriEncoding; + } + + + /** + * Sets the charset used to URLEncode and Decode request paths. + * @param uriEncoding The charset. By default this is set to UTF-8 + */ + public void setUriEncoding(String uriEncoding) { + this.uriEncoding = uriEncoding; + } + + + public boolean isConsolidateSetCookieHeaders() { + return consolidateSetCookieHeaders; + } + + + /** + * Tells the library to consolidate multiple Set-Cookie headers into a single Set-Cookie header with multiple, comma-separated values. This is allowed + * by the RFC 2109 (https://tools.ietf.org/html/rfc2109). However, since not all clients support this, we consider it optional. When this value is set + * to true the framework will consolidate all Set-Cookie headers into a single header, when it's set to false, the framework will only return the first + * Set-Cookie header specified in a response. + * + * Because API Gateway needs header keys to be unique, we give an option to configure this. + * @param consolidateSetCookieHeaders Whether to consolidate the cookie headers or not. + */ + public void setConsolidateSetCookieHeaders(boolean consolidateSetCookieHeaders) { + this.consolidateSetCookieHeaders = consolidateSetCookieHeaders; + } + + + /** + * Tells whether the stage name passed in the request should be added to the context path: {@link AwsProxyHttpServletRequest#getContextPath()}. + * @return true if the stage will be included in the context path, false otherwise. + */ + public boolean isUseStageAsServletContext() { + return useStageAsServletContext; + } + + + /** + * Sets whether the API Gateway stage name should be included in the servlet context path. + * @param useStageAsServletContext true if you want the stage to appear as the root of the context path, false otherwise. + */ + public void setUseStageAsServletContext(boolean useStageAsServletContext) { + this.useStageAsServletContext = useStageAsServletContext; + } + + + /** + * Returns the list of file paths that the servlet accepts read/write requests to + * @return A List of file paths. By default this is set to /tmp and /var/task + */ + public List getValidFilePaths() { + return validFilePaths; + } + + + /** + * Sets a list of valid file paths for the servlet to read/write from. + * @param validFilePaths A populated list of base paths + */ + public void setValidFilePaths(List validFilePaths) { + this.validFilePaths = validFilePaths; + } + + + /** + * Adds a new base path to the list of allowed paths. + * @param filePath The base path + */ + public void addValidFilePath(String filePath) { + validFilePaths.add(filePath); + } + + + /** + * Adds a new custom domain name to the list of allowed domains + * @param name The new custom domain name, excluding the scheme ("https") and port + */ + public void addCustomDomain(String name) { + customDomainNames.add(name); + } + + + /** + * Returns the list of custom domain names enabled for the application + * @return The configured custom domain names + */ + public List getCustomDomainNames() { + return customDomainNames; + } + + + /** + * Enables localhost custom domain name for testing. This setting should be used only in local + * with SAM local + */ + public void enableLocalhost() { + customDomainNames.add("localhost"); + } + + + /** + * Whether query string parameters in the request should be case sensitive or not. By default + * this is set to false for backward compatibility. + * @return true if the parameter matching algorithm is case sensitive + */ + public boolean isQueryStringCaseSensitive() { + return queryStringCaseSensitive; + } + + + /** + * Sets whether query string parameter names should be treated as case sensitive. The default + * value of this option is false for backward compatibility. + * @param queryStringCaseSensitive Tells the framework to treat query string parameter names as case sensitive + */ + public void setQueryStringCaseSensitive(boolean queryStringCaseSensitive) { + this.queryStringCaseSensitive = queryStringCaseSensitive; + } + + /** + * Configure specified content type(s) as binary + * @param contentTypes list of exact content types that will be considered as binary + */ + public void addBinaryContentTypes(String... contentTypes) { + if(contentTypes != null) { + binaryContentTypes.addAll(Arrays.asList(contentTypes)); + } + } + + /** + * Determine if specified content type has been configured as binary + * @param contentType content type to query + * @return + */ + public boolean isBinaryContentType(String contentType) { + return contentType != null && binaryContentTypes.contains(contentType.trim()); + } + + + /** + * Returns the name of the default charset appended to the Content-Type header if no charset is specified by the request. The + * default value of this is ISO-8859-1. + * @return The name of the default charset for the Content-Type header + */ + public String getDefaultContentCharset() { + return defaultContentCharset; + } + + + /** + * Sets the default charset value for the Content-Type header if no charset is specified with a request. The default value of this + * is ISO-8859-1. If a request specifies a Content-Type header without a charset property, the value of + * this field is automatically appended to the header. + * @param defaultContentCharset The name of the charset for the content type header. + */ + public void setDefaultContentCharset(String defaultContentCharset) { + this.defaultContentCharset = defaultContentCharset; + } + + /** + * Returns the maximum amount of time (in milliseconds) set for the initialization time. See documentation on the + * {@link #setInitializationTimeout(int)} for additional details. + * @return The max time allocated for initialization + */ + public int getInitializationTimeout() { + return initializationTimeout; + } + + /** + * Sets the initialization timeout. When using an async {@link com.amazonaws.serverless.proxy.InitializationWrapper} + * the underlying framework is initialized in a separate thread. Serverless Java Container will wait for the maximum + * time available during AWS Lambda's init step (~10 seconds) and then return control to the main thread. In the meanwhile, + * the initialization process of the underlying framework can continue in a separate thread. AWS Lambda will then call + * the handler class to handle an event. This timeout is the maximum amount of time Serverless Java Container framework + * will wait for the underlying framework to initialize before returning an error. By default, this is set to 10 seconds. + * @param initializationTimeout The maximum amount of time to wait for the underlying framework initialization after + * an event is received in milliseconds. + */ + public void setInitializationTimeout(int initializationTimeout) { + this.initializationTimeout = initializationTimeout; + } + + /** + * Whether the framework will run exception thrown by the application through the implementation of + * {@link com.amazonaws.serverless.proxy.ExceptionHandler}. When this parameter is set to false the Lambda + * container handler object lets the Exception propagate upwards to the Lambda handler class. + * @return true if exception mapping is disabled, false otherwise. + */ + public boolean isDisableExceptionMapper() { + return disableExceptionMapper; + } + + /** + * This configuration parameter tells the container whether it should skip exception mapping and simply let any + * Exception thrown by the underlying application bubble up to the Lambda handler class. + * @param disable Set this value to true to disable exception mapping, false otherwise. + */ + public void setDisableExceptionMapper(boolean disable) { + this.disableExceptionMapper = disable; + } +} diff --git a/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/model/ErrorModel.java b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/model/ErrorModel.java similarity index 93% rename from aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/model/ErrorModel.java rename to aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/model/ErrorModel.java index dbc1c7ca..64514691 100644 --- a/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/model/ErrorModel.java +++ b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/model/ErrorModel.java @@ -10,10 +10,10 @@ * OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions * and limitations under the License. */ -package com.amazonaws.serverless.proxy.internal.model; +package com.amazonaws.serverless.proxy.model; /** - * Default error response model. This object is used by the AwsProxyExceptionHandler objcect. + * Default error response model. This object is used by the AwsProxyExceptionHandler object. */ public class ErrorModel { diff --git a/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/model/Headers.java b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/model/Headers.java new file mode 100644 index 00000000..73f9d284 --- /dev/null +++ b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/model/Headers.java @@ -0,0 +1,22 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance + * with the License. A copy of the License is located at + * + * http://aws.amazon.com/apache2.0/ + * + * or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES + * OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions + * and limitations under the License. + */ +package com.amazonaws.serverless.proxy.model; + +public class Headers extends MultiValuedTreeMap { + + private static final long serialVersionUID = 42L; + + public Headers() { + super(String.CASE_INSENSITIVE_ORDER); + } +} diff --git a/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/model/HttpApiV2AuthorizerMap.java b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/model/HttpApiV2AuthorizerMap.java new file mode 100644 index 00000000..8226be98 --- /dev/null +++ b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/model/HttpApiV2AuthorizerMap.java @@ -0,0 +1,128 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance + * with the License. A copy of the License is located at + * + * http://aws.amazon.com/apache2.0/ + * + * or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES + * OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions + * and limitations under the License. + */ +package com.amazonaws.serverless.proxy.model; + +import com.amazonaws.serverless.proxy.internal.LambdaContainerHandler; +import tools.jackson.core.JsonGenerator; +import tools.jackson.core.JsonParser; +import tools.jackson.databind.DeserializationContext; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.SerializationContext; +import tools.jackson.databind.annotation.JsonDeserialize; +import tools.jackson.databind.annotation.JsonSerialize; +import tools.jackson.databind.deser.std.StdDeserializer; +import tools.jackson.databind.ser.std.StdSerializer; +import tools.jackson.databind.type.TypeFactory; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +@JsonSerialize(using = HttpApiV2AuthorizerMap.HttpApiV2AuthorizerSerializer.class) +@JsonDeserialize(using = HttpApiV2AuthorizerMap.HttpApiV2AuthorizerDeserializer.class) +public class HttpApiV2AuthorizerMap extends HashMap { + private static final String JWT_KEY = "jwt"; + private static final String LAMBDA_KEY = "lambda"; + private static final String IAM_KEY = "iam"; + private static final long serialVersionUID = 42L; + + public HttpApiV2JwtAuthorizer getJwtAuthorizer() { + return (HttpApiV2JwtAuthorizer) get(JWT_KEY); + } + + public Map getLambdaAuthorizerContext() { + return (Map) get(LAMBDA_KEY); + } + + public HttpApiV2IamAuthorizer getIamAuthorizer() { + return (HttpApiV2IamAuthorizer) get(IAM_KEY); + } + + public boolean isJwt() { + return containsKey(JWT_KEY); + } + + public boolean isLambda() { + return containsKey(LAMBDA_KEY); + } + + public boolean isIam() { + return containsKey(IAM_KEY); + } + + public void putJwtAuthorizer(HttpApiV2JwtAuthorizer jwt) { + put(JWT_KEY, jwt); + } + + public void putIamAuthorizer(HttpApiV2IamAuthorizer iam) { + put(IAM_KEY, iam); + } + + public static class HttpApiV2AuthorizerDeserializer extends StdDeserializer { + private static final long serialVersionUID = 42L; + + public HttpApiV2AuthorizerDeserializer() { + super(HttpApiV2AuthorizerMap.class); + } + + @Override + public HttpApiV2AuthorizerMap deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) { + HttpApiV2AuthorizerMap map = new HttpApiV2AuthorizerMap(); + JsonNode node = deserializationContext.readTree(jsonParser); + if (node.has(JWT_KEY)) { + HttpApiV2JwtAuthorizer authorizer = LambdaContainerHandler.getObjectMapper() + .treeToValue(node.get(JWT_KEY), HttpApiV2JwtAuthorizer.class); + map.putJwtAuthorizer(authorizer); + } + if (node.has(LAMBDA_KEY)) { + Map context = LambdaContainerHandler.getObjectMapper().treeToValue(node.get(LAMBDA_KEY), + LambdaContainerHandler.getObjectMapper().getTypeFactory().constructMapType(HashMap.class, String.class, Object.class)); + map.put(LAMBDA_KEY, context); + } + if (node.has(IAM_KEY)) { + HttpApiV2IamAuthorizer iam_authorizer = LambdaContainerHandler.getObjectMapper() + .treeToValue(node.get(IAM_KEY), HttpApiV2IamAuthorizer.class); + map.putIamAuthorizer(iam_authorizer); + } + // we ignore other, unknown values + return map; + } + } + + public static class HttpApiV2AuthorizerSerializer extends StdSerializer { + private static final long serialVersionUID = 42L; + + public HttpApiV2AuthorizerSerializer() { + super(HttpApiV2AuthorizerMap.class); + } + + @Override + public void serialize(HttpApiV2AuthorizerMap httpApiV2AuthorizerMap, JsonGenerator jsonGenerator, + SerializationContext serializationContext) { + jsonGenerator.writeStartObject(); + if (httpApiV2AuthorizerMap.isJwt()) { + jsonGenerator.writeName(JWT_KEY); + jsonGenerator.writePOJO(httpApiV2AuthorizerMap.getJwtAuthorizer()); + } + if (httpApiV2AuthorizerMap.isLambda()) { + jsonGenerator.writeName(LAMBDA_KEY); + jsonGenerator.writePOJO(httpApiV2AuthorizerMap.getLambdaAuthorizerContext()); + } + if (httpApiV2AuthorizerMap.isIam()) { + jsonGenerator.writeName(IAM_KEY); + jsonGenerator.writePOJO(httpApiV2AuthorizerMap.get(IAM_KEY)); + } + jsonGenerator.writeEndObject(); + } + } +} \ No newline at end of file diff --git a/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/model/HttpApiV2HttpContext.java b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/model/HttpApiV2HttpContext.java new file mode 100644 index 00000000..e6eb1287 --- /dev/null +++ b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/model/HttpApiV2HttpContext.java @@ -0,0 +1,61 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance + * with the License. A copy of the License is located at + * + * http://aws.amazon.com/apache2.0/ + * + * or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES + * OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions + * and limitations under the License. + */ +package com.amazonaws.serverless.proxy.model; + +public class HttpApiV2HttpContext { + private String method; + private String path; + private String protocol; + private String sourceIp; + private String userAgent; + + public String getMethod() { + return method; + } + + public void setMethod(String method) { + this.method = method; + } + + public String getPath() { + return path; + } + + public void setPath(String path) { + this.path = path; + } + + public String getProtocol() { + return protocol; + } + + public void setProtocol(String protocol) { + this.protocol = protocol; + } + + public String getSourceIp() { + return sourceIp; + } + + public void setSourceIp(String sourceIp) { + this.sourceIp = sourceIp; + } + + public String getUserAgent() { + return userAgent; + } + + public void setUserAgent(String userAgent) { + this.userAgent = userAgent; + } +} diff --git a/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/model/HttpApiV2IamAuthorizer.java b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/model/HttpApiV2IamAuthorizer.java new file mode 100644 index 00000000..d2a0952e --- /dev/null +++ b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/model/HttpApiV2IamAuthorizer.java @@ -0,0 +1,68 @@ +package com.amazonaws.serverless.proxy.model; + +public class HttpApiV2IamAuthorizer { + public String accessKey; + public String accountId; + public String callerId; + public String cognitoIdentity; + public String principalOrgId; + public String userArn; + public String userId; + + public String getAccessKey() { + return accessKey; + } + + public String getAccountId() { + return accountId; + } + + public String getCallerId() { + return callerId; + } + + public String getCognitoIdentity() { + return cognitoIdentity; + } + + public String getPrincipalOrgId() { + return principalOrgId; + } + + public String getUserArn() { + return userArn; + } + + public String getUserId() { + return userId; + } + + public void setAccessKey(String accessKey) { + this.accessKey = accessKey; + } + + public void setAccountId(String accountId) { + this.accountId = accountId; + } + + public void setCallerId(String callerId) { + this.callerId = callerId; + } + + public void setCognitoIdentity(String cognitoIdentity) { + this.cognitoIdentity = cognitoIdentity; + } + + public void setPrincipalOrgId(String principalOrgId) { + this.principalOrgId = principalOrgId; + } + + public void setUserArn(String userArn) { + this.userArn = userArn; + } + + public void setUserId(String userId) { + this.userId = userId; + } + +} \ No newline at end of file diff --git a/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/model/HttpApiV2JwtAuthorizer.java b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/model/HttpApiV2JwtAuthorizer.java new file mode 100644 index 00000000..d81a3c77 --- /dev/null +++ b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/model/HttpApiV2JwtAuthorizer.java @@ -0,0 +1,37 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance + * with the License. A copy of the License is located at + * + * http://aws.amazon.com/apache2.0/ + * + * or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES + * OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions + * and limitations under the License. + */ +package com.amazonaws.serverless.proxy.model; + +import java.util.List; +import java.util.Map; + +public class HttpApiV2JwtAuthorizer { + private Map claims; + private List scopes; + + public Map getClaims() { + return claims; + } + + public void setClaims(Map claims) { + this.claims = claims; + } + + public List getScopes() { + return scopes; + } + + public void setScopes(List scopes) { + this.scopes = scopes; + } +} diff --git a/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/model/HttpApiV2ProxyRequest.java b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/model/HttpApiV2ProxyRequest.java new file mode 100644 index 00000000..023236cc --- /dev/null +++ b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/model/HttpApiV2ProxyRequest.java @@ -0,0 +1,141 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance + * with the License. A copy of the License is located at + * + * http://aws.amazon.com/apache2.0/ + * + * or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES + * OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions + * and limitations under the License. + */ +package com.amazonaws.serverless.proxy.model; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonIgnore; + +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +public class HttpApiV2ProxyRequest { + private String version; + private String routeKey; + private String rawPath; + private String rawQueryString; + private List cookies; + private Map headers; + private Map queryStringParameters; + private String body; + private Map pathParameters; + private boolean isBase64Encoded; + private Map stageVariables; + private HttpApiV2ProxyRequestContext requestContext; + + public String getVersion() { + return version; + } + + public void setVersion(String version) { + this.version = version; + } + + public String getRouteKey() { + return routeKey; + } + + public void setRouteKey(String routeKey) { + this.routeKey = routeKey; + } + + public String getRawPath() { + return rawPath; + } + + public void setRawPath(String rawPath) { + this.rawPath = rawPath; + } + + public String getRawQueryString() { + return rawQueryString; + } + + public void setRawQueryString(String rawQueryString) { + this.rawQueryString = rawQueryString; + } + + public List getCookies() { + return cookies; + } + + public void setCookies(List cookies) { + this.cookies = cookies; + } + + public Map getHeaders() { + return headers; + } + + public void setHeaders(Map headers) { + this.headers = headers; + } + + public Map getQueryStringParameters() { + return queryStringParameters; + } + + public void setQueryStringParameters(Map queryStringParameters) { + this.queryStringParameters = queryStringParameters; + } + + public String getBody() { + return body; + } + + public Map getPathParameters() { + return pathParameters; + } + + public void setPathParameters(Map pathParameters) { + this.pathParameters = pathParameters; + } + + public void setBody(String body) { + this.body = body; + } + + @JsonProperty("isBase64Encoded") + public boolean isBase64Encoded() { + return isBase64Encoded; + } + + public void setBase64Encoded(boolean base64Encoded) { + isBase64Encoded = base64Encoded; + } + + public Map getStageVariables() { + return stageVariables; + } + + public void setStageVariables(Map stageVariables) { + this.stageVariables = stageVariables; + } + + public HttpApiV2ProxyRequestContext getRequestContext() { + return requestContext; + } + + public void setRequestContext(HttpApiV2ProxyRequestContext requestContext) { + this.requestContext = requestContext; + } + + @JsonIgnore + public RequestSource getRequestSource() { + return Optional.ofNullable(getRequestContext()) + .map(HttpApiV2ProxyRequestContext::getElb) + .map(albContext -> RequestSource.ALB) + .orElse(RequestSource.API_GATEWAY); + } +} diff --git a/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/model/HttpApiV2ProxyRequestContext.java b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/model/HttpApiV2ProxyRequestContext.java new file mode 100644 index 00000000..e5a5b9d2 --- /dev/null +++ b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/model/HttpApiV2ProxyRequestContext.java @@ -0,0 +1,130 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance + * with the License. A copy of the License is located at + * + * http://aws.amazon.com/apache2.0/ + * + * or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES + * OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions + * and limitations under the License. + */ +package com.amazonaws.serverless.proxy.model; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +@JsonIgnoreProperties(ignoreUnknown = true) +public class HttpApiV2ProxyRequestContext { + private String accountId; + private String apiId; + private String domainName; + private String domainPrefix; + private String requestId; + private String routeKey; + private String stage; + private String time; + private long timeEpoch; + private AlbContext elb; + + private HttpApiV2HttpContext http; + private HttpApiV2AuthorizerMap authorizer; + + public String getAccountId() { + return accountId; + } + + public void setAccountId(String accountId) { + this.accountId = accountId; + } + + public String getApiId() { + return apiId; + } + + public void setApiId(String apiId) { + this.apiId = apiId; + } + + public String getDomainName() { + return domainName; + } + + public void setDomainName(String domainName) { + this.domainName = domainName; + } + + public String getDomainPrefix() { + return domainPrefix; + } + + public void setDomainPrefix(String domainPrefix) { + this.domainPrefix = domainPrefix; + } + + public String getRequestId() { + return requestId; + } + + public void setRequestId(String requestId) { + this.requestId = requestId; + } + + public String getRouteKey() { + return routeKey; + } + + public void setRouteKey(String routeKey) { + this.routeKey = routeKey; + } + + public String getStage() { + return stage; + } + + public void setStage(String stage) { + this.stage = stage; + } + + public String getTime() { + return time; + } + + public void setTime(String time) { + this.time = time; + } + + public long getTimeEpoch() { + return timeEpoch; + } + + public void setTimeEpoch(long timeEpoch) { + this.timeEpoch = timeEpoch; + } + + public HttpApiV2HttpContext getHttp() { + return http; + } + + public void setHttp(HttpApiV2HttpContext http) { + this.http = http; + } + + public HttpApiV2AuthorizerMap getAuthorizer() { + return authorizer; + } + + public void setAuthorizer(HttpApiV2AuthorizerMap authorizer) { + this.authorizer = authorizer; + } + + public AlbContext getElb() { + return this.elb; + } + + public void setElb(AlbContext context) { + this.elb = context; + } + + +} diff --git a/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/model/MultiValuedTreeMap.java b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/model/MultiValuedTreeMap.java new file mode 100644 index 00000000..f6439e82 --- /dev/null +++ b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/model/MultiValuedTreeMap.java @@ -0,0 +1,234 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance + * with the License. A copy of the License is located at + * + * http://aws.amazon.com/apache2.0/ + * + * or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES + * OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions + * and limitations under the License. + */ +package com.amazonaws.serverless.proxy.model; + +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; + +import jakarta.ws.rs.core.MultivaluedMap; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; + +/** + * Simple implementation of a multi valued tree map to use for case-insensitive headers + * + * @param The type for the map key + * @param The type for the map values + */ +public class MultiValuedTreeMap implements MultivaluedMap, Serializable, Cloneable { + + private static final long serialVersionUID = 42L; + + private final Map> map; + + + public MultiValuedTreeMap() { + map = new TreeMap<>(); + } + + public MultiValuedTreeMap(Comparator comparator) { + map = new TreeMap<>(comparator); + } + + @Override + public void add(Key key, Value value) { + List values = findKey(key); + values.add(value); + } + + @Override + public Value getFirst(Key key) { + List values = get(key); + if (values == null || values.size() == 0) { + return null; + } + return values.get(0); + } + + public Value getLast(Key key) { + List values = get(key); + if (values == null || values.isEmpty()) { + return null; + } + return values.get(values.size() - 1); + } + + @Override + public void putSingle(Key key, Value value) { + List values = findKey(key); + values.clear(); + values.add(value); + } + + @Override + public void clear() { + map.clear(); + } + + @Override + public boolean containsKey(Object key) { + return map.containsKey(key); + } + + @Override + public boolean containsValue(Object value) { + return map.containsValue(value); + } + + @Override + public Set>> entrySet() { + return map.entrySet(); + } + + public boolean equals(Object o) { + return map.equals(o); + } + + @Override + public List get(Object key) { + return map.get(key); + } + + public int hashCode() { + return map.hashCode(); + } + + @Override + public boolean isEmpty() { + return map.isEmpty(); + } + + @Override + public Set keySet() { + return map.keySet(); + } + + @Override + public List put(Key key, List value) { + return map.put(key, value); + } + + @Override + public void putAll(Map> t) { + map.putAll(t); + } + + @Override + public List remove(Object key) { + return map.remove(key); + } + + @Override + public int size() { + return map.size(); + } + + @Override + public Collection> values() { + return map.values(); + } + + @Override + public void addAll(Key key, Value... newValues) { + for (Value value : newValues) { + add(key, value); + } + } + + @Override + public void addAll(Key key, List valueList) { + for (Value value : valueList) { + add(key, value); + } + } + + @Override + public void addFirst(Key key, Value value) { + List values = get(key); + if (values == null) { + add(key, value); + return; + } else { + values.add(0, value); + } + } + + @Override + public boolean equalsIgnoreValueOrder(MultivaluedMap vmap) { + if (this == vmap) { + return true; + } + if (!keySet().equals(vmap.keySet())) { + return false; + } + for (Map.Entry> e : entrySet()) { + List olist = vmap.get(e.getKey()); + if (e.getValue().size() != olist.size()) { + return false; + } + for (Value v : e.getValue()) { + if (!olist.contains(v)) { + return false; + } + } + } + return true; + } + + private List findKey(Key key) { + List values = this.get(key); + if (values == null) { + values = new ArrayList<>(); + put(key, values); + } + return values; + } + + @Override + @SuppressFBWarnings("CN_IDIOM_NO_SUPER_CALL") + public MultiValuedTreeMap clone() { + MultiValuedTreeMap clone = new MultiValuedTreeMap<>(); + for (Key key : keySet()) { + List value = get(key); + List newValue = new ArrayList<>(value); + clone.put(key, newValue); + } + return clone; + } + + public String toString() { + StringBuilder result = new StringBuilder(); + String delim = ","; + for (Object name : keySet()) { + for (Object value : get(name)) { + result.append(delim); + if (name == null) { + result.append("null"); //$NON-NLS-1$ + } else { + result.append(name.toString()); + } + if (value != null) { + result.append('='); + result.append(value.toString()); + } + } + } + return "[" + result.toString() + "]"; + } +} diff --git a/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/model/RequestSource.java b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/model/RequestSource.java new file mode 100644 index 00000000..c819fdcc --- /dev/null +++ b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/model/RequestSource.java @@ -0,0 +1,18 @@ +/* + * Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance + * with the License. A copy of the License is located at + * + * http://aws.amazon.com/apache2.0/ + * + * or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES + * OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions + * and limitations under the License. + */ +package com.amazonaws.serverless.proxy.model; + +public enum RequestSource { + ALB, + API_GATEWAY +} \ No newline at end of file diff --git a/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/model/SingleValueHeaders.java b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/model/SingleValueHeaders.java new file mode 100644 index 00000000..6dbf0393 --- /dev/null +++ b/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/model/SingleValueHeaders.java @@ -0,0 +1,24 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance + * with the License. A copy of the License is located at + * + * http://aws.amazon.com/apache2.0/ + * + * or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES + * OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions + * and limitations under the License. + */ +package com.amazonaws.serverless.proxy.model; + +import java.util.TreeMap; + +public class SingleValueHeaders extends TreeMap { + + private static final long serialVersionUID = 42L; + + public SingleValueHeaders() { + super(String.CASE_INSENSITIVE_ORDER); + } +} diff --git a/aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/AsyncInitializationWrapperTest.java b/aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/AsyncInitializationWrapperTest.java new file mode 100644 index 00000000..74a6b803 --- /dev/null +++ b/aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/AsyncInitializationWrapperTest.java @@ -0,0 +1,28 @@ +package com.amazonaws.serverless.proxy; + +import org.junit.jupiter.api.Test; + +import java.lang.management.ManagementFactory; +import java.time.Clock; +import java.time.Instant; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class AsyncInitializationWrapperTest { + + @Test + void initCreate_noStartTime_setsCurrentTime() { + AsyncInitializationWrapper init = new AsyncInitializationWrapper(); + long initTime = ManagementFactory.getRuntimeMXBean().getStartTime(); + assertEquals(initTime, init.getActualStartTimeMs()); + } + + @Test + void initCreate_withStartTime_storesCustomStartTime() throws InterruptedException { + long initTime = Instant.now().toEpochMilli(); + Thread.sleep(500); + AsyncInitializationWrapper init = new AsyncInitializationWrapper(initTime); + + assertEquals(initTime, init.getActualStartTimeMs()); + } +} diff --git a/aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/internal/AwsProxyExceptionHandlerTest.java b/aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/AwsProxyExceptionHandlerTest.java similarity index 57% rename from aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/internal/AwsProxyExceptionHandlerTest.java rename to aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/AwsProxyExceptionHandlerTest.java index ff503ff9..e15b9876 100644 --- a/aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/internal/AwsProxyExceptionHandlerTest.java +++ b/aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/AwsProxyExceptionHandlerTest.java @@ -1,40 +1,44 @@ -package com.amazonaws.serverless.proxy.internal; +package com.amazonaws.serverless.proxy; import com.amazonaws.serverless.exceptions.InvalidRequestEventException; import com.amazonaws.serverless.exceptions.InvalidResponseObjectException; -import com.amazonaws.serverless.proxy.internal.model.AwsProxyResponse; -import com.amazonaws.serverless.proxy.internal.model.ErrorModel; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; +import com.amazonaws.serverless.proxy.model.AwsProxyResponse; +import com.amazonaws.serverless.proxy.model.ErrorModel; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.ObjectMapper; -import static org.junit.Assert.*; -import static org.mockito.Matchers.any; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; -import org.junit.Before; -import org.junit.Test; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; -import javax.ws.rs.core.HttpHeaders; -import javax.ws.rs.core.MediaType; +import jakarta.ws.rs.InternalServerErrorException; +import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.core.MediaType; import java.io.*; public class AwsProxyExceptionHandlerTest { + private static final String INTERNAL_SERVER_ERROR_MESSAGE = "Internal server error"; private static final String INVALID_REQUEST_MESSAGE = "Invalid request error"; private static final String INVALID_RESPONSE_MESSAGE = "Invalid response error"; private AwsProxyExceptionHandler exceptionHandler; private ObjectMapper objectMapper; - @Before + + @BeforeEach public void setUp() { exceptionHandler = new AwsProxyExceptionHandler(); objectMapper = new ObjectMapper(); } @Test - public void typedHandle_InvalidRequestEventException_500State() { + void typedHandle_InvalidRequestEventException_500State() { AwsProxyResponse resp = exceptionHandler.handle(new InvalidRequestEventException(INVALID_REQUEST_MESSAGE, null)); assertNotNull(resp); @@ -42,8 +46,8 @@ public void typedHandle_InvalidRequestEventException_500State() { } @Test - public void typedHandle_InvalidRequestEventException_responseString() - throws JsonProcessingException { + void typedHandle_InvalidRequestEventException_responseString() + throws JacksonException { AwsProxyResponse resp = exceptionHandler.handle(new InvalidRequestEventException(INVALID_REQUEST_MESSAGE, null)); assertNotNull(resp); @@ -52,16 +56,16 @@ public void typedHandle_InvalidRequestEventException_responseString() } @Test - public void typedHandle_InvalidRequestEventException_jsonContentTypeHeader() { + void typedHandle_InvalidRequestEventException_jsonContentTypeHeader() { AwsProxyResponse resp = exceptionHandler.handle(new InvalidRequestEventException(INVALID_REQUEST_MESSAGE, null)); assertNotNull(resp); - assertTrue(resp.getHeaders().containsKey(HttpHeaders.CONTENT_TYPE)); - assertEquals(MediaType.APPLICATION_JSON, resp.getHeaders().get(HttpHeaders.CONTENT_TYPE)); + assertTrue(resp.getMultiValueHeaders().containsKey(HttpHeaders.CONTENT_TYPE)); + assertEquals(MediaType.APPLICATION_JSON, resp.getMultiValueHeaders().getFirst(HttpHeaders.CONTENT_TYPE)); } @Test - public void typedHandle_InvalidResponseObjectException_502State() { + void typedHandle_InvalidResponseObjectException_502State() { AwsProxyResponse resp = exceptionHandler.handle(new InvalidResponseObjectException(INVALID_RESPONSE_MESSAGE, null)); assertNotNull(resp); @@ -69,8 +73,8 @@ public void typedHandle_InvalidResponseObjectException_502State() { } @Test - public void typedHandle_InvalidResponseObjectException_responseString() - throws JsonProcessingException { + void typedHandle_InvalidResponseObjectException_responseString() + throws JacksonException { AwsProxyResponse resp = exceptionHandler.handle(new InvalidResponseObjectException(INVALID_RESPONSE_MESSAGE, null)); assertNotNull(resp); @@ -79,29 +83,67 @@ public void typedHandle_InvalidResponseObjectException_responseString() } @Test - public void typedHandle_InvalidResponseObjectException_jsonContentTypeHeader() { + void typedHandle_InvalidResponseObjectException_jsonContentTypeHeader() { AwsProxyResponse resp = exceptionHandler.handle(new InvalidResponseObjectException(INVALID_RESPONSE_MESSAGE, null)); assertNotNull(resp); - assertTrue(resp.getHeaders().containsKey(HttpHeaders.CONTENT_TYPE)); - assertEquals(MediaType.APPLICATION_JSON, resp.getHeaders().get(HttpHeaders.CONTENT_TYPE)); + assertTrue(resp.getMultiValueHeaders().containsKey(HttpHeaders.CONTENT_TYPE)); + assertEquals(MediaType.APPLICATION_JSON, resp.getMultiValueHeaders().getFirst(HttpHeaders.CONTENT_TYPE)); + } + + @Test + void typedHandle_InternalServerErrorException_500State() { + // Needed to mock InternalServerErrorException because it leverages RuntimeDelegate to set an internal + // response object. + InternalServerErrorException mockInternalServerErrorException = Mockito.mock(InternalServerErrorException.class); + Mockito.when(mockInternalServerErrorException.getMessage()).thenReturn(INTERNAL_SERVER_ERROR_MESSAGE); + + AwsProxyResponse resp = exceptionHandler.handle(mockInternalServerErrorException); + + assertNotNull(resp); + assertEquals(500, resp.getStatusCode()); + } + + @Test + void typedHandle_InternalServerErrorException_responseString() + throws JacksonException { + InternalServerErrorException mockInternalServerErrorException = Mockito.mock(InternalServerErrorException.class); + Mockito.when(mockInternalServerErrorException.getMessage()).thenReturn(INTERNAL_SERVER_ERROR_MESSAGE); + + AwsProxyResponse resp = exceptionHandler.handle(mockInternalServerErrorException); + + assertNotNull(resp); + String body = objectMapper.writeValueAsString(new ErrorModel(AwsProxyExceptionHandler.INTERNAL_SERVER_ERROR)); + assertEquals(body, resp.getBody()); + } + + @Test + void typedHandle_InternalServerErrorException_jsonContentTypeHeader() { + InternalServerErrorException mockInternalServerErrorException = Mockito.mock(InternalServerErrorException.class); + Mockito.when(mockInternalServerErrorException.getMessage()).thenReturn(INTERNAL_SERVER_ERROR_MESSAGE); + + AwsProxyResponse resp = exceptionHandler.handle(mockInternalServerErrorException); + + assertNotNull(resp); + assertTrue(resp.getMultiValueHeaders().containsKey(HttpHeaders.CONTENT_TYPE)); + assertEquals(MediaType.APPLICATION_JSON, resp.getMultiValueHeaders().getFirst(HttpHeaders.CONTENT_TYPE)); } @Test - public void typedHandle_NullPointerException_responseObject() - throws JsonProcessingException { + void typedHandle_NullPointerException_responseObject() + throws JacksonException { AwsProxyResponse resp = exceptionHandler.handle(new NullPointerException()); assertNotNull(resp); assertEquals(502, resp.getStatusCode()); - assertTrue(resp.getHeaders().containsKey(HttpHeaders.CONTENT_TYPE)); - assertEquals(MediaType.APPLICATION_JSON, resp.getHeaders().get(HttpHeaders.CONTENT_TYPE)); + assertTrue(resp.getMultiValueHeaders().containsKey(HttpHeaders.CONTENT_TYPE)); + assertEquals(MediaType.APPLICATION_JSON, resp.getMultiValueHeaders().getFirst(HttpHeaders.CONTENT_TYPE)); String body = objectMapper.writeValueAsString(new ErrorModel(AwsProxyExceptionHandler.GATEWAY_TIMEOUT_ERROR)); assertEquals(body, resp.getBody()); } @Test - public void streamHandle_InvalidRequestEventException_500State() + void streamHandle_InvalidRequestEventException_500State() throws IOException { ByteArrayOutputStream respStream = new ByteArrayOutputStream(); exceptionHandler.handle(new InvalidRequestEventException(INVALID_REQUEST_MESSAGE, null), respStream); @@ -114,7 +156,7 @@ public void streamHandle_InvalidRequestEventException_500State() } @Test - public void streamHandle_InvalidRequestEventException_responseString() + void streamHandle_InvalidRequestEventException_responseString() throws IOException { ByteArrayOutputStream respStream = new ByteArrayOutputStream(); exceptionHandler.handle(new InvalidRequestEventException(INVALID_REQUEST_MESSAGE, null), respStream); @@ -128,7 +170,7 @@ public void streamHandle_InvalidRequestEventException_responseString() } @Test - public void streamHandle_InvalidRequestEventException_jsonContentTypeHeader() + void streamHandle_InvalidRequestEventException_jsonContentTypeHeader() throws IOException { ByteArrayOutputStream respStream = new ByteArrayOutputStream(); exceptionHandler.handle(new InvalidRequestEventException(INVALID_REQUEST_MESSAGE, null), respStream); @@ -137,12 +179,12 @@ public void streamHandle_InvalidRequestEventException_jsonContentTypeHeader() assertTrue(respStream.size() > 0); AwsProxyResponse resp = objectMapper.readValue(new ByteArrayInputStream(respStream.toByteArray()), AwsProxyResponse.class); assertNotNull(resp); - assertTrue(resp.getHeaders().containsKey(HttpHeaders.CONTENT_TYPE)); - assertEquals(MediaType.APPLICATION_JSON, resp.getHeaders().get(HttpHeaders.CONTENT_TYPE)); + assertTrue(resp.getMultiValueHeaders().containsKey(HttpHeaders.CONTENT_TYPE)); + assertEquals(MediaType.APPLICATION_JSON, resp.getMultiValueHeaders().getFirst(HttpHeaders.CONTENT_TYPE)); } @Test - public void streamHandle_InvalidResponseObjectException_502State() + void streamHandle_InvalidResponseObjectException_502State() throws IOException { ByteArrayOutputStream respStream = new ByteArrayOutputStream(); exceptionHandler.handle(new InvalidResponseObjectException(INVALID_RESPONSE_MESSAGE, null), respStream); @@ -155,7 +197,7 @@ public void streamHandle_InvalidResponseObjectException_502State() } @Test - public void streamHandle_InvalidResponseObjectException_responseString() + void streamHandle_InvalidResponseObjectException_responseString() throws IOException { ByteArrayOutputStream respStream = new ByteArrayOutputStream(); exceptionHandler.handle(new InvalidResponseObjectException(INVALID_RESPONSE_MESSAGE, null), respStream); @@ -169,7 +211,7 @@ public void streamHandle_InvalidResponseObjectException_responseString() } @Test - public void streamHandle_InvalidResponseObjectException_jsonContentTypeHeader() + void streamHandle_InvalidResponseObjectException_jsonContentTypeHeader() throws IOException { ByteArrayOutputStream respStream = new ByteArrayOutputStream(); exceptionHandler.handle(new InvalidResponseObjectException(INVALID_RESPONSE_MESSAGE, null), respStream); @@ -178,22 +220,22 @@ public void streamHandle_InvalidResponseObjectException_jsonContentTypeHeader() assertTrue(respStream.size() > 0); AwsProxyResponse resp = objectMapper.readValue(new ByteArrayInputStream(respStream.toByteArray()), AwsProxyResponse.class); assertNotNull(resp); - assertTrue(resp.getHeaders().containsKey(HttpHeaders.CONTENT_TYPE)); - assertEquals(MediaType.APPLICATION_JSON, resp.getHeaders().get(HttpHeaders.CONTENT_TYPE)); + assertTrue(resp.getMultiValueHeaders().containsKey(HttpHeaders.CONTENT_TYPE)); + assertEquals(MediaType.APPLICATION_JSON, resp.getMultiValueHeaders().getFirst(HttpHeaders.CONTENT_TYPE)); } @Test - public void errorMessage_InternalServerError_staticString() { + void errorMessage_InternalServerError_staticString() { assertEquals("Internal Server Error", AwsProxyExceptionHandler.INTERNAL_SERVER_ERROR); } @Test - public void errorMessage_GatewayTimeout_staticString() { - assertEquals("Gateway timeout", AwsProxyExceptionHandler.GATEWAY_TIMEOUT_ERROR); + void errorMessage_GatewayTimeout_staticString() { + assertEquals("Gateway Timeout", AwsProxyExceptionHandler.GATEWAY_TIMEOUT_ERROR); } @Test - public void getErrorJson_ErrorModel_validJson() + void getErrorJson_ErrorModel_validJson() throws IOException { String output = exceptionHandler.getErrorJson(INVALID_RESPONSE_MESSAGE); assertNotNull(output); @@ -203,10 +245,10 @@ public void getErrorJson_ErrorModel_validJson() } @Test - public void getErrorJson_JsonParsinException_validJson() + void getErrorJson_JsonParsinException_validJson() throws IOException { ObjectMapper mockMapper = mock(ObjectMapper.class); - JsonProcessingException exception = mock(JsonProcessingException.class); + JacksonException exception = mock(JacksonException.class); when(mockMapper.writeValueAsString(any(Object.class))).thenThrow(exception); String output = exceptionHandler.getErrorJson(INVALID_RESPONSE_MESSAGE); diff --git a/aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/internal/AwsProxySecurityContextWriterTest.java b/aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/AwsProxySecurityContextWriterTest.java similarity index 70% rename from aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/internal/AwsProxySecurityContextWriterTest.java rename to aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/AwsProxySecurityContextWriterTest.java index 4f1c6860..8a2134f4 100644 --- a/aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/internal/AwsProxySecurityContextWriterTest.java +++ b/aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/AwsProxySecurityContextWriterTest.java @@ -1,34 +1,34 @@ -package com.amazonaws.serverless.proxy.internal; +package com.amazonaws.serverless.proxy; -import com.amazonaws.serverless.proxy.internal.model.AwsProxyRequest; +import com.amazonaws.serverless.proxy.model.AwsProxyRequest; import com.amazonaws.serverless.proxy.internal.testutils.AwsProxyRequestBuilder; import com.amazonaws.services.lambda.runtime.Context; -import org.junit.Before; -import org.junit.Test; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; -import javax.ws.rs.core.SecurityContext; +import jakarta.ws.rs.core.SecurityContext; import java.lang.reflect.Method; -import static org.junit.Assert.*; +import static org.junit.jupiter.api.Assertions.*; public class AwsProxySecurityContextWriterTest { private AwsProxySecurityContextWriter writer; - @Before + @BeforeEach public void setUp() { writer = new AwsProxySecurityContextWriter(); } @Test - public void write_returnClass_securityContext() + void write_returnClass_securityContext() throws NoSuchMethodException { Method writeMethod = writer.getClass().getMethod("writeSecurityContext", AwsProxyRequest.class, Context.class); assertEquals(SecurityContext.class, writeMethod.getReturnType()); } @Test - public void write_noAuth_emptySecurityContext() { + void write_noAuth_emptySecurityContext() { AwsProxyRequest request = new AwsProxyRequestBuilder("/test").build(); SecurityContext context = writer.writeSecurityContext(request, null); diff --git a/aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/RequestReaderTest.java b/aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/RequestReaderTest.java new file mode 100644 index 00000000..3afe72f9 --- /dev/null +++ b/aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/RequestReaderTest.java @@ -0,0 +1,77 @@ +package com.amazonaws.serverless.proxy; + + +import com.amazonaws.serverless.proxy.model.ContainerConfig; +import com.amazonaws.serverless.proxy.internal.servlet.AwsProxyHttpServletRequestReader; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + + +public class RequestReaderTest { + + private static final String ORDERS_URL = "/orders"; + private static final String BASE_PATH_MAPPING = "svc1"; + + private static final AwsProxyHttpServletRequestReader requestReader = new AwsProxyHttpServletRequestReader(); + + @Test + void defaultConfig_doNotStripBasePath() { + ContainerConfig config = ContainerConfig.defaultConfig(); + assertFalse(config.isStripBasePath()); + assertNull(config.getServiceBasePath()); + } + + @Test + void setServiceBasePath_addSlashes() { + ContainerConfig config = new ContainerConfig(); + + config.setServiceBasePath(BASE_PATH_MAPPING); + assertEquals("/" + BASE_PATH_MAPPING, config.getServiceBasePath()); + + config.setServiceBasePath(BASE_PATH_MAPPING + "/"); + assertEquals("/" + BASE_PATH_MAPPING, config.getServiceBasePath()); + } + + @Test + void requestReader_stripBasePath() { + ContainerConfig config = ContainerConfig.defaultConfig(); + String requestPath = "/" + BASE_PATH_MAPPING + ORDERS_URL; + + String finalPath = requestReader.stripBasePath(requestPath, config); + assertNotNull(finalPath); + assertEquals(requestPath, finalPath); + + config.setStripBasePath(true); + config.setServiceBasePath(BASE_PATH_MAPPING); + finalPath = requestReader.stripBasePath(requestPath, config); + assertNotNull(finalPath); + assertEquals(ORDERS_URL, finalPath); + + finalPath = requestReader.stripBasePath(ORDERS_URL, config); + assertNotNull(finalPath); + assertEquals(ORDERS_URL, finalPath); + } + + @Test + void requestReader_doubleBasePath() { + ContainerConfig config = ContainerConfig.defaultConfig(); + config.setStripBasePath(true); + config.setServiceBasePath(BASE_PATH_MAPPING); + + String finalPath = requestReader.stripBasePath("/" + BASE_PATH_MAPPING + "/" + BASE_PATH_MAPPING, config); + assertNotNull(finalPath); + assertEquals("/" + BASE_PATH_MAPPING, finalPath); + + finalPath = requestReader.stripBasePath("/custom/" + BASE_PATH_MAPPING, config); + assertNotNull(finalPath); + assertEquals("/custom/" + BASE_PATH_MAPPING, finalPath); + + finalPath = requestReader.stripBasePath(BASE_PATH_MAPPING, config); + assertNotNull(finalPath); + // the request path does not start with a "/", the comparison in the method should fail + // and nothing should get replaced + assertEquals(BASE_PATH_MAPPING, finalPath); + } +} diff --git a/aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/ResponseWriterTest.java b/aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/ResponseWriterTest.java new file mode 100644 index 00000000..609fb0fd --- /dev/null +++ b/aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/ResponseWriterTest.java @@ -0,0 +1,75 @@ +package com.amazonaws.serverless.proxy; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.amazonaws.serverless.exceptions.InvalidResponseObjectException; +import com.amazonaws.serverless.proxy.internal.servlet.AwsProxyHttpServletRequest; +import com.amazonaws.services.lambda.runtime.Context; +import org.junit.jupiter.api.Test; + +import jakarta.servlet.http.HttpServletRequest; + + +public class ResponseWriterTest { + private static int[][] NAUGHTY_STRINGS = { + new int[] { 0b11111110 }, new int[] { 0xff }, new int[] {0xfe, 0xfe, 0xff, 0xff } + }; + + private static String[] VALID_STRINGS = { + "ᚠᛇᚻ᛫ᛒᛦᚦ᛫ᚠᚱᚩᚠᚢᚱ᛫ᚠᛁᚱᚪ᛫ᚷᛖᚻᚹᛦᛚᚳᚢᛗ\nᛋᚳᛖᚪᛚ᛫ᚦᛖᚪᚻ᛫ᛗᚪᚾᚾᚪ᛫ᚷᛖᚻᚹᛦᛚᚳ᛫ᛗᛁᚳᛚᚢᚾ᛫ᚻᛦᛏ᛫ᛞᚫᛚᚪᚾ\nᚷᛁᚠ᛫ᚻᛖ᛫ᚹᛁᛚᛖ᛫ᚠᚩᚱ᛫ᛞᚱᛁᚻᛏᚾᛖ᛫ᛞᚩᛗᛖᛋ᛫ᚻᛚᛇᛏᚪᚾ᛬", + "Τη γλώσσα μου έδωσαν ελληνική\nτο σπίτι φτωχικό στις αμμουδιές του Ομήρου.\nΜονάχη έγνοια η γλώσσα μου στις αμμουδιές του Ομήρου.", + "ვეპხის ტყაოსანი შოთა რუსთაველი\nღმერთსი შემვედრე, ნუთუ კვლა დამხსნას სოფლისა შრომასა, ცეცხლს, წყალსა და მიწასა, ჰაერთა თანა მრომასა; მომცნეს ფრთენი და აღვფრინდე, მივჰხვდე მას ჩემსა ნდომასა, დღისით და ღამით ვჰხედვიდე მზისა ელვათა კრთომაასა.", + "ಬಾ ಇಲ್ಲಿ ಸಂಭವಿಸು ಇಂದೆನ್ನ ಹೃದಯದಲಿ \nನಿತ್ಯವೂ ಅವತರಿಪ ಸತ್ಯಾವತಾರ\nಮಣ್ಣಾಗಿ ಮರವಾಗಿ ಮಿಗವಾಗಿ ಕಗವಾಗೀ... \nಮಣ್ಣಾಗಿ ಮರವಾಗಿ ಮಿಗವಾಗಿ ಕಗವಾಗಿ \nಭವ ಭವದಿ ಭತಿಸಿಹೇ ಭವತಿ ದೂರ \nನಿತ್ಯವೂ ಅವತರಿಪ ಸತ್ಯಾವತಾರ || ಬಾ ಇಲ್ಲಿ ||" + }; + + @Test + public void isValidUtf8_testNaughtyStrings_allShouldFail() { + MockResponseWriter rw = new MockResponseWriter(); + for (int[] s : NAUGHTY_STRINGS) { + byte[] buf = new byte[s.length * 4]; + int pos = 0; + for (int v : s) { + for (byte b : convert2Bytes(v)) { + buf[pos] = b; + pos++; + } + } + assertFalse(rw.isValidUtf8(buf)); + } + } + + @Test + public void isValidUtf8_testUtf8Strings_allShouldSucceed() { + MockResponseWriter rw = new MockResponseWriter(); + for (String s : VALID_STRINGS) { + assertTrue(rw.isValidUtf8(s.getBytes())); + } + } + + //little endian + public static byte[] convert2Bytes(int src) { + //an int is equivalent to 32 bits, 4 bytes + byte tgt[] = new byte[4]; + int mask = 0377; /* 0377 in octal*/ + + tgt[3] = (byte)(src >>> 24); + tgt[2] = (byte)((src >> 16) & 0xff); + tgt[1] = (byte)((src >> 8) & 0xff); + tgt[0] = (byte)(src & 0xff); + + return tgt; + } + + public class MockResponseWriter extends ResponseWriter { + + @Override + public HttpServletRequest writeResponse(AwsProxyHttpServletRequest containerResponse, Context lambdaContext) throws InvalidResponseObjectException { + return null; + } + + public boolean testValidUtf8(final byte[] input) { + return isValidUtf8(input); + } + } +} diff --git a/aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/internal/LambdaContainerHandlerTest.java b/aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/internal/LambdaContainerHandlerTest.java new file mode 100644 index 00000000..d6af5112 --- /dev/null +++ b/aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/internal/LambdaContainerHandlerTest.java @@ -0,0 +1,105 @@ +package com.amazonaws.serverless.proxy.internal; + +import com.amazonaws.serverless.exceptions.ContainerInitializationException; +import com.amazonaws.serverless.proxy.*; +import com.amazonaws.serverless.proxy.internal.servlet.AwsHttpServletResponse; +import com.amazonaws.serverless.proxy.internal.servlet.AwsProxyHttpServletRequestReader; +import com.amazonaws.serverless.proxy.internal.servlet.AwsProxyHttpServletResponseWriter; +import com.amazonaws.serverless.proxy.internal.testutils.AwsProxyRequestBuilder; +import com.amazonaws.serverless.proxy.internal.testutils.MockLambdaContext; +import com.amazonaws.serverless.proxy.model.AwsProxyRequest; +import com.amazonaws.serverless.proxy.model.AwsProxyResponse; +import com.amazonaws.services.lambda.runtime.Context; +import org.apache.hc.client5.http.impl.classic.RequestAbortedException; +import org.junit.jupiter.api.Test; + +import jakarta.servlet.http.HttpServletRequest; +import java.util.concurrent.CountDownLatch; + +import static org.junit.jupiter.api.Assertions.*; + +public class LambdaContainerHandlerTest { + private boolean isRuntimeException = false; + private boolean throwException = false; + + ExceptionContainerHandlerTest handler = new ExceptionContainerHandlerTest( + AwsProxyRequest.class, AwsProxyResponse.class, + new AwsProxyHttpServletRequestReader(), new AwsProxyHttpServletResponseWriter(), + new AwsProxySecurityContextWriter(), new AwsProxyExceptionHandler(), new InitializationWrapper() + ); + + @Test + void throwRuntime_returnsUnwrappedException() { + try { + isRuntimeException = true; + throwException = true; + LambdaContainerHandler.getContainerConfig().setDisableExceptionMapper(true); + handler.proxy(new AwsProxyRequestBuilder("/test", "GET").build(), new MockLambdaContext()); + } catch (Exception e) { + assertNotNull(e); + assertEquals(ExceptionContainerHandlerTest.RUNTIME_MESSAGE, e.getMessage()); + return; + } + fail("Did not throw runtime exception"); + } + + @Test + void throwNonRuntime_returnsWrappedException() { + try { + isRuntimeException = false; + throwException = true; + LambdaContainerHandler.getContainerConfig().setDisableExceptionMapper(true); + handler.proxy(new AwsProxyRequestBuilder("/test", "GET").build(), new MockLambdaContext()); + } catch (Exception e) { + assertNotNull(e); + assertNotNull(e.getCause()); + assertTrue(e.getCause() instanceof RequestAbortedException); + assertEquals(ExceptionContainerHandlerTest.NON_RUNTIME_MESSAGE, e.getCause().getMessage()); + return; + } + fail("Did not throw exception"); + } + + @Test + void noException_returnsResponse() { + throwException = false; + LambdaContainerHandler.getContainerConfig().setDisableExceptionMapper(false); + AwsProxyResponse resp = handler.proxy(new AwsProxyRequestBuilder("/test", "GET").build(), new MockLambdaContext()); + assertEquals(200, resp.getStatusCode()); + assertEquals("OK", resp.getBody()); + } + + public class ExceptionContainerHandlerTest extends LambdaContainerHandler { + + public static final String RUNTIME_MESSAGE = "test RuntimeException"; + public static final String NON_RUNTIME_MESSAGE = "test NonRuntimeException"; + + protected ExceptionContainerHandlerTest(Class requestClass, Class responseClass, RequestReader requestReader, ResponseWriter responseWriter, SecurityContextWriter securityContextWriter, ExceptionHandler exceptionHandler, InitializationWrapper init) { + super(requestClass, responseClass, requestReader, responseWriter, securityContextWriter, exceptionHandler, init); + } + + @Override + protected AwsHttpServletResponse getContainerResponse(HttpServletRequest request, CountDownLatch latch) { + return new AwsHttpServletResponse(request, latch); + } + + @Override + protected void handleRequest(HttpServletRequest containerRequest, AwsHttpServletResponse containerResponse, Context lambdaContext) throws Exception { + if (throwException) { + if (isRuntimeException) { + throw new RuntimeException(RUNTIME_MESSAGE); + } else { + throw new RequestAbortedException(NON_RUNTIME_MESSAGE); + } + } + containerResponse.setStatus(200); + containerResponse.getWriter().print("OK"); + containerResponse.flushBuffer(); + } + + @Override + public void initialize() throws ContainerInitializationException { + + } + } +} diff --git a/aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/internal/SecurityUtilsTest.java b/aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/internal/SecurityUtilsTest.java new file mode 100644 index 00000000..acf48d9c --- /dev/null +++ b/aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/internal/SecurityUtilsTest.java @@ -0,0 +1,79 @@ +package com.amazonaws.serverless.proxy.internal; + +import java.util.HashMap; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +public class SecurityUtilsTest { + + private static final HashMap NAUGHTY_UNICODE_STRINGS = new HashMap<>(); + static { + NAUGHTY_UNICODE_STRINGS.put("Ω≈ç√∫˜µ≤≥÷", "\\u03A9\\u2248\\u00E7\\u221A\\u222B\\u02DC\\u00B5\\u2264\\u2265\\u00F7"); + NAUGHTY_UNICODE_STRINGS.put("åß∂ƒ©˙∆˚¬…æ", "\\u00E5\\u00DF\\u2202\\u0192\\u00A9\\u02D9\\u2206\\u02DA\\u00AC\\u2026\\u00E6"); + NAUGHTY_UNICODE_STRINGS.put("œ∑´®†¥¨ˆøπ“‘", "\\u0153\\u2211\\u00B4\\u00AE\\u2020\\u00A5\\u00A8\\u02C6\\u00F8\\u03C0\\u201C\\u2018"); + NAUGHTY_UNICODE_STRINGS.put("¡™£¢∞§¶•ªº–≠", "\\u00A1\\u2122\\u00A3\\u00A2\\u221E\\u00A7\\u00B6\\u2022\\u00AA\\u00BA\\u2013\\u2260"); + NAUGHTY_UNICODE_STRINGS.put("¸˛Ç◊ı˜Â¯˘¿", "\\u00B8\\u02DB\\u00C7\\u25CA\\u0131\\u02DC\\u00C2\\u00AF\\u02D8\\u00BF"); + NAUGHTY_UNICODE_STRINGS.put("ÅÍÎÏ˝ÓÔÒÚÆ☃", "\\u00C5\\u00CD\\u00CE\\u00CF\\u02DD\\u00D3\\u00D4\\uF8FF\\u00D2\\u00DA\\u00C6\\u2603"); + NAUGHTY_UNICODE_STRINGS.put("Œ„´‰ˇÁ¨ˆØ∏”’", "\\u0152\\u201E\\u00B4\\u2030\\u02C7\\u00C1\\u00A8\\u02C6\\u00D8\\u220F\\u201D\\u2019"); + NAUGHTY_UNICODE_STRINGS.put("⅛⅜⅝⅞", "\\u215B\\u215C\\u215D\\u215E"); + NAUGHTY_UNICODE_STRINGS.put("ЁЂЃЄЅІЇЈЉЊЋЌЍЎЏАБВГДЕЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯабвгдежзийклмнопрстуфхцчшщъыьэюя", "\\u0401\\u0402\\u0403\\u0404\\u0405\\u0406\\u0407\\u0408\\u0409\\u040A\\u040B\\u040C\\u040D\\u040E\\u040F\\u0410\\u0411\\u0412\\u0413\\u0414\\u0415\\u0416\\u0417\\u0418\\u0419\\u041A\\u041B\\u041C\\u041D\\u041E\\u041F\\u0420\\u0421\\u0422\\u0423\\u0424\\u0425\\u0426\\u0427\\u0428\\u0429\\u042A\\u042B\\u042C\\u042D\\u042E\\u042F\\u0430\\u0431\\u0432\\u0433\\u0434\\u0435\\u0436\\u0437\\u0438\\u0439\\u043A\\u043B\\u043C\\u043D\\u043E\\u043F\\u0440\\u0441\\u0442\\u0443\\u0444\\u0445\\u0446\\u0447\\u0448\\u0449\\u044A\\u044B\\u044C\\u044D\\u044E\\u044F"); + NAUGHTY_UNICODE_STRINGS.put("\bhello\nhello\thello\fhello\r", "\\bhello\\nhello\\thello\\fhello\\r"); + NAUGHTY_UNICODE_STRINGS.put("\'", "\'"); + NAUGHTY_UNICODE_STRINGS.put("\"", "\\\""); + NAUGHTY_UNICODE_STRINGS.put("\\", "\\\\"); + NAUGHTY_UNICODE_STRINGS.put("ò", "\\u00F2"); + } + + + @Test + public void encode_nullString_returnsNullIfStringIsNull() { + assertNull(SecurityUtils.encode(null)); + } + + @Test + public void encode_naughtyStrings_encodedCorrectly() { + for (Map.Entry e : NAUGHTY_UNICODE_STRINGS.entrySet()) { + assertEquals(e.getValue(), SecurityUtils.encode(e.getKey())); + } + } + + @Test + public void getValidFilePath_nullOrEmpty_returnsNull() { + assertNull(SecurityUtils.getValidFilePath("")); + assertNull(SecurityUtils.getValidFilePath(null)); + } + + @Test + public void getValidFilePath_writeToTaskPath_throwsIllegalArgumentException() { + boolean thrown = false; + try { + SecurityUtils.getValidFilePath("/var/task/test.txt", true); + } catch (IllegalArgumentException e) { + thrown = true; + } + if (!thrown) { + fail("Did not throw exception"); + } + + try { + SecurityUtils.getValidFilePath("file:///var/task/test.txt", true); + } catch (IllegalArgumentException e) { + return; + } + + fail(); + } + + @Test + public void getValidFilePath_writeToBlockedPath_throwsIllegalArgumentException() { + try { + SecurityUtils.getValidFilePath("/usr/lib/test.txt"); + } catch (IllegalArgumentException e) { + return; + } + fail("Did not throw exception"); + } +} diff --git a/aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/internal/jaxrs/AwsProxySecurityContextTest.java b/aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/internal/jaxrs/AwsProxySecurityContextTest.java index c5d63158..f4641f36 100644 --- a/aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/internal/jaxrs/AwsProxySecurityContextTest.java +++ b/aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/internal/jaxrs/AwsProxySecurityContextTest.java @@ -1,42 +1,97 @@ package com.amazonaws.serverless.proxy.internal.jaxrs; -import com.amazonaws.serverless.proxy.internal.model.AwsProxyRequest; +import com.amazonaws.serverless.proxy.model.AwsProxyRequest; import com.amazonaws.serverless.proxy.internal.testutils.AwsProxyRequestBuilder; -import org.junit.Test; +import org.junit.jupiter.api.Test; -import static org.junit.Assert.*; +import java.security.Principal; + +import static com.amazonaws.serverless.proxy.internal.jaxrs.AwsProxySecurityContext.ALB_ACESS_TOKEN_HEADER; +import static com.amazonaws.serverless.proxy.internal.jaxrs.AwsProxySecurityContext.ALB_IDENTITY_HEADER; +import static com.amazonaws.serverless.proxy.internal.jaxrs.AwsProxySecurityContext.AUTH_SCHEME_COGNITO_POOL; +import static com.amazonaws.serverless.proxy.internal.jaxrs.AwsProxySecurityContext.AUTH_SCHEME_CUSTOM; +import static org.junit.jupiter.api.Assertions.*; public class AwsProxySecurityContextTest { + private static final String CLAIM_KEY = "custom:claim"; + private static final String CLAIM_VALUE = "customClaimant"; private static final String COGNITO_IDENTITY_ID = "us-east-2:123123123123"; private static final AwsProxyRequest REQUEST_NO_AUTH = new AwsProxyRequestBuilder("/hello", "GET").build(); + private static final AwsProxyRequest ALB_REQUEST_NO_AUTH = new AwsProxyRequestBuilder("/hello", "GET").alb().build(); private static final AwsProxyRequest REQUEST_COGNITO_USER_POOL = new AwsProxyRequestBuilder("/hello", "GET") - .cognitoUserPool(COGNITO_IDENTITY_ID).build(); + .cognitoUserPool(COGNITO_IDENTITY_ID).claim(CLAIM_KEY, CLAIM_VALUE).build(); + private static final AwsProxyRequest ALB_REQUEST_COGNITO_USER_POOL = new AwsProxyRequestBuilder("/hello", "GET") + .alb() + .header(ALB_ACESS_TOKEN_HEADER, "xxxxx") + .header(ALB_IDENTITY_HEADER, COGNITO_IDENTITY_ID) + .build(); + private static final AwsProxyRequest ALB_REQUEST_MULTIPLE_HEADERS = new AwsProxyRequestBuilder("/hello", "GET") + .alb() + .header(ALB_ACESS_TOKEN_HEADER, "xxxxx") + .header(ALB_IDENTITY_HEADER, "test-identity") + .header(ALB_IDENTITY_HEADER, COGNITO_IDENTITY_ID) + .build(); @Test - public void localVars_constructor_nullValues() { + void localVars_constructor_nullValues() { AwsProxySecurityContext context = new AwsProxySecurityContext(null, null); - assertNull(context.event); - assertNull(context.lambdaContext); + assertNull(context.getEvent()); + assertNull(context.getLambdaContext()); } @Test - public void localVars_constructor_ValidRequest() { + void localVars_constructor_ValidRequest() { AwsProxySecurityContext context = new AwsProxySecurityContext(null, REQUEST_NO_AUTH); - assertEquals(REQUEST_NO_AUTH, context.event); - assertNull(context.lambdaContext); + assertEquals(REQUEST_NO_AUTH, context.getEvent()); + assertNull(context.getLambdaContext()); } @Test - public void authScheme_getAuthenticationScheme_userPool() { + void alb_noAuth_expectEmptyScheme() { + AwsProxySecurityContext context = new AwsProxySecurityContext(null, ALB_REQUEST_NO_AUTH); + assertEquals(ALB_REQUEST_NO_AUTH, context.getEvent()); + assertNull(context.getLambdaContext()); + assertFalse(context.isSecure()); + assertNull(context.getAuthenticationScheme()); + } + + @Test + void authScheme_getAuthenticationScheme_userPool() { AwsProxySecurityContext context = new AwsProxySecurityContext(null, REQUEST_COGNITO_USER_POOL); assertNotNull(context.getAuthenticationScheme()); - assertEquals("COGNITO_USER_POOL", context.getAuthenticationScheme()); + assertEquals(AUTH_SCHEME_COGNITO_POOL, context.getAuthenticationScheme()); } @Test - public void authScheme_getPrincipal_userPool() { + void authScheme_getPrincipal_userPool() { AwsProxySecurityContext context = new AwsProxySecurityContext(null, REQUEST_COGNITO_USER_POOL); - assertEquals("COGNITO_USER_POOL", context.getAuthenticationScheme()); + assertEquals(AUTH_SCHEME_COGNITO_POOL, context.getAuthenticationScheme()); assertEquals(COGNITO_IDENTITY_ID, context.getUserPrincipal().getName()); } + + @Test + void alb_cognitoAuth_expectCustomSchemeAndCorrectPrincipal() { + AwsProxySecurityContext context = new AwsProxySecurityContext(null, ALB_REQUEST_COGNITO_USER_POOL); + assertTrue(context.isSecure()); + assertEquals(AUTH_SCHEME_CUSTOM, context.getAuthenticationScheme()); + assertEquals(COGNITO_IDENTITY_ID, context.getUserPrincipal().getName()); + } + + @Test + void alb_multipleIdentityHeaders_usesLastValue() { + AwsProxySecurityContext context = new AwsProxySecurityContext(null, ALB_REQUEST_MULTIPLE_HEADERS); + assertEquals(COGNITO_IDENTITY_ID, context.getUserPrincipal().getName()); + } + + @Test + void userPool_getClaims_retrieveCustomClaim() { + AwsProxySecurityContext context = new AwsProxySecurityContext(null, REQUEST_COGNITO_USER_POOL); + Principal userPrincipal = context.getUserPrincipal(); + assertNotNull(userPrincipal.getName()); + assertEquals(COGNITO_IDENTITY_ID, userPrincipal.getName()); + + assertTrue(userPrincipal instanceof AwsProxySecurityContext.CognitoUserPoolPrincipal); + assertNotNull(((AwsProxySecurityContext.CognitoUserPoolPrincipal)userPrincipal).getClaims().getClaim(CLAIM_KEY)); + assertEquals(CLAIM_VALUE, ((AwsProxySecurityContext.CognitoUserPoolPrincipal)userPrincipal).getClaims().getClaim(CLAIM_KEY)); + } } diff --git a/aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/internal/jaxrs/HttpApiV2SecurityContextTest.java b/aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/internal/jaxrs/HttpApiV2SecurityContextTest.java new file mode 100644 index 00000000..a4c6aa31 --- /dev/null +++ b/aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/internal/jaxrs/HttpApiV2SecurityContextTest.java @@ -0,0 +1,49 @@ +package com.amazonaws.serverless.proxy.internal.jaxrs; + +import com.amazonaws.serverless.proxy.AwsHttpApiV2SecurityContextWriter; +import com.amazonaws.serverless.proxy.internal.testutils.AwsProxyRequestBuilder; +import com.amazonaws.serverless.proxy.model.HttpApiV2ProxyRequest; +import org.junit.jupiter.api.Test; + +import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.core.SecurityContext; + +import static org.junit.jupiter.api.Assertions.*; + +public class HttpApiV2SecurityContextTest { + private static final String JWT_SUB_VALUE = "1234567890"; + + HttpApiV2ProxyRequest EMPTY_AUTH = new AwsProxyRequestBuilder("/", "GET").toHttpApiV2Request(); + HttpApiV2ProxyRequest BASIC_AUTH = new AwsProxyRequestBuilder("/", "GET") + .authorizerPrincipal("test").toHttpApiV2Request(); + HttpApiV2ProxyRequest JWT_AUTH = new AwsProxyRequestBuilder("/", "GET") + .authorizerPrincipal("test") + .header(HttpHeaders.AUTHORIZATION, "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c") + .toHttpApiV2Request(); + + AwsHttpApiV2SecurityContextWriter contextWriter = new AwsHttpApiV2SecurityContextWriter(); + + @Test + void getAuthenticationScheme_nullAuth_nullSchema() { + SecurityContext ctx = contextWriter.writeSecurityContext(EMPTY_AUTH, null); + assertNull(ctx.getAuthenticationScheme()); + assertNull(ctx.getUserPrincipal()); + assertFalse(ctx.isSecure()); + } + + @Test + void getAuthenticationScheme_jwtAuth_correctSchema() { + SecurityContext ctx = contextWriter.writeSecurityContext(BASIC_AUTH, null); + assertEquals(AwsHttpApiV2SecurityContext.AUTH_SCHEME_JWT, ctx.getAuthenticationScheme()); + assertTrue(ctx.isSecure()); + assertNull(ctx.getUserPrincipal()); + } + + @Test + void getPrincipal_parseJwt_returnsSub() { + SecurityContext ctx = contextWriter.writeSecurityContext(JWT_AUTH, null); + assertEquals(AwsHttpApiV2SecurityContext.AUTH_SCHEME_JWT, ctx.getAuthenticationScheme()); + assertTrue(ctx.isSecure()); + assertEquals(JWT_SUB_VALUE, ctx.getUserPrincipal().getName()); + } +} diff --git a/aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/internal/servlet/ApacheCombinedServletLogFormatterTest.java b/aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/internal/servlet/ApacheCombinedServletLogFormatterTest.java new file mode 100644 index 00000000..69b2d798 --- /dev/null +++ b/aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/internal/servlet/ApacheCombinedServletLogFormatterTest.java @@ -0,0 +1,86 @@ +package com.amazonaws.serverless.proxy.internal.servlet; + + +import com.amazonaws.serverless.proxy.model.ApiGatewayRequestIdentity; +import com.amazonaws.serverless.proxy.model.AwsProxyRequest; +import com.amazonaws.serverless.proxy.model.AwsProxyRequestContext; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneId; + +import static com.amazonaws.serverless.proxy.RequestReader.API_GATEWAY_CONTEXT_PROPERTY; +import static org.hamcrest.CoreMatchers.containsString; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class ApacheCombinedServletLogFormatterTest { + + private ApacheCombinedServletLogFormatter sut; + + private HttpServletRequest mockServletRequest; + private HttpServletResponse mockServletResponse; + private AwsProxyRequest proxyRequest; + private AwsProxyRequestContext context; + + @BeforeEach + public void setup() { + proxyRequest = new AwsProxyRequest(); + Clock fixedClock = Clock.fixed(Instant.ofEpochSecond(665888523L), ZoneId.of("UTC")); + mockServletRequest = mock(HttpServletRequest.class); + context = new AwsProxyRequestContext(); + context.setIdentity(new ApiGatewayRequestIdentity()); + when(mockServletRequest.getAttribute(eq(API_GATEWAY_CONTEXT_PROPERTY))) + .thenReturn(context); + when(mockServletRequest.getMethod()) + .thenReturn("GET"); + mockServletResponse = mock(HttpServletResponse.class); + proxyRequest.setRequestContext(context); + + sut = new ApacheCombinedServletLogFormatter(fixedClock); + } + + @Test + void logsCurrentTimeWhenContextNull() { + // given + proxyRequest.setRequestContext(null); + + // when + String actual = sut.format(mockServletRequest, mockServletResponse, null); + + // then + assertThat(actual, containsString("[07/02/1991:01:02:03Z]")); + } + + @Test + void logsCurrentTimeWhenRequestTimeZero() { + // given + context.setRequestTimeEpoch(0); + + // when + String actual = sut.format(mockServletRequest, mockServletResponse, null); + + // then + assertThat(actual, containsString("[07/02/1991:01:02:03Z]")); + } + + @Test + void logsRequestTimeWhenRequestTimeEpochGreaterThanZero() { + // given + context.setRequestTimeEpoch(1563023494000L); + + // when + String actual = sut.format(mockServletRequest, mockServletResponse, null); + + // then + assertThat(actual, containsString("[13/07/2019:13:11:34Z]")); + } + +} diff --git a/aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/internal/servlet/AwsAsyncContextTest.java b/aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/internal/servlet/AwsAsyncContextTest.java new file mode 100644 index 00000000..a8383b5c --- /dev/null +++ b/aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/internal/servlet/AwsAsyncContextTest.java @@ -0,0 +1,115 @@ +package com.amazonaws.serverless.proxy.internal.servlet; + +import com.amazonaws.serverless.exceptions.ContainerInitializationException; +import com.amazonaws.serverless.exceptions.InvalidRequestEventException; +import com.amazonaws.serverless.proxy.AwsProxyExceptionHandler; +import com.amazonaws.serverless.proxy.AwsProxySecurityContextWriter; +import com.amazonaws.serverless.proxy.internal.LambdaContainerHandler; +import com.amazonaws.serverless.proxy.internal.testutils.AwsProxyRequestBuilder; +import com.amazonaws.serverless.proxy.internal.testutils.MockLambdaContext; +import com.amazonaws.serverless.proxy.model.AwsProxyRequest; +import com.amazonaws.serverless.proxy.model.AwsProxyResponse; +import com.amazonaws.services.lambda.runtime.Context; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; + +import jakarta.servlet.AsyncContext; +import jakarta.servlet.Servlet; +import jakarta.servlet.ServletException; +import jakarta.servlet.ServletRegistration; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.concurrent.CountDownLatch; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +public class AwsAsyncContextTest { + private MockLambdaContext lambdaCtx = new MockLambdaContext(); + private MockContainerHandler handler = new MockContainerHandler(); + private AwsProxyHttpServletRequestReader reader = new AwsProxyHttpServletRequestReader(); + private AwsServletContextTest.TestServlet srv1 = new AwsServletContextTest.TestServlet("srv1"); + private AwsServletContextTest.TestServlet srv2 = new AwsServletContextTest.TestServlet("srv2"); + private AwsServletContext ctx = getCtx(); + + + @Test + void dispatch_amendsPath() throws InvalidRequestEventException { + AwsProxyHttpServletRequest req = (AwsProxyHttpServletRequest)reader.readRequest(new AwsProxyRequestBuilder("/srv1/hello", "GET").build(), null, lambdaCtx, LambdaContainerHandler.getContainerConfig()); + req.setResponse(handler.getContainerResponse(req, new CountDownLatch(1))); + req.setServletContext(ctx); + req.setContainerHandler(handler); + + AsyncContext asyncCtx = req.startAsync(); + asyncCtx.dispatch("/srv4/hello"); + assertEquals("/srv1/hello", req.getRequestURI()); + } + + + private AwsServletContext getCtx() { + AwsServletContext ctx = new AwsServletContext(handler); + handler.setServletContext(ctx); + + ServletRegistration.Dynamic reg1 = ctx.addServlet("srv1", srv1); + reg1.addMapping("/srv1"); + + ServletRegistration.Dynamic reg2 = ctx.addServlet("srv2", srv2); + reg2.addMapping("/"); + return ctx; + } + + public static class MockContainerHandler extends AwsLambdaServletContainerHandler { + private int desiredStatus; + private HttpServletResponse response; + private Servlet selectedServlet; + + public MockContainerHandler() { + super(AwsProxyRequest.class, AwsProxyResponse.class, new AwsProxyHttpServletRequestReader(), new AwsProxyHttpServletResponseWriter(), new AwsProxySecurityContextWriter(), new AwsProxyExceptionHandler()); + desiredStatus = 200; + } + + @Override + protected AwsHttpServletResponse getContainerResponse(HttpServletRequest request, CountDownLatch latch) { + return new AwsHttpServletResponse(request, latch); + } + + @Override + protected void doFilter(HttpServletRequest request, HttpServletResponse response, Servlet servlet) throws IOException, ServletException { + selectedServlet = servlet; + try { + this.response = response; + //handleRequest((AwsProxyHttpServletRequest)request, , new MockLambdaContext()); + if (AwsProxyHttpServletRequest.class.isAssignableFrom(request.getClass())) { + ((AwsProxyHttpServletRequest)request).setResponse((AwsHttpServletResponse)this.response); + } + this.response.setStatus(desiredStatus); + this.response.flushBuffer(); + } catch (Exception e) { + throw new ServletException(e); + } + } + + @Override + protected void handleRequest(HttpServletRequest containerRequest, AwsHttpServletResponse containerResponse, Context lambdaContext) throws Exception { + + } + + @Override + public void initialize() throws ContainerInitializationException { + + } + + public void setDesiredStatus(int status) { + desiredStatus = status; + } + + public AwsHttpServletResponse getResponse() { + return (AwsHttpServletResponse)response; + } + + public Servlet getSelectedServlet() { + return selectedServlet; + } + } +} diff --git a/aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/internal/servlet/AwsFilterChainManagerTest.java b/aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/internal/servlet/AwsFilterChainManagerTest.java index e0dad89a..1a51b558 100644 --- a/aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/internal/servlet/AwsFilterChainManagerTest.java +++ b/aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/internal/servlet/AwsFilterChainManagerTest.java @@ -2,38 +2,53 @@ import com.amazonaws.serverless.proxy.internal.testutils.AwsProxyRequestBuilder; import com.amazonaws.serverless.proxy.internal.testutils.MockLambdaContext; +import com.amazonaws.serverless.proxy.internal.testutils.MockServlet; import com.amazonaws.services.lambda.runtime.Context; -import org.junit.BeforeClass; -import org.junit.Test; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; -import javax.servlet.*; +import jakarta.servlet.*; import java.io.IOException; import java.util.EnumSet; +import java.util.concurrent.CountDownLatch; -import static org.junit.Assert.*; +import static org.junit.jupiter.api.Assertions.*; public class AwsFilterChainManagerTest { + private static final String SERVLET1_NAME = "Servlet 1"; + private static final String SERVLET2_NAME = "Servlet 2"; + private static final String REQUEST_CUSTOM_ATTRIBUTE_NAME = "X-Custom-Attribute"; + private static final String REQUEST_CUSTOM_ATTRIBUTE_VALUE = "CustomAttrValue"; private static AwsFilterChainManager chainManager; private static Context lambdaContext = new MockLambdaContext(); + private static ServletContext servletContext; - @BeforeClass + private Logger log = LoggerFactory.getLogger(AwsFilterChainManagerTest.class); + + @BeforeAll public static void setUp() { - ServletContext context = AwsServletContext.getInstance(lambdaContext); + servletContext = new AwsServletContext( null);//AwsServletContext.getInstance(lambdaContext, null); - FilterRegistration.Dynamic reg = context.addFilter("Filter1", new MockFilter()); + FilterRegistration.Dynamic reg = servletContext.addFilter("Filter1", new MockFilter()); reg.addMappingForUrlPatterns(EnumSet.of(DispatcherType.REQUEST), true, "/first/second"); - FilterRegistration.Dynamic reg2 = context.addFilter("Filter2", new MockFilter()); + FilterRegistration.Dynamic reg2 = servletContext.addFilter("Filter2", new MockFilter()); reg2.addMappingForUrlPatterns(EnumSet.of(DispatcherType.REQUEST), true, "/second/*"); - FilterRegistration.Dynamic reg3 = context.addFilter("Filter3", new MockFilter()); + FilterRegistration.Dynamic reg3 = servletContext.addFilter("Filter3", new MockFilter()); reg3.addMappingForUrlPatterns(EnumSet.of(DispatcherType.REQUEST), true, "/third/fourth/*"); + ServletRegistration.Dynamic firstServlet = servletContext.addServlet(SERVLET1_NAME, new MockServlet()); + firstServlet.addMapping("/first/*"); + ServletRegistration.Dynamic secondServlet = servletContext.addServlet(SERVLET2_NAME, new MockServlet()); + secondServlet.addMapping("/second/*"); - chainManager = new AwsFilterChainManager((AwsServletContext) context); + chainManager = new AwsFilterChainManager((AwsServletContext) servletContext); } @Test - public void paths_pathMatches_validPaths() { + void paths_pathMatches_validPaths() { assertTrue(chainManager.pathMatches("/users/123123123", "/users/*")); assertTrue(chainManager.pathMatches("/apis/123/methods", "/apis/*")); assertTrue(chainManager.pathMatches("/very/long/path/with/sub/resources", "/*")); @@ -44,7 +59,7 @@ public void paths_pathMatches_validPaths() { } @Test - public void paths_pathMatches_invalidPaths() { + void paths_pathMatches_invalidPaths() { // I expect we'd want to run filters on these requests, especially the ones that look invalid assertTrue(chainManager.pathMatches("_%Garbled%20Path_%", "/*")); assertTrue(chainManager.pathMatches("", "/*")); @@ -53,7 +68,7 @@ public void paths_pathMatches_invalidPaths() { } @Test - public void cacheKey_compare_samePath() { + void cacheKey_compare_samePath() { FilterChainManager.TargetCacheKey cacheKey = new FilterChainManager.TargetCacheKey(); cacheKey.setDispatcherType(DispatcherType.REQUEST); cacheKey.setTargetPath("/first/path"); @@ -63,11 +78,11 @@ public void cacheKey_compare_samePath() { secondCacheKey.setTargetPath("/first/path"); assertEquals(cacheKey.hashCode(), secondCacheKey.hashCode()); - assertTrue(cacheKey.equals(secondCacheKey)); + assertEquals(cacheKey, secondCacheKey); } @Test - public void cacheKey_compare_differentDispatcher() { + void cacheKey_compare_differentDispatcher() { FilterChainManager.TargetCacheKey cacheKey = new FilterChainManager.TargetCacheKey(); cacheKey.setDispatcherType(DispatcherType.REQUEST); cacheKey.setTargetPath("/first/path"); @@ -77,11 +92,27 @@ public void cacheKey_compare_differentDispatcher() { secondCacheKey.setTargetPath("/first/path"); assertNotEquals(cacheKey.hashCode(), secondCacheKey.hashCode()); - assertFalse(cacheKey.equals(secondCacheKey)); + assertNotEquals(cacheKey, secondCacheKey); + } + + @Test + void cacheKey_compare_differentServlet() { + FilterChainManager.TargetCacheKey cacheKey = new FilterChainManager.TargetCacheKey(); + cacheKey.setDispatcherType(DispatcherType.REQUEST); + cacheKey.setTargetPath("/first/path"); + cacheKey.setServletName("Dispatcher servlet"); + + FilterChainManager.TargetCacheKey secondCacheKey = new FilterChainManager.TargetCacheKey(); + secondCacheKey.setDispatcherType(DispatcherType.REQUEST); + secondCacheKey.setTargetPath("/first/path"); + cacheKey.setServletName("Real servlet"); + + assertNotEquals(cacheKey.hashCode(), secondCacheKey.hashCode()); + assertNotEquals(cacheKey, secondCacheKey); } @Test - public void cacheKey_compare_additionalChars() { + void cacheKey_compare_additionalChars() { FilterChainManager.TargetCacheKey cacheKey = new FilterChainManager.TargetCacheKey(); cacheKey.setDispatcherType(DispatcherType.REQUEST); cacheKey.setTargetPath("/first/path"); @@ -90,56 +121,156 @@ public void cacheKey_compare_additionalChars() { secondCacheKey.setDispatcherType(DispatcherType.REQUEST); secondCacheKey.setTargetPath("/first/path/"); assertEquals(cacheKey.hashCode(), secondCacheKey.hashCode()); - assertTrue(cacheKey.equals(secondCacheKey)); + assertEquals(cacheKey, secondCacheKey); secondCacheKey.setTargetPath(" /first/path"); assertEquals(cacheKey.hashCode(), secondCacheKey.hashCode()); - assertTrue(cacheKey.equals(secondCacheKey)); + assertEquals(cacheKey, secondCacheKey); secondCacheKey.setTargetPath("first/path/"); assertEquals(cacheKey.hashCode(), secondCacheKey.hashCode()); - assertTrue(cacheKey.equals(secondCacheKey)); + assertEquals(cacheKey, secondCacheKey); } @Test - public void filterChain_getFilterChain_subsetOfFilters() { + void filterChain_getFilterChain_subsetOfFilters() { AwsProxyHttpServletRequest req = new AwsProxyHttpServletRequest( - new AwsProxyRequestBuilder("/first/second", "GET").build(), lambdaContext, null + new AwsProxyRequestBuilder("/first/second", "GET").build(), lambdaContext, null ); - FilterChainHolder fcHolder = chainManager.getFilterChain(req); + req.setServletContext(servletContext); + FilterChainHolder fcHolder = chainManager.getFilterChain(req, null); assertEquals(1, fcHolder.filterCount()); assertEquals("Filter1", fcHolder.getFilter(0).getFilterName()); req = new AwsProxyHttpServletRequest( new AwsProxyRequestBuilder("/second/mime", "GET").build(), lambdaContext, null ); - fcHolder = chainManager.getFilterChain(req); + fcHolder = chainManager.getFilterChain(req, null); assertEquals(1, fcHolder.filterCount()); assertEquals("Filter2", fcHolder.getFilter(0).getFilterName()); req = new AwsProxyHttpServletRequest( new AwsProxyRequestBuilder("/second/mime/third", "GET").build(), lambdaContext, null ); - fcHolder = chainManager.getFilterChain(req); + fcHolder = chainManager.getFilterChain(req, null); assertEquals(1, fcHolder.filterCount()); assertEquals("Filter2", fcHolder.getFilter(0).getFilterName()); } @Test - public void filterChain_getFilterChain_multipleFilters() { + void filterChain_matchMultipleTimes_expectSameMatch() { + AwsProxyHttpServletRequest req = new AwsProxyHttpServletRequest( + new AwsProxyRequestBuilder("/first/second", "GET").build(), lambdaContext, null + ); + req.setServletContext(servletContext); + FilterChainHolder fcHolder = chainManager.getFilterChain(req, null); + assertEquals(1, fcHolder.filterCount()); + assertEquals("Filter1", fcHolder.getFilter(0).getFilterName()); + + AwsProxyHttpServletRequest req2 = new AwsProxyHttpServletRequest( + new AwsProxyRequestBuilder("/first/second", "GET").build(), lambdaContext, null + ); + req.setServletContext(servletContext); + FilterChainHolder fcHolder2 = chainManager.getFilterChain(req2, null); + assertEquals(1, fcHolder2.filterCount()); + assertEquals("Filter1", fcHolder2.getFilter(0).getFilterName()); + } + + @Test + void filterChain_executeMultipleFilters_expectRunEachTime() { + AwsProxyHttpServletRequest req = new AwsProxyHttpServletRequest( + new AwsProxyRequestBuilder("/first/second", "GET").build(), lambdaContext, null + ); + req.setServletContext(servletContext); + FilterChainHolder fcHolder = chainManager.getFilterChain(req, null); + assertEquals(1, fcHolder.filterCount()); + assertEquals("Filter1", fcHolder.getFilter(0).getFilterName()); + AwsHttpServletResponse resp = new AwsHttpServletResponse(req, new CountDownLatch(1)); + + try { + fcHolder.doFilter(req, resp); + } catch (IOException e) { + fail("IO Exception while executing filters"); + e.printStackTrace(); + } catch (ServletException e) { + fail("Servlet exception while executing filters"); + e.printStackTrace(); + } + + assertTrue(req.getAttribute(REQUEST_CUSTOM_ATTRIBUTE_NAME) != null); + assertEquals(REQUEST_CUSTOM_ATTRIBUTE_VALUE, req.getAttribute(REQUEST_CUSTOM_ATTRIBUTE_NAME)); + + log.debug("Starting second request"); + + AwsProxyHttpServletRequest req2 = new AwsProxyHttpServletRequest( + new AwsProxyRequestBuilder("/first/second", "GET").build(), lambdaContext, null + ); + req2.setServletContext(servletContext); + FilterChainHolder fcHolder2 = chainManager.getFilterChain(req2, null); + assertEquals(1, fcHolder2.filterCount()); + assertEquals("Filter1", fcHolder2.getFilter(0).getFilterName()); + assertEquals(-1, fcHolder2.currentFilter); + + AwsHttpServletResponse resp2 = new AwsHttpServletResponse(req, new CountDownLatch(1)); + + try { + fcHolder2.doFilter(req2, resp2); + } catch (IOException e) { + e.printStackTrace(); + fail("IO Exception while executing filters"); + } catch (ServletException e) { + e.printStackTrace(); + fail("Servlet exception while executing filters"); + } + + assertTrue(req2.getAttribute(REQUEST_CUSTOM_ATTRIBUTE_NAME) != null); + assertEquals(REQUEST_CUSTOM_ATTRIBUTE_VALUE, req2.getAttribute(REQUEST_CUSTOM_ATTRIBUTE_NAME)); + } + + @Test + void filterChain_multipleServlets_callsCorrectServlet() throws IOException, ServletException { + MockServlet servlet1 = (MockServlet) ((AwsServletRegistration) servletContext.getServletRegistration(SERVLET1_NAME)).getServlet(); + ServletConfig servlet1Config = ((AwsServletRegistration) servletContext.getServletRegistration(SERVLET1_NAME)).getServletConfig(); + servlet1.init(servlet1Config); + + MockServlet servlet2 = (MockServlet) ((AwsServletRegistration) servletContext.getServletRegistration(SERVLET2_NAME)).getServlet(); + ServletConfig servlet2Config = ((AwsServletRegistration) servletContext.getServletRegistration(SERVLET2_NAME)).getServletConfig(); + servlet2.init(servlet2Config); + + AwsProxyHttpServletRequest req = new AwsProxyHttpServletRequest( + new AwsProxyRequestBuilder("/", "GET").build(), lambdaContext, null + ); + AwsHttpServletResponse resp = new AwsHttpServletResponse(req, new CountDownLatch(1)); + + FilterChainHolder servlet1filterChain = chainManager.getFilterChain(req, servlet1); + servlet1filterChain.doFilter(req, resp); + + assertEquals(1, servlet1.getServiceCalls()); + assertEquals(0, servlet2.getServiceCalls()); + + FilterChainHolder servlet2filterChain = chainManager.getFilterChain(req, servlet2); + servlet2filterChain.doFilter(req, resp); + + assertEquals(1, servlet1.getServiceCalls()); + assertEquals(1, servlet2.getServiceCalls()); + } + + @Test + void filterChain_getFilterChain_multipleFilters() { AwsProxyHttpServletRequest req = new AwsProxyHttpServletRequest( new AwsProxyRequestBuilder("/second/important", "GET").build(), lambdaContext, null ); + req.setServletContext(servletContext); FilterRegistration.Dynamic reg = req.getServletContext().addFilter("Filter4", new MockFilter()); reg.addMappingForUrlPatterns(EnumSet.of(DispatcherType.REQUEST), true, "/second/*"); - FilterChainHolder fcHolder = chainManager.getFilterChain(req); + FilterChainHolder fcHolder = chainManager.getFilterChain(req, null); assertEquals(2, fcHolder.filterCount()); assertEquals("Filter2", fcHolder.getFilter(0).getFilterName()); assertEquals("Filter4", fcHolder.getFilter(1).getFilterName()); reg = req.getServletContext().addFilter("Filter5", new MockFilter()); reg.addMappingForUrlPatterns(EnumSet.of(DispatcherType.REQUEST), false, "/second/*"); - fcHolder = chainManager.getFilterChain(req); + fcHolder = chainManager.getFilterChain(req, null); assertEquals(3, fcHolder.filterCount()); assertEquals("Filter2", fcHolder.getFilter(0).getFilterName()); assertEquals("Filter4", fcHolder.getFilter(1).getFilterName()); @@ -150,18 +281,18 @@ private static class MockFilter implements Filter { @Override public void init(FilterConfig filterConfig) throws ServletException { - System.out.println("Init"); + } @Override public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException { - System.out.println("DoFilter"); + servletRequest.setAttribute(REQUEST_CUSTOM_ATTRIBUTE_NAME, REQUEST_CUSTOM_ATTRIBUTE_VALUE); filterChain.doFilter(servletRequest, servletResponse); } @Override public void destroy() { - System.out.println("Destroy"); + } } } diff --git a/aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/internal/servlet/AwsHttpApiV2HttpServletRequestReaderTest.java b/aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/internal/servlet/AwsHttpApiV2HttpServletRequestReaderTest.java new file mode 100644 index 00000000..12f694bc --- /dev/null +++ b/aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/internal/servlet/AwsHttpApiV2HttpServletRequestReaderTest.java @@ -0,0 +1,53 @@ +package com.amazonaws.serverless.proxy.internal.servlet; + +import com.amazonaws.serverless.exceptions.InvalidRequestEventException; +import com.amazonaws.serverless.proxy.internal.LambdaContainerHandler; +import com.amazonaws.serverless.proxy.internal.testutils.AwsProxyRequestBuilder; +import com.amazonaws.serverless.proxy.model.HttpApiV2ProxyRequest; +import com.amazonaws.serverless.proxy.model.HttpApiV2ProxyRequestContext; +import org.junit.jupiter.api.Test; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.ws.rs.core.HttpHeaders; + +import static org.junit.jupiter.api.Assertions.*; + +public class AwsHttpApiV2HttpServletRequestReaderTest { + private AwsHttpApiV2HttpServletRequestReader reader = new AwsHttpApiV2HttpServletRequestReader(); + + @Test + void reflection_getRequestClass_returnsCorrectType() { + assertSame(HttpApiV2ProxyRequest.class, reader.getRequestClass()); + } + + @Test + void baseRequest_read_populatesSuccessfully() { + HttpApiV2ProxyRequest req = new AwsProxyRequestBuilder("/hello", "GET") + .referer("localhost") + .userAgent("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/102.0.5005.61 Safari/537.36") + .queryString("param1", "value1") + .header("custom", "value") + .cookie("cookey", "cooval") + .apiId("test").toHttpApiV2Request(); + AwsHttpApiV2HttpServletRequestReader reader = new AwsHttpApiV2HttpServletRequestReader(); + try { + HttpServletRequest servletRequest = reader.readRequest(req, null, null, LambdaContainerHandler.getContainerConfig()); + assertEquals("/hello", servletRequest.getPathInfo()); + assertEquals("value1", servletRequest.getParameter("param1")); + assertEquals("value", servletRequest.getHeader("CUSTOM")); + assertEquals("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/102.0.5005.61 Safari/537.36", servletRequest.getHeader(HttpHeaders.USER_AGENT)); + + assertNotNull(servletRequest.getCookies()); + assertEquals(1, servletRequest.getCookies().length); + assertEquals("cookey", servletRequest.getCookies()[0].getName()); + assertEquals("cooval", servletRequest.getCookies()[0].getValue()); + + assertNotNull(servletRequest.getAttribute(AwsHttpApiV2HttpServletRequestReader.HTTP_API_CONTEXT_PROPERTY)); + assertEquals("test", + ((HttpApiV2ProxyRequestContext)servletRequest.getAttribute(AwsHttpApiV2HttpServletRequestReader.HTTP_API_CONTEXT_PROPERTY)).getApiId()); + } catch (InvalidRequestEventException e) { + e.printStackTrace(); + fail("Could not read request"); + } + } +} diff --git a/aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/internal/servlet/AwsHttpServletRequestTest.java b/aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/internal/servlet/AwsHttpServletRequestTest.java index 41c49dfc..83c74724 100644 --- a/aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/internal/servlet/AwsHttpServletRequestTest.java +++ b/aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/internal/servlet/AwsHttpServletRequestTest.java @@ -1,17 +1,20 @@ package com.amazonaws.serverless.proxy.internal.servlet; -import com.amazonaws.serverless.proxy.internal.model.AwsProxyRequest; +import com.amazonaws.serverless.proxy.model.AwsProxyRequest; +import com.amazonaws.serverless.proxy.model.MultiValuedTreeMap; import com.amazonaws.serverless.proxy.internal.testutils.AwsProxyRequestBuilder; import com.amazonaws.serverless.proxy.internal.testutils.MockLambdaContext; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; -import org.junit.Test; +import com.amazonaws.serverless.proxy.model.ContainerConfig; -import javax.ws.rs.core.HttpHeaders; +import org.junit.jupiter.api.Test; -import static org.junit.Assert.*; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.Cookie; +import jakarta.ws.rs.core.HttpHeaders; -import java.util.AbstractMap; +import static org.junit.jupiter.api.Assertions.*; + +import java.util.Base64; import java.util.List; import java.util.Map; @@ -22,18 +25,44 @@ public class AwsHttpServletRequestTest { .header(HttpHeaders.CONTENT_TYPE, "application/xml; charset=utf-8").build(); private static final AwsProxyRequest validCookieRequest = new AwsProxyRequestBuilder("/cookie", "GET") .header(HttpHeaders.COOKIE, "yummy_cookie=choco; tasty_cookie=strawberry").build(); + private static final AwsProxyRequest controlCharCookieRequest = new AwsProxyRequestBuilder("/cookie", "GET") + .header(HttpHeaders.COOKIE, "name=\u0007\u0009; tasty_cookie=strawberry").build(); + private static final AwsProxyRequest unicodeCookieRequest = new AwsProxyRequestBuilder("/cookie", "GET") + .header(HttpHeaders.COOKIE, "yummy_cookie=chøcø; tasty_cookie=strawberry").build(); + private static final AwsProxyRequest invalidNameCookieRequest = new AwsProxyRequestBuilder("/cookie", "GET") + .header(HttpHeaders.COOKIE, "yummy@cookie=choco; tasty_cookie=strawberry").build(); private static final AwsProxyRequest complexAcceptHeader = new AwsProxyRequestBuilder("/accept", "GET") .header(HttpHeaders.ACCEPT, "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8").build(); private static final AwsProxyRequest queryString = new AwsProxyRequestBuilder("/test", "GET") .queryString("one", "two").queryString("three", "four").build(); + private static final AwsProxyRequest queryStringNullValue = new AwsProxyRequestBuilder("/test", "GET") + .queryString("one", "two").queryString("three", null).build(); + private static final AwsProxyRequest queryStringEmptyValue = new AwsProxyRequestBuilder("/test", "GET") + .queryString("one", "two").queryString("three", "").build(); + private static final AwsProxyRequest encodedQueryString = new AwsProxyRequestBuilder("/test", "GET") + .queryString("one", "two").queryString("json value@1", "{\"name\":\"faisal\"}").build(); + private static final AwsProxyRequest encodedQueryStringAlb = new AwsProxyRequestBuilder("/test", "GET") + .queryString("one", "two").queryString("json value@1", "{\"name\":\"faisal\"}").alb().build(); + private static final AwsProxyRequest multipleParams = new AwsProxyRequestBuilder("/test", "GET") + .queryString("one", "two").queryString("one", "three").queryString("json value@1", "{\"name\":\"faisal\"}").build(); + private static final AwsProxyRequest formEncodedAndQueryString = new AwsProxyRequestBuilder("/test", "POST") + .queryString("one", "two").queryString("one", "three") + .queryString("five", "six") + .form("one", "four") + .form("seven", "eight").build(); + private static final AwsProxyRequest differentCasing = new AwsProxyRequestBuilder("/test", "POST") + .queryString("one", "two").queryString("one", "three") + .queryString("ONE", "four").build(); private static final MockLambdaContext mockContext = new MockLambdaContext(); + private static ContainerConfig config = ContainerConfig.defaultConfig(); + @Test - public void headers_parseHeaderValue_multiValue() { - AwsProxyHttpServletRequest request = new AwsProxyHttpServletRequest(contentTypeRequest, mockContext, null); + void headers_parseHeaderValue_multiValue() { + AwsProxyHttpServletRequest request = new AwsProxyHttpServletRequest(contentTypeRequest, mockContext, null, config); // I'm also using this to double-check that I can get a header ignoring case - List> values = request.parseHeaderValue(request.getHeader("content-type")); + List values = request.parseHeaderValue(request.getHeader("content-type")); assertEquals(2, values.size()); assertEquals("application/xml", values.get(0).getValue()); @@ -44,9 +73,9 @@ public void headers_parseHeaderValue_multiValue() { } @Test - public void headers_parseHeaderValue_validMultipleCookie() { - AwsProxyHttpServletRequest request = new AwsProxyHttpServletRequest(validCookieRequest, mockContext, null); - List> values = request.parseHeaderValue(request.getHeader(HttpHeaders.COOKIE)); + void headers_parseHeaderValue_validMultipleCookie() { + AwsProxyHttpServletRequest request = new AwsProxyHttpServletRequest(validCookieRequest, mockContext, null, config); + List values = request.parseHeaderValue(request.getHeader(HttpHeaders.COOKIE), ";", ","); assertEquals(2, values.size()); assertEquals("yummy_cookie", values.get(0).getKey()); @@ -56,25 +85,387 @@ public void headers_parseHeaderValue_validMultipleCookie() { } @Test - public void headers_parseHeaderValue_complexAccept() { - AwsProxyHttpServletRequest request = new AwsProxyHttpServletRequest(complexAcceptHeader, mockContext, null); - List> values = request.parseHeaderValue(request.getHeader(HttpHeaders.ACCEPT)); + void headers_parseHeaderValue_controlCharCookie() { + AwsProxyHttpServletRequest request = new AwsProxyHttpServletRequest(controlCharCookieRequest, mockContext, null, config); + Cookie[] cookies = request.getCookies(); + + // parse only valid cookies + assertEquals(1, cookies.length); + assertEquals("tasty_cookie", cookies[0].getName()); + assertEquals("strawberry", cookies[0].getValue()); + } + + @Test + void headers_parseHeaderValue_unicodeCookie() { + AwsProxyHttpServletRequest request = new AwsProxyHttpServletRequest(unicodeCookieRequest, mockContext, null, config); + Cookie[] cookies = request.getCookies(); + + // parse only valid cookies + assertEquals(1, cookies.length); + assertEquals("tasty_cookie", cookies[0].getName()); + assertEquals("strawberry", cookies[0].getValue()); + } + + @Test + void headers_parseHeaderValue_invalidNameCookie() { + AwsProxyHttpServletRequest request = new AwsProxyHttpServletRequest(invalidNameCookieRequest, mockContext, null, config); + Cookie[] cookies = request.getCookies(); + + // parse only valid cookies + assertEquals(1, cookies.length); + assertEquals("tasty_cookie", cookies[0].getName()); + assertEquals("strawberry", cookies[0].getValue()); + } + + @Test + void headers_parseHeaderValue_complexAccept() { + AwsProxyHttpServletRequest request = new AwsProxyHttpServletRequest(complexAcceptHeader, mockContext, null, config); + List values = request.parseHeaderValue(request.getHeader(HttpHeaders.ACCEPT), ",", ";"); + + assertEquals(4, values.size()); + } + + @Test + void headers_parseHeaderValue_encodedContentWithEquals() { + AwsHttpServletRequest context = new AwsProxyHttpServletRequest(null, null, null); + + String value = Base64.getUrlEncoder().encodeToString("a".getBytes()); + + List result = context.parseHeaderValue(value); + assertTrue(result.size() > 0); + assertEquals("YQ==", result.get(0).getValue()); + } + + @Test + void headers_parseHeaderValue_base64EncodedCookieValue() { + String value = Base64.getUrlEncoder().encodeToString("a".getBytes()); + String cookieValue = "jwt=" + value + "; secondValue=second"; + AwsProxyRequest req = new AwsProxyRequestBuilder("/test", "GET").header(HttpHeaders.COOKIE, cookieValue).build(); + AwsHttpServletRequest context = new AwsProxyHttpServletRequest(req, null, null); + + Cookie[] cookies = context.getCookies(); + + assertEquals(2, cookies.length); + assertEquals("jwt", cookies[0].getName()); + assertEquals(value, cookies[0].getValue()); + } + + @Test + void headers_parseHeaderValue_cookieWithSeparatorInValue() { + String cookieValue = "jwt==test; secondValue=second"; + AwsProxyRequest req = new AwsProxyRequestBuilder("/test", "GET").header(HttpHeaders.COOKIE, cookieValue).build(); + AwsHttpServletRequest context = new AwsProxyHttpServletRequest(req, null, null); + + Cookie[] cookies = context.getCookies(); + + assertEquals(2, cookies.length); + assertEquals("jwt", cookies[0].getName()); + assertEquals("=test", cookies[0].getValue()); + } + + @Test + void headers_parseHeaderValue_headerWithPaddingButNotBase64Encoded() { + AwsHttpServletRequest context = new AwsProxyHttpServletRequest(null, null, null); + + List result = context.parseHeaderValue("hello="); + assertTrue(result.size() > 0); + assertEquals("hello", result.get(0).getKey()); + assertNull(result.get(0).getValue()); + } + + @Test + void queryString_generateQueryString_validQuery() { + AwsProxyHttpServletRequest request = new AwsProxyHttpServletRequest(queryString, mockContext, null, config); + + String parsedString = null; + try { + parsedString = request.generateQueryString(request.getAwsProxyRequest().getMultiValueQueryStringParameters(), true, config.getUriEncoding()); + } catch (ServletException e) { + e.printStackTrace(); + fail("Could not generate query string"); + } + assertTrue(parsedString.contains("one=two")); + assertTrue(parsedString.contains("three=four")); + assertTrue(parsedString.contains("&") && parsedString.indexOf("&") > 0 && parsedString.indexOf("&") < parsedString.length()); + } + + @Test + void queryString_generateQueryString_nullParameterIsEmpty() { + AwsProxyHttpServletRequest request = new AwsProxyHttpServletRequest(queryStringNullValue, mockContext, null, config); + String parsedString = null; + try { + parsedString = request.generateQueryString(request.getAwsProxyRequest().getMultiValueQueryStringParameters(), true, config.getUriEncoding()); + } catch (ServletException e) { + e.printStackTrace(); + fail("Could not generate query string"); + } + + assertTrue(parsedString.endsWith("three=")); + } + + @Test + void queryString_generateQueryString_emptyParameterIsEmpty() { + AwsProxyHttpServletRequest request = new AwsProxyHttpServletRequest(queryStringEmptyValue, mockContext, null, config); + String parsedString = null; + try { + parsedString = request.generateQueryString(request.getAwsProxyRequest().getMultiValueQueryStringParameters(), true, config.getUriEncoding()); + } catch (ServletException e) { + e.printStackTrace(); + fail("Could not generate query string"); + } + + assertTrue(parsedString.endsWith("three=")); + } + + @Test + void queryStringWithEncodedParams_generateQueryString_validQuery() { + AwsProxyHttpServletRequest request = new AwsProxyHttpServletRequest(encodedQueryString, mockContext, null, config); + + String parsedString = null; + try { + parsedString = request.generateQueryString(request.getAwsProxyRequest().getMultiValueQueryStringParameters(), true, config.getUriEncoding()); + } catch (ServletException e) { + e.printStackTrace(); + fail("Could not generate query string"); + } + assertTrue(parsedString.contains("one=two")); + assertTrue(parsedString.contains("json+value%401=%7B%22name%22%3A%22faisal%22%7D")); + assertTrue(parsedString.contains("&") && parsedString.indexOf("&") > 0 && parsedString.indexOf("&") < parsedString.length()); + } + + @Test + void queryStringWithEncodedParams_alb_generateQueryString_validQuery() { + AwsProxyHttpServletRequest request = new AwsProxyHttpServletRequest(encodedQueryStringAlb, mockContext, null, config); + + String parsedString = null; + try { + parsedString = request.generateQueryString(request.getAwsProxyRequest().getMultiValueQueryStringParameters(), false, config.getUriEncoding()); + } catch (ServletException e) { + e.printStackTrace(); + fail("Could not generate query string"); + } + assertTrue(parsedString.contains("one=two")); + assertTrue(parsedString.contains("json+value%401=%7B%22name%22%3A%22faisal%22%7D")); + assertTrue(parsedString.contains("&") && parsedString.indexOf("&") > 0 && parsedString.indexOf("&") < parsedString.length()); + } + + @Test + void queryStringWithMultipleValues_generateQueryString_validQuery() { + AwsProxyHttpServletRequest request = new AwsProxyHttpServletRequest(multipleParams, mockContext, null, config); + + String parsedString = null; + try { + parsedString = request.generateQueryString(request.getAwsProxyRequest().getMultiValueQueryStringParameters(), true, config.getUriEncoding()); + } catch (ServletException e) { + e.printStackTrace(); + fail("Could not generate query string"); + } + assertTrue(parsedString.contains("one=two")); + assertTrue(parsedString.contains("one=three")); + assertTrue(parsedString.contains("json+value%401=%7B%22name%22%3A%22faisal%22%7D")); + assertTrue(parsedString.contains("&") && parsedString.indexOf("&") > 0 && parsedString.indexOf("&") < parsedString.length()); + } + + @Test + void parameterMap_generateParameterMap_validQuery() { + AwsProxyHttpServletRequest request = new AwsProxyHttpServletRequest(queryString, mockContext, null, config); + Map paramMap = null; try { - System.out.println(new ObjectMapper().writeValueAsString(values)); - } catch (JsonProcessingException e) { + paramMap = request.generateParameterMap(request.getAwsProxyRequest().getMultiValueQueryStringParameters(), config); + } catch (Exception e) { e.printStackTrace(); + fail("Could not generate parameter map"); } - assertEquals(3, values.size()); + assertArrayEquals(new String[]{"two"}, paramMap.get("one")); + assertArrayEquals(new String[]{"four"}, paramMap.get("three")); + assertTrue(paramMap.size() == 2); } @Test - public void queyrString_generateQueryString_validQuery() { - AwsProxyHttpServletRequest request = new AwsProxyHttpServletRequest(queryString, mockContext, null); + void parameterMap_generateParameterMap_nullParameter() { + AwsProxyHttpServletRequest request = new AwsProxyHttpServletRequest(queryStringNullValue, mockContext, null, config); + Map paramMap = null; + try { + paramMap = request.generateParameterMap(request.getAwsProxyRequest().getMultiValueQueryStringParameters(), config); + } catch (Exception e) { + e.printStackTrace(); + fail("Could not generate parameter map"); + } - String parsedString = request.generateQueryString(queryString.getQueryStringParameters()); - assertEquals("one=two&three=four", parsedString); + assertArrayEquals(new String[]{"two"}, paramMap.get("one")); + assertArrayEquals(new String[]{null}, paramMap.get("three")); + assertTrue(paramMap.size() == 2); + } - // TODO test url encoding, wrong parameters + @Test + void parameterMap_generateParameterMap_emptyParameter() { + AwsProxyHttpServletRequest request = new AwsProxyHttpServletRequest(queryStringEmptyValue, mockContext, null, config); + Map paramMap = null; + try { + paramMap = request.generateParameterMap(request.getAwsProxyRequest().getMultiValueQueryStringParameters(), config); + } catch (Exception e) { + e.printStackTrace(); + fail("Could not generate parameter map"); + } + + assertArrayEquals(new String[]{"two"}, paramMap.get("one")); + assertArrayEquals(new String[]{""}, paramMap.get("three")); + assertTrue(paramMap.size() == 2); } + + @Test + void parameterMapWithEncodedParams_generateParameterMap_validQuery() { + AwsProxyHttpServletRequest request = new AwsProxyHttpServletRequest(encodedQueryString, mockContext, null, config); + + Map paramMap = null; + try { + paramMap = request.generateParameterMap(request.getAwsProxyRequest().getMultiValueQueryStringParameters(), config); + } catch (Exception e) { + e.printStackTrace(); + fail("Could not generate parameter map"); + } + + assertArrayEquals(new String[]{"two"}, paramMap.get("one")); + assertArrayEquals(new String[]{"{\"name\":\"faisal\"}"}, paramMap.get("json value@1")); + assertTrue(paramMap.size() == 2); + } + + @Test + void parameterMapWithEncodedParams_alb_generateParameterMap_validQuery() { + AwsProxyHttpServletRequest request = new AwsProxyHttpServletRequest(encodedQueryStringAlb, mockContext, null, config); + + Map paramMap = null; + try { + paramMap = request.generateParameterMap(request.getAwsProxyRequest().getMultiValueQueryStringParameters(), config, true); + } catch (Exception e) { + e.printStackTrace(); + fail("Could not generate parameter map"); + } + + assertArrayEquals(new String[]{"two"}, paramMap.get("one")); + assertArrayEquals(new String[]{"{\"name\":\"faisal\"}"}, paramMap.get("json value@1")); + assertTrue(paramMap.size() == 2); + } + + @Test + void parameterMapWithMultipleValues_generateParameterMap_validQuery() { + AwsProxyHttpServletRequest request = new AwsProxyHttpServletRequest(multipleParams, mockContext, null, config); + + Map paramMap = null; + try { + paramMap = request.generateParameterMap(request.getAwsProxyRequest().getMultiValueQueryStringParameters(), config); + } catch (Exception e) { + e.printStackTrace(); + fail("Could not generate parameter map"); + } + assertArrayEquals(new String[]{"two", "three"}, paramMap.get("one")); + assertArrayEquals(new String[]{"{\"name\":\"faisal\"}"}, paramMap.get("json value@1")); + assertTrue(paramMap.size() == 2); + } + + @Test + void parameterMap_generateParameterMap_formEncodedAndQueryString() { + AwsProxyHttpServletRequest request = new AwsProxyHttpServletRequest(formEncodedAndQueryString, mockContext, null, config); + + Map paramMap = null; + try { + paramMap = request.generateParameterMap(request.getAwsProxyRequest().getMultiValueQueryStringParameters(), config); + } catch (Exception e) { + e.printStackTrace(); + fail("Could not generate parameter map"); + } + // Combines form encoded parameters (one=four) with query string (one=two,three) + // The order between them is not officially guaranteed (it could be four,two,three or two,three,four) + // Current implementation gives form encoded parameters first + assertArrayEquals(new String[]{"four", "two", "three"}, paramMap.get("one")); + assertArrayEquals(new String[]{"six"}, paramMap.get("five")); + assertArrayEquals(new String[]{"eight"}, paramMap.get("seven")); + assertTrue(paramMap.size() == 3); + } + + @Test + void parameterMap_generateParameterMap_differentCasing_caseSensitive() { + ContainerConfig caseSensitiveConfig = ContainerConfig.defaultConfig(); + caseSensitiveConfig.setQueryStringCaseSensitive(true); + AwsProxyHttpServletRequest request = new AwsProxyHttpServletRequest(differentCasing, mockContext, null, caseSensitiveConfig); + Map paramMap = null; + try { + paramMap = request.generateParameterMap(request.getAwsProxyRequest().getMultiValueQueryStringParameters(), caseSensitiveConfig); + } catch (Exception e) { + e.printStackTrace(); + fail("Could not generate parameter map"); + } + assertArrayEquals(new String[] {"two", "three"}, paramMap.get("one")); + assertArrayEquals(new String[] {"four"}, paramMap.get("ONE")); + assertTrue(paramMap.size() == 2); + } + + @Test + void parameterMap_generateParameterMap_differentCasing_caseInsensitive() { + ContainerConfig caseInsensitiveConfig = ContainerConfig.defaultConfig(); + caseInsensitiveConfig.setQueryStringCaseSensitive(false); + + AwsProxyHttpServletRequest request = new AwsProxyHttpServletRequest(differentCasing, mockContext, null, caseInsensitiveConfig); + + Map paramMap = null; + try { + paramMap = request.generateParameterMap(request.getAwsProxyRequest().getMultiValueQueryStringParameters(), caseInsensitiveConfig); + } catch (Exception e) { + e.printStackTrace(); + fail("Could not generate parameter map"); + } + // If a parameter is duplicated but with a different casing, it's replaced with only one of them + assertArrayEquals(paramMap.get("one"), paramMap.get("ONE")); + assertTrue(paramMap.size() == 2); + } + + @Test + void queryParamValues_getQueryParamValues() { + AwsProxyHttpServletRequest request = new AwsProxyHttpServletRequest(new AwsProxyRequest(), mockContext, null); + MultiValuedTreeMap map = new MultiValuedTreeMap<>(); + map.add("test", "test"); + map.add("test", "test2"); + String[] result1 = request.getQueryParamValues(map, "test", true); + assertArrayEquals(new String[]{"test", "test2"}, result1); + String[] result2 = request.getQueryParamValues(map, "TEST", true); + assertNull(result2); + } + + @Test + void queryParamValues_getQueryParamValues_nullValue() { + AwsProxyHttpServletRequest request = new AwsProxyHttpServletRequest(new AwsProxyRequest(), mockContext, null); + MultiValuedTreeMap map = new MultiValuedTreeMap<>(); + map.add("test", null); + String[] result1 = request.getQueryParamValues(map, "test", true); + assertArrayEquals(new String[] {null}, result1); + String[] result2 = request.getQueryParamValues(map, "TEST", true); + assertNull(result2); + } + + @Test + void queryParamValues_getQueryParamValues_caseInsensitive() { + AwsProxyHttpServletRequest request = new AwsProxyHttpServletRequest(new AwsProxyRequest(), mockContext, null); + MultiValuedTreeMap map = new MultiValuedTreeMap<>(); + map.add("test", "test"); + map.add("test", "test2"); + String[] result1 = request.getQueryParamValues(map, "test", false); + assertArrayEquals(new String[]{"test", "test2"}, result1); + String[] result2 = request.getQueryParamValues(map, "TEST", false); + assertArrayEquals(new String[]{"test", "test2"}, result2); + } + + @Test + void queryParamValues_getQueryParamValues_multipleCaseInsensitive() { + AwsProxyHttpServletRequest request = new AwsProxyHttpServletRequest(new AwsProxyRequest(), mockContext, null); + + MultiValuedTreeMap map = new MultiValuedTreeMap<>(); + map.add("test", "test"); + map.add("TEST", "test2"); + String[] result1 = request.getQueryParamValues(map, "test", false); + assertArrayEquals(new String[]{"test2"}, result1); + String[] result2 = request.getQueryParamValues(map, "TEST", false); + assertArrayEquals(new String[]{"test2"}, result2); + } + } diff --git a/aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/internal/servlet/AwsHttpServletResponseTest.java b/aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/internal/servlet/AwsHttpServletResponseTest.java new file mode 100644 index 00000000..143caab6 --- /dev/null +++ b/aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/internal/servlet/AwsHttpServletResponseTest.java @@ -0,0 +1,394 @@ +package com.amazonaws.serverless.proxy.internal.servlet; + + +import com.amazonaws.serverless.proxy.model.ContainerConfig; +import com.amazonaws.serverless.proxy.model.Headers; + +import org.junit.jupiter.api.Test; + +import jakarta.servlet.http.Cookie; +import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.core.MediaType; + +import java.io.IOException; +import java.io.PrintWriter; +import java.nio.charset.StandardCharsets; +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.time.Instant; +import java.util.Calendar; +import java.util.Locale; +import java.util.TimeZone; +import java.util.concurrent.CountDownLatch; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.junit.jupiter.api.Assertions.*; + + +public class AwsHttpServletResponseTest { + // we use this int to compare the cookie expiration time in the tests. The date we generate to compare to + // may be slight off compared to the date generated during the request processing + private static final int COOKIE_GRACE_COMPARE_MILLIS = 2000; + private static final String COOKIE_NAME = "session_id"; + private static final String COOKIE_VALUE = "123"; + private static final String COOKIE_PATH = "/api"; + private static final String COOKIE_DOMAIN = "mydomain.com"; + private static final int MAX_AGE_VALUE = 300; + + private static final Pattern MAX_AGE_PATTERN = Pattern.compile("Max-Age=(-?[0-9]+)"); + private static final Pattern EXPIRES_PATTERN = Pattern.compile("Expires=([^;]+)"); + + private static final String CONTENT_TYPE_WITH_CHARSET = "application/json; charset=UTF-8"; + private static final String JAVASCRIPT_CONTENT_TYPE_WITH_CHARSET = "application/javascript; charset=UTF-8"; + + @Test + void cookie_addCookie_verifyPath() { + AwsHttpServletResponse resp = new AwsHttpServletResponse(null, null); + Cookie pathCookie = new Cookie(COOKIE_NAME, COOKIE_VALUE); + pathCookie.setPath(COOKIE_PATH); + + resp.addCookie(pathCookie); + String cookieHeader = resp.getHeader(HttpHeaders.SET_COOKIE); + assertNotNull(cookieHeader); + assertTrue(cookieHeader.contains("Path=" + COOKIE_PATH)); + assertTrue(cookieHeader.contains(COOKIE_NAME + "=" + COOKIE_VALUE)); + } + + @Test + void cookie_addCookie_verifySecure() { + AwsHttpServletResponse resp = new AwsHttpServletResponse(null, null); + Cookie secureCookie = new Cookie(COOKIE_NAME, COOKIE_VALUE); + secureCookie.setSecure(true); + + resp.addCookie(secureCookie); + String cookieHeader = resp.getHeader(HttpHeaders.SET_COOKIE); + assertNotNull(cookieHeader); + assertTrue(cookieHeader.contains("; Secure")); + assertTrue(cookieHeader.contains(COOKIE_NAME + "=" + COOKIE_VALUE)); + } + + @Test + void cookie_addCookie_verifyDomain() { + AwsHttpServletResponse resp = new AwsHttpServletResponse(null, null); + Cookie domainCookie = new Cookie(COOKIE_NAME, COOKIE_VALUE); + domainCookie.setDomain(COOKIE_DOMAIN); + + resp.addCookie(domainCookie); + String cookieHeader = resp.getHeader(HttpHeaders.SET_COOKIE); + assertNotNull(cookieHeader); + assertTrue(cookieHeader.contains("; Domain=" + COOKIE_DOMAIN)); + assertTrue(cookieHeader.contains(COOKIE_NAME + "=" + COOKIE_VALUE)); + } + + @Test + void cookie_addCookie_defaultMaxAgeIsNegative() { + AwsHttpServletResponse resp = new AwsHttpServletResponse(null, null); + Cookie maxAgeCookie = new Cookie(COOKIE_NAME, COOKIE_VALUE); + maxAgeCookie.setDomain(COOKIE_DOMAIN); + + resp.addCookie(maxAgeCookie); + String cookieHeader = resp.getHeader(HttpHeaders.SET_COOKIE); + assertNotNull(cookieHeader); + assertFalse(cookieHeader.contains("Max-Age=")); + assertTrue(cookieHeader.contains(COOKIE_NAME + "=" + COOKIE_VALUE)); + } + + @Test + void cookie_addCookie_positiveMaxAgeIsPresent() { + AwsHttpServletResponse resp = new AwsHttpServletResponse(null, null); + Cookie maxAgeCookie = new Cookie(COOKIE_NAME, COOKIE_VALUE); + maxAgeCookie.setMaxAge(MAX_AGE_VALUE); + + resp.addCookie(maxAgeCookie); + String cookieHeader = resp.getHeader(HttpHeaders.SET_COOKIE); + assertNotNull(cookieHeader); + assertTrue(cookieHeader.contains("; Max-Age=")); + assertTrue(cookieHeader.contains(COOKIE_NAME + "=" + COOKIE_VALUE)); + + int maxAge = getMaxAge(cookieHeader); + assertEquals(MAX_AGE_VALUE, maxAge); + } + + @Test + void cookie_addCookie_positiveMaxAgeExpiresDate() { + AwsHttpServletResponse resp = new AwsHttpServletResponse(null, null); + Cookie maxAgeCookie = new Cookie(COOKIE_NAME, COOKIE_VALUE); + maxAgeCookie.setMaxAge(MAX_AGE_VALUE); + + resp.addCookie(maxAgeCookie); + Calendar testExpiration = Calendar.getInstance(); + testExpiration.add(Calendar.SECOND, MAX_AGE_VALUE); + testExpiration.setTimeZone(TimeZone.getTimeZone(AwsHttpServletResponse.COOKIE_DEFAULT_TIME_ZONE)); + + String cookieHeader = resp.getHeader(HttpHeaders.SET_COOKIE); + assertNotNull(cookieHeader); + assertTrue(cookieHeader.contains("; Max-Age=")); + assertTrue(cookieHeader.contains(COOKIE_NAME + "=" + COOKIE_VALUE)); + + SimpleDateFormat dateFormat = new SimpleDateFormat(AwsHttpServletResponse.HEADER_DATE_PATTERN); + + Calendar expiration = getExpires(cookieHeader); + + long dateDiff = testExpiration.getTimeInMillis() - expiration.getTimeInMillis(); + assertTrue(Math.abs(dateDiff) < COOKIE_GRACE_COMPARE_MILLIS); + } + + @Test + void cookie_addCookieWithoutMaxAge_expectNoExpires() { + AwsHttpServletResponse resp = new AwsHttpServletResponse(null, null); + Cookie simpleCookie = new Cookie(COOKIE_NAME, COOKIE_VALUE); + resp.addCookie(simpleCookie); + + String cookieHeader = resp.getHeader(HttpHeaders.SET_COOKIE); + assertNotNull(cookieHeader); + assertFalse(cookieHeader.contains("Expires")); + } + + @Test + void cookie_addCookieWithMaxAgeZero_expectExpiresInThePast() { + AwsHttpServletResponse resp = new AwsHttpServletResponse(null, null); + Cookie zeroMaxAgeCookie = new Cookie(COOKIE_NAME, COOKIE_VALUE); + zeroMaxAgeCookie.setMaxAge(0); + + resp.addCookie(zeroMaxAgeCookie); + String cookieHeader = resp.getHeader(HttpHeaders.SET_COOKIE); + + Calendar cal = getExpires(cookieHeader); + long currentTimeMillis = System.currentTimeMillis(); + + assertNotNull(cookieHeader); + assertTrue(cal.getTimeInMillis() < currentTimeMillis); + assertTrue(cookieHeader.contains(COOKIE_NAME + "=" + COOKIE_VALUE)); + } + + @Test + void responseHeaders_getAwsResponseHeaders_expectLatestHeader() { + AwsHttpServletResponse resp = new AwsHttpServletResponse(null, null); + resp.addHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON); + resp.addHeader("content-type", "application/xml"); + + Headers awsResp = resp.getAwsResponseHeaders(); + assertEquals(1, awsResp.size()); + assertEquals("application/xml", awsResp.getFirst(HttpHeaders.CONTENT_TYPE)); + } + + @Test + void responseHeaders_setHeaderWithNullValue_expectHeaderRemoved() { + AwsHttpServletResponse resp = new AwsHttpServletResponse(null, null); + resp.setHeader(HttpHeaders.CONTENT_DISPOSITION, "inline"); + resp.setHeader(HttpHeaders.CONTENT_DISPOSITION, null); + + Headers awsResp = resp.getAwsResponseHeaders(); + assertEquals(0, awsResp.size()); + } + + @Test + void responseHeaders_getAwsResponseHeaders_expectedMultpleCookieHeaders() { + AwsHttpServletResponse resp = new AwsHttpServletResponse(null, null); + resp.addCookie(new Cookie(COOKIE_NAME, COOKIE_VALUE)); + resp.addCookie(new Cookie("Second", "test")); + + Headers awsResp = resp.getAwsResponseHeaders(); + assertEquals(1, awsResp.size()); + assertEquals(2, awsResp.get(HttpHeaders.SET_COOKIE).size()); + } + + @Test + void releaseLatch_flushBuffer_expectFlushToWriteAndRelease() { + CountDownLatch respLatch = new CountDownLatch(1); + AwsHttpServletResponse resp = new AwsHttpServletResponse(null, respLatch); + String respBody = "Test resp"; + PrintWriter writer = null; + try { + writer = resp.getWriter(); + PrintWriter finalWriter = writer; + Runnable bodyWriter = () -> { + finalWriter.write(respBody); + try { + resp.flushBuffer(); + } catch (IOException e) { + fail("Could not flush buffer"); + } + }; + + new Thread(bodyWriter).start(); + } catch (IOException e) { + fail("Could not get writer"); + } + + try { + respLatch.await(); + } catch (InterruptedException e) { + e.printStackTrace(); + fail("Response latch interrupted"); + } + + assertEquals(0, respLatch.getCount()); + assertNotNull(writer); + assertEquals(respBody, resp.getAwsResponseBodyString()); + } + + @Test + void dateHeader_addDateHeader_expectMultipleHeaders() { + AwsHttpServletResponse resp = new AwsHttpServletResponse(null, null); + resp.addDateHeader("Date", Instant.now().toEpochMilli()); + resp.addDateHeader("Date", Instant.now().toEpochMilli() - 1000); + + assertEquals(2, resp.getHeaders("Date").size()); + } + + @Test + void dateHeader_setDateHeader_expectSingleHeader() { + AwsHttpServletResponse resp = new AwsHttpServletResponse(null, null); + resp.setDateHeader("Date", Instant.now().toEpochMilli()); + resp.setDateHeader("Date", Instant.now().toEpochMilli() - 1000); + + assertEquals(1, resp.getHeaders("Date").size()); + } + + @Test + void response_reset_expectEmptyHeadersAndBody() { + CountDownLatch respLatch = new CountDownLatch(1); + AwsHttpServletResponse resp = new AwsHttpServletResponse(null, respLatch); + String body = "My Body"; + + resp.addHeader("Test", "test"); + try { + resp.getWriter().write(body); + resp.flushBuffer(); + } catch (IOException e) { + fail("Could not get writer"); + } + + assertEquals(1, resp.getHeaderNames().size()); + assertEquals(body, resp.getAwsResponseBodyString()); + + resp.reset(); + + assertEquals(0, resp.getHeaderNames().size()); + } + + @Test + void headers_setIntHeader_expectSingleHeaderValue() { + AwsHttpServletResponse resp = new AwsHttpServletResponse(null, null); + resp.setIntHeader("Test", 15); + resp.setIntHeader("Test", 34); + + assertEquals(1, resp.getHeaderNames().size()); + assertEquals(1, resp.getHeaders("Test").size()); + assertEquals("34", resp.getHeader("Test")); + } + + @Test + void headers_addIntHeader_expectMultipleHeaderValues() { + AwsHttpServletResponse resp = new AwsHttpServletResponse(null, null); + resp.addIntHeader("Test", 15); + resp.addIntHeader("Test", 34); + + assertEquals(1, resp.getHeaderNames().size()); + assertEquals(2, resp.getHeaders("Test").size()); + assertEquals("15", resp.getHeader("Test")); + } + + @Test + void characterEncoding_setCharacterEncoding() { + AwsHttpServletResponse resp = new AwsHttpServletResponse(null, null); + resp.setContentType("application/json"); + resp.setCharacterEncoding("UTF-8"); + assertNotEquals("UTF-8", resp.getHeader("Content-Encoding")); + assertEquals("application/json; charset=UTF-8", resp.getContentType()); + assertEquals("application/json; charset=UTF-8", resp.getHeader("Content-Type")); + } + + @Test + void characterEncoding_setContentType() { + AwsHttpServletResponse resp = new AwsHttpServletResponse(null, null); + resp.setContentType("application/json; charset=utf-8"); + resp.setCharacterEncoding("UTF-8"); + + assertEquals("application/json; charset=UTF-8", resp.getContentType()); + assertEquals("application/json; charset=UTF-8", resp.getHeader("Content-Type")); + assertEquals("UTF-8", resp.getCharacterEncoding()); + } + + @Test + void characterEncoding_setContentTypeAndsetCharacterEncoding() { + AwsHttpServletResponse resp = new AwsHttpServletResponse(null, null); + resp.setContentType("application/json"); + resp.setCharacterEncoding("UTF-8"); + + assertEquals("application/json; charset=UTF-8", resp.getContentType()); + assertEquals("application/json; charset=UTF-8", resp.getHeader("Content-Type")); + assertEquals("UTF-8", resp.getCharacterEncoding()); + } + + @Test + void characterEncoding_setCharacterEncodingAndsetContentType() { + AwsHttpServletResponse resp = new AwsHttpServletResponse(null, null); + resp.setCharacterEncoding("UTF-8"); + resp.setContentType("application/json"); + + assertEquals("application/json; charset=UTF-8", resp.getContentType()); + assertEquals("application/json; charset=UTF-8", resp.getHeader("Content-Type")); + assertEquals("UTF-8", resp.getCharacterEncoding()); + } + + @Test + void characterEncoding_setCharacterEncodingInContentType_characterEncodingPopulatedCorrectly() { + AwsHttpServletResponse resp = new AwsHttpServletResponse(null, null); + resp.setContentType(CONTENT_TYPE_WITH_CHARSET); + + assertEquals(CONTENT_TYPE_WITH_CHARSET, resp.getContentType()); + assertEquals(CONTENT_TYPE_WITH_CHARSET, resp.getHeader("Content-Type")); + assertEquals("UTF-8", resp.getCharacterEncoding()); + } + + @Test + void characterEncoding_setCharacterEncodingInContentType_overridesDefault() { + AwsHttpServletResponse resp = new AwsHttpServletResponse(null, null); + resp.setCharacterEncoding(ContainerConfig.DEFAULT_CONTENT_CHARSET); + resp.setContentType(CONTENT_TYPE_WITH_CHARSET); + + assertEquals(CONTENT_TYPE_WITH_CHARSET, resp.getContentType()); + assertEquals(CONTENT_TYPE_WITH_CHARSET, resp.getHeader("Content-Type")); + assertEquals("UTF-8", resp.getCharacterEncoding()); + } + + @Test + void characterEncoding_encodingInContentTypeHeader_writesCorrectData() throws IOException { + AwsHttpServletResponse resp = new AwsHttpServletResponse(null, new CountDownLatch(1)); + resp.setHeader("Content-Type", JAVASCRIPT_CONTENT_TYPE_WITH_CHARSET); + resp.getOutputStream().write("ü".getBytes(StandardCharsets.UTF_8)); + resp.flushBuffer(); + + assertEquals(JAVASCRIPT_CONTENT_TYPE_WITH_CHARSET, resp.getContentType()); + assertEquals(JAVASCRIPT_CONTENT_TYPE_WITH_CHARSET, resp.getHeader("Content-Type")); + assertEquals("ü",resp.getAwsResponseBodyString()); + } + + private int getMaxAge(String header) { + Matcher ageMatcher = MAX_AGE_PATTERN.matcher(header); + assertTrue(ageMatcher.find()); + assertTrue(ageMatcher.groupCount() >= 1); + String ageString = ageMatcher.group(1); + return Integer.parseInt(ageString); + } + + private Calendar getExpires(String header) { + Matcher ageMatcher = EXPIRES_PATTERN.matcher(header); + assertTrue(ageMatcher.find()); + assertTrue(ageMatcher.groupCount() >= 1); + String expiresString = ageMatcher.group(1); + SimpleDateFormat sdf = new SimpleDateFormat(AwsHttpServletResponse.HEADER_DATE_PATTERN, Locale.US); + Calendar cal = Calendar.getInstance(); + try { + cal.setTime(sdf.parse(expiresString)); + } catch (ParseException e) { + e.printStackTrace(); + fail("Could not parse expire date"); + } + + return cal; + } +} diff --git a/aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/internal/servlet/AwsHttpSessionTest.java b/aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/internal/servlet/AwsHttpSessionTest.java new file mode 100644 index 00000000..bcca83dd --- /dev/null +++ b/aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/internal/servlet/AwsHttpSessionTest.java @@ -0,0 +1,79 @@ +package com.amazonaws.serverless.proxy.internal.servlet; + +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.util.Enumeration; + +import static org.junit.jupiter.api.Assertions.*; + +public class AwsHttpSessionTest { + + @Test + void new_withNullId_throwsException() { + try { + AwsHttpSession session = new AwsHttpSession(null); + } catch (RuntimeException e) { + assertTrue(e.getMessage().contains("cannot be null")); + return; + } + fail("Did not throw exception with null ID"); + } + + @Test + void new_withValidId_setsIdCorrectly() { + AwsHttpSession session = new AwsHttpSession("id"); + assertEquals("id", session.getId()); + } + + @Test + void new_creationTimePopulatedCorrectly() { + AwsHttpSession session = new AwsHttpSession("id"); + assertTrue(session.getCreationTime() > Instant.now().getEpochSecond() - 1); + assertEquals(AwsHttpSession.SESSION_DURATION_SEC, session.getMaxInactiveInterval()); + assertEquals(session.getLastAccessedTime(), session.getCreationTime()); + } + + @Test + void attributes_dataStoredCorrectly() throws InterruptedException { + AwsHttpSession sess = new AwsHttpSession("id"); + sess.setAttribute("test", "test"); + sess.setAttribute("test2", "test2"); + Enumeration attrs = sess.getAttributeNames(); + int attrsCnt = 0; + while (attrs.hasMoreElements()) { + attrs.nextElement(); + attrsCnt++; + } + assertEquals(2, attrsCnt); + assertEquals("test", sess.getAttribute("test")); + sess.removeAttribute("test2"); + attrs = sess.getAttributeNames(); + attrsCnt = 0; + while (attrs.hasMoreElements()) { + attrs.nextElement(); + attrsCnt++; + } + assertEquals(1, attrsCnt); + + + // changing attribute should touch the session + Thread.sleep(1000); + sess.setAttribute("test3", "test3"); + assertTrue(sess.getLastAccessedTime() > sess.getCreationTime()); + } + + @Test + void validSession_expectCorrectValidationOrInvalidation() throws InterruptedException { + AwsHttpSession sess = new AwsHttpSession("id"); + assertTrue(sess.isValid()); + assertTrue(sess.isNew()); + + Thread.sleep(1000); + sess.setAttribute("test", "test"); + assertFalse(sess.isNew()); + sess.invalidate(); + assertFalse(sess.isValid()); + assertNull(sess.getAttribute("test")); + } +} diff --git a/aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/internal/servlet/AwsProxyHttpServletRequestFormTest.java b/aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/internal/servlet/AwsProxyHttpServletRequestFormTest.java new file mode 100644 index 00000000..67b14762 --- /dev/null +++ b/aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/internal/servlet/AwsProxyHttpServletRequestFormTest.java @@ -0,0 +1,173 @@ +package com.amazonaws.serverless.proxy.internal.servlet; + + +import com.amazonaws.serverless.proxy.model.AwsProxyRequest; +import com.amazonaws.serverless.proxy.internal.testutils.AwsProxyRequestBuilder; + +import jakarta.servlet.http.Part; +import org.apache.commons.io.IOUtils; +import org.apache.hc.client5.http.entity.mime.MultipartPartBuilder; +import org.apache.hc.core5.http.ContentType; +import org.apache.hc.core5.http.HttpEntity; +import org.apache.hc.client5.http.entity.mime.MultipartEntityBuilder; +import org.junit.jupiter.api.Test; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.core.MediaType; + +import java.io.IOException; +import java.nio.charset.Charset; +import java.util.*; + +import static org.junit.jupiter.api.Assertions.*; + + +public class AwsProxyHttpServletRequestFormTest { + private static final String PART_KEY_1 = "test1"; + private static final String PART_VALUE_1 = "value1"; + private static final String PART_KEY_2 = "test2"; + private static final String PART_VALUE_2 = "value2"; + private static final String FILE_KEY = "file_upload_1"; + private static final String FILE_KEY_2 = "file_upload_2"; + private static final String FILE_NAME = "testImage.jpg"; + + private static final String ENCODED_VALUE = "test123a%3D1%262@3"; + + private static final HttpEntity MULTIPART_FORM_DATA = MultipartEntityBuilder.create() + .addTextBody(PART_KEY_1, PART_VALUE_1) + .addTextBody(PART_KEY_2, PART_VALUE_2) + .build(); + private static final int FILE_SIZE = 512; + private static byte[] FILE_BYTES = new byte[FILE_SIZE]; + private static byte[] FILE_BYTES_2 = new byte[FILE_SIZE]; + static { + new Random().nextBytes(FILE_BYTES); + } + private static final HttpEntity MULTIPART_BINARY_DATA = MultipartEntityBuilder.create() + .addTextBody(PART_KEY_1, PART_VALUE_1) + .addTextBody(PART_KEY_2, PART_VALUE_2) + .addBinaryBody(FILE_KEY, FILE_BYTES, ContentType.IMAGE_JPEG, FILE_NAME) + .build(); + private static final HttpEntity MULTIPART_BINARY_DATA_2 = MultipartEntityBuilder.create() + .addBinaryBody(FILE_KEY, FILE_BYTES, ContentType.IMAGE_JPEG, FILE_NAME) + .addBinaryBody(FILE_KEY, FILE_BYTES_2, ContentType.IMAGE_JPEG, FILE_NAME) + .build(); + private static final String ENCODED_FORM_ENTITY = PART_KEY_1 + "=" + ENCODED_VALUE + "&" + PART_KEY_2 + "=" + PART_VALUE_2; + + @Test + void postForm_getParam_getEncodedFullValue() { + try { + AwsProxyRequest proxyRequest = new AwsProxyRequestBuilder("/form", "POST") + .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_FORM_URLENCODED) + .body(ENCODED_FORM_ENTITY) + .build(); + + HttpServletRequest request = new AwsProxyHttpServletRequest(proxyRequest, null, null); + assertNotNull(request.getParts()); + assertEquals("test123a=1&2@3", request.getParameter(PART_KEY_1)); + } catch (IOException | ServletException e) { + fail(e.getMessage()); + } + } + + @Test + void postForm_getParts_parsing() { + try { + AwsProxyRequest proxyRequest = new AwsProxyRequestBuilder("/form", "POST") + .header(HttpHeaders.CONTENT_TYPE, MULTIPART_FORM_DATA.getContentType()) + //.header(formData.getContentEncoding().getName(), formData.getContentEncoding().getValue()) + .body(IOUtils.toString(MULTIPART_FORM_DATA.getContent(), Charset.defaultCharset())) + .build(); + + HttpServletRequest request = new AwsProxyHttpServletRequest(proxyRequest, null, null); + assertNotNull(request.getParts()); + assertEquals(2, request.getParts().size()); + assertEquals(PART_VALUE_1, IOUtils.toString(request.getPart(PART_KEY_1).getInputStream(), Charset.defaultCharset())); + assertEquals(PART_VALUE_2, IOUtils.toString(request.getPart(PART_KEY_2).getInputStream(), Charset.defaultCharset())); + } catch (IOException | ServletException e) { + fail(e.getMessage()); + } + } + + @Test + void multipart_getParts_binary() { + try { + AwsProxyRequest proxyRequest = new AwsProxyRequestBuilder("/form", "POST") + .header(HttpHeaders.CONTENT_TYPE, MULTIPART_BINARY_DATA.getContentType()) + .header(HttpHeaders.CONTENT_LENGTH, MULTIPART_BINARY_DATA.getContentLength() + "") + .binaryBody(MULTIPART_BINARY_DATA.getContent()) + .build(); + + HttpServletRequest request = new AwsProxyHttpServletRequest(proxyRequest, null, null); + assertNotNull(request.getParts()); + assertEquals(3, request.getParts().size()); + assertNotNull(request.getPart(FILE_KEY)); + assertEquals(FILE_SIZE, request.getPart(FILE_KEY).getSize()); + assertEquals(FILE_KEY, request.getPart(FILE_KEY).getName()); + assertEquals(FILE_NAME, request.getPart(FILE_KEY).getSubmittedFileName()); + assertEquals(PART_VALUE_1, IOUtils.toString(request.getPart(PART_KEY_1).getInputStream(), Charset.defaultCharset())); + assertEquals(PART_VALUE_2, IOUtils.toString(request.getPart(PART_KEY_2).getInputStream(), Charset.defaultCharset())); + } catch (IOException | ServletException e) { + fail(e.getMessage()); + } + } + + @Test + void multipart_getParts_returnsMultiplePartsWithSameFieldName() { + try { + AwsProxyRequest proxyRequest = new AwsProxyRequestBuilder("/form", "POST") + .header(HttpHeaders.CONTENT_TYPE, MULTIPART_BINARY_DATA_2.getContentType()) + .header(HttpHeaders.CONTENT_LENGTH, MULTIPART_BINARY_DATA_2.getContentLength() + "") + .binaryBody(MULTIPART_BINARY_DATA_2.getContent()) + .build(); + + HttpServletRequest request = new AwsProxyHttpServletRequest(proxyRequest, null, null); + assertNotNull(request.getParts()); + assertEquals(2, request.getParts().size()); + assertNotNull(request.getPart(FILE_KEY)); + List partList = new ArrayList<>(request.getParts()); + assertEquals(partList.get(0).getSubmittedFileName(), partList.get(1).getSubmittedFileName()); + assertEquals(partList.get(0).getName(), partList.get(1).getName()); + assertEquals(FILE_SIZE, request.getPart(FILE_KEY).getSize()); + assertEquals(FILE_KEY, request.getPart(FILE_KEY).getName()); + assertEquals(FILE_NAME, request.getPart(FILE_KEY).getSubmittedFileName()); + } catch (IOException | ServletException e) { + fail(e.getMessage()); + } + } + + @Test + void postForm_getParamsBase64Encoded_expectAllParams() { + AwsProxyRequest proxyRequest = new AwsProxyRequestBuilder("/form", "POST") + .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_FORM_URLENCODED).build(); + proxyRequest.setBody(Base64.getEncoder().encodeToString(ENCODED_FORM_ENTITY.getBytes(Charset.defaultCharset()))); + proxyRequest.setIsBase64Encoded(true); + + HttpServletRequest request = new AwsProxyHttpServletRequest(proxyRequest, null, null); + Map params = request.getParameterMap(); + assertNotNull(params); + assertEquals(2, params.size()); + assertTrue(params.containsKey(PART_KEY_1)); + assertEquals(2, Collections.list(request.getParameterNames()).size()); + } + + /** + * issue #340 + */ + @Test + void postForm_emptyParamPresent() { + AwsProxyRequest proxyRequest = new AwsProxyRequestBuilder("/form", "POST") + .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_FORM_URLENCODED).build(); + String body = PART_KEY_1 + "=" + "&" + PART_KEY_2 + "=" + PART_VALUE_2; + proxyRequest.setBody(body); + + HttpServletRequest request = new AwsProxyHttpServletRequest(proxyRequest, null, null); + Map params = request.getParameterMap(); + assertNotNull(params); + assertEquals(2, params.size()); + assertTrue(params.containsKey(PART_KEY_1)); + assertEquals(2, Collections.list(request.getParameterNames()).size()); + } +} diff --git a/aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/internal/servlet/AwsProxyHttpServletRequestReaderTest.java b/aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/internal/servlet/AwsProxyHttpServletRequestReaderTest.java index 21878ef1..f92c3716 100644 --- a/aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/internal/servlet/AwsProxyHttpServletRequestReaderTest.java +++ b/aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/internal/servlet/AwsProxyHttpServletRequestReaderTest.java @@ -2,16 +2,17 @@ import com.amazonaws.serverless.exceptions.InvalidRequestEventException; -import com.amazonaws.serverless.proxy.internal.model.AwsProxyRequest; +import com.amazonaws.serverless.proxy.internal.LambdaContainerHandler; +import com.amazonaws.serverless.proxy.model.AwsProxyRequest; +import com.amazonaws.serverless.proxy.model.ContainerConfig; import com.amazonaws.serverless.proxy.internal.testutils.AwsProxyRequestBuilder; -import com.amazonaws.services.lambda.runtime.Context; -import org.junit.Test; +import org.junit.jupiter.api.Test; -import javax.servlet.http.HttpServletRequest; -import javax.ws.rs.core.SecurityContext; -import java.lang.reflect.Method; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.core.SecurityContext; -import static org.junit.Assert.*; +import static org.junit.jupiter.api.Assertions.*; public class AwsProxyHttpServletRequestReaderTest { @@ -19,24 +20,146 @@ public class AwsProxyHttpServletRequestReaderTest { private static final String TEST_HEADER_KEY = "x-test"; private static final String TEST_HEADER_VALUE = "header"; + private static final String ENCODED_REQUEST_PATH = "/foo/bar/Some%20Thing"; + private static final String DECODED_REQUEST_PATH = "/foo/bar/Some Thing"; @Test - public void readRequest_reflection_returnType() throws NoSuchMethodException { - Method readRequestMethod = AwsProxyHttpServletRequestReader.class.getMethod("readRequest", AwsProxyRequest.class, SecurityContext.class, Context.class); + void readRequest_validAwsProxy_populatedRequest() { + AwsProxyRequest request = new AwsProxyRequestBuilder("/path", "GET").header(TEST_HEADER_KEY, TEST_HEADER_VALUE).build(); + try { + HttpServletRequest servletRequest = reader.readRequest(request, null, null, ContainerConfig.defaultConfig()); + assertNotNull(servletRequest.getHeader(TEST_HEADER_KEY)); + assertEquals(TEST_HEADER_VALUE, servletRequest.getHeader(TEST_HEADER_KEY)); + } catch (InvalidRequestEventException e) { + e.printStackTrace(); + fail("Could not read request"); + } + } + + @Test + void readRequest_urlDecode_expectDecodedPath() { + AwsProxyRequest request = new AwsProxyRequestBuilder(ENCODED_REQUEST_PATH, "GET").build(); + try { + HttpServletRequest servletRequest = reader.readRequest(request, null, null, ContainerConfig.defaultConfig()); + assertNotNull(servletRequest); + assertEquals(DECODED_REQUEST_PATH, servletRequest.getPathInfo()); + assertEquals(ENCODED_REQUEST_PATH, servletRequest.getRequestURI()); + } catch (InvalidRequestEventException e) { + e.printStackTrace(); + fail("Could not read request"); + } - assertTrue(readRequestMethod.getReturnType() == AwsProxyHttpServletRequest.class); } @Test - public void readRequest_validAwsProxy_populatedRequest() { - AwsProxyRequest request = new AwsProxyRequestBuilder().header(TEST_HEADER_KEY, TEST_HEADER_VALUE).build(); + void readRequest_contentCharset_doesNotOverrideRequestCharset() { + String requestCharset = "application/json; charset=UTF-8"; + AwsProxyRequest request = new AwsProxyRequestBuilder(ENCODED_REQUEST_PATH, "GET").header(HttpHeaders.CONTENT_TYPE, requestCharset).build(); try { - HttpServletRequest servletRequest = reader.readRequest(request, null, null); - assertNotNull(servletRequest.getHeader(TEST_HEADER_KEY)); - assertEquals(TEST_HEADER_VALUE, servletRequest.getHeader(TEST_HEADER_KEY)); + HttpServletRequest servletRequest = reader.readRequest(request, null, null, ContainerConfig.defaultConfig()); + assertNotNull(servletRequest); + assertNotNull(servletRequest.getHeader(HttpHeaders.CONTENT_TYPE)); + assertEquals(requestCharset, servletRequest.getHeader(HttpHeaders.CONTENT_TYPE)); + assertEquals("UTF-8", servletRequest.getCharacterEncoding()); + } catch (InvalidRequestEventException e) { + e.printStackTrace(); + fail("Could not read request"); + } + } + + @Test + void readRequest_contentCharset_setsDefaultCharsetWhenNotSpecified() { + String requestCharset = "application/json"; + AwsProxyRequest request = new AwsProxyRequestBuilder(ENCODED_REQUEST_PATH, "GET").header(HttpHeaders.CONTENT_TYPE, requestCharset).build(); + try { + HttpServletRequest servletRequest = reader.readRequest(request, null, null, ContainerConfig.defaultConfig()); + assertNotNull(servletRequest); + assertNotNull(servletRequest.getHeader(HttpHeaders.CONTENT_TYPE)); + String contentAndCharset = requestCharset + "; charset=" + LambdaContainerHandler.getContainerConfig().getDefaultContentCharset(); + assertEquals(contentAndCharset, servletRequest.getHeader(HttpHeaders.CONTENT_TYPE)); + assertEquals(LambdaContainerHandler.getContainerConfig().getDefaultContentCharset(), servletRequest.getCharacterEncoding()); + } catch (InvalidRequestEventException e) { + e.printStackTrace(); + fail("Could not read request"); + } + } + + @Test + void readRequest_contentCharset_appendsCharsetToComplextContentType() { + String contentType = "multipart/form-data; boundary=something"; + AwsProxyRequest request = new AwsProxyRequestBuilder(ENCODED_REQUEST_PATH, "GET").header(HttpHeaders.CONTENT_TYPE, contentType).build(); + try { + HttpServletRequest servletRequest = reader.readRequest(request, null, null, ContainerConfig.defaultConfig()); + assertNotNull(servletRequest); + assertNotNull(servletRequest.getHeader(HttpHeaders.CONTENT_TYPE)); + String contentAndCharset = contentType + "; charset=" + LambdaContainerHandler.getContainerConfig().getDefaultContentCharset(); + assertEquals(contentAndCharset, servletRequest.getHeader(HttpHeaders.CONTENT_TYPE)); + assertEquals(LambdaContainerHandler.getContainerConfig().getDefaultContentCharset(), servletRequest.getCharacterEncoding()); } catch (InvalidRequestEventException e) { e.printStackTrace(); fail("Could not read request"); } } + + @Test + void readRequest_validEventEmptyPath_expectException() { + try { + AwsProxyRequest req = new AwsProxyRequestBuilder(null, "GET").build(); + HttpServletRequest servletReq = reader.readRequest(req, null, null, ContainerConfig.defaultConfig()); + assertNotNull(servletReq); + } catch (InvalidRequestEventException e) { + e.printStackTrace(); + fail("Could not read a request with a null path"); + } + } + + @Test + void readRequest_invalidEventEmptyMethod_expectException() { + try { + AwsProxyRequest req = new AwsProxyRequestBuilder("/path", null).build(); + reader.readRequest(req, null, null, ContainerConfig.defaultConfig()); + fail("Expected InvalidRequestEventException"); + } catch (InvalidRequestEventException e) { + assertEquals(AwsProxyHttpServletRequestReader.INVALID_REQUEST_ERROR, e.getMessage()); + } + } + + @Test + void readRequest_invalidEventEmptyContext_expectException() { + try { + AwsProxyRequest req = new AwsProxyRequestBuilder("/path", "GET").build(); + req.setRequestContext(null); + reader.readRequest(req, null, null, ContainerConfig.defaultConfig()); + fail("Expected InvalidRequestEventException"); + } catch (InvalidRequestEventException e) { + assertEquals(AwsProxyHttpServletRequestReader.INVALID_REQUEST_ERROR, e.getMessage()); + } + } + + @Test + void readRequest_nullHeaders_expectSuccess() { + AwsProxyRequest req = new AwsProxyRequestBuilder("/path", "GET").build(); + req.setMultiValueHeaders(null); + try { + HttpServletRequest servletReq = reader.readRequest(req, null, null, ContainerConfig.defaultConfig()); + String headerValue = servletReq.getHeader(HttpHeaders.CONTENT_TYPE); + assertNull(headerValue); + } catch (InvalidRequestEventException e) { + e.printStackTrace(); + fail("Failed to read request with null headers"); + } + } + + @Test + void readRequest_emptyHeaders_expectSuccess() { + AwsProxyRequest req = new AwsProxyRequestBuilder("/path", "GET").build(); + try { + HttpServletRequest servletReq = reader.readRequest(req, null, null, ContainerConfig.defaultConfig()); + String headerValue = servletReq.getHeader(HttpHeaders.CONTENT_TYPE); + assertNull(headerValue); + } catch (InvalidRequestEventException e) { + e.printStackTrace(); + fail("Failed to read request with null headers"); + } + } } diff --git a/aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/internal/servlet/AwsProxyHttpServletRequestTest.java b/aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/internal/servlet/AwsProxyHttpServletRequestTest.java index d9ddc313..736da27b 100644 --- a/aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/internal/servlet/AwsProxyHttpServletRequestTest.java +++ b/aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/internal/servlet/AwsProxyHttpServletRequestTest.java @@ -1,124 +1,227 @@ package com.amazonaws.serverless.proxy.internal.servlet; -import com.amazonaws.serverless.proxy.internal.model.AwsProxyRequest; +import com.amazonaws.serverless.proxy.internal.LambdaContainerHandler; +import com.amazonaws.serverless.proxy.model.AwsProxyRequest; import com.amazonaws.serverless.proxy.internal.testutils.AwsProxyRequestBuilder; -import org.junit.Test; -import javax.servlet.http.HttpServletRequest; -import javax.ws.rs.core.HttpHeaders; -import javax.ws.rs.core.MediaType; +import com.amazonaws.services.lambda.runtime.Context; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; -import java.util.Collections; -import java.util.Enumeration; -import java.util.List; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.SecurityContext; -import static org.junit.Assert.*; +import java.io.IOException; +import java.io.InputStream; +import java.io.UnsupportedEncodingException; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.time.ZonedDateTime; +import java.util.*; + +import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assumptions.assumeFalse; public class AwsProxyHttpServletRequestTest { + private String requestType; + private static final String CUSTOM_HEADER_KEY = "X-Custom-Header"; private static final String CUSTOM_HEADER_VALUE = "Custom-Header-Value"; private static final String FORM_PARAM_NAME = "name"; private static final String FORM_PARAM_NAME_VALUE = "Stef"; private static final String FORM_PARAM_TEST = "test_cookie_param"; - private static final String QUERY_STRING_NAME_VALUE = "Bob"; + private static final String QUERY_STRING_NAME_VALUE = "Bob B!"; + private static final String QUERY_STRING_NAME = "name$"; private static final String REQUEST_SCHEME_HTTP = "http"; + private static final String USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/102.0.5005.61 Safari/537.36"; + private static final String REFERER = "https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/User-Agent/Firefox"; + private static ZonedDateTime REQUEST_DATE = ZonedDateTime.now(); - private static final AwsProxyRequest REQUEST_WITH_HEADERS = new AwsProxyRequestBuilder("/hello", "GET") - .header(CUSTOM_HEADER_KEY, CUSTOM_HEADER_VALUE) - .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON) - .header(AwsProxyHttpServletRequest.CF_PROTOCOL_HEADER_NAME, REQUEST_SCHEME_HTTP) - .build(); - private static final AwsProxyRequest REQUEST_FORM_URLENCODED = new AwsProxyRequestBuilder("/hello", "POST") - .form(FORM_PARAM_NAME, FORM_PARAM_NAME_VALUE).build(); - private static final AwsProxyRequest REQUEST_INVALID_FORM_URLENCODED = new AwsProxyRequestBuilder("/hello", "GET") - .form(FORM_PARAM_NAME, FORM_PARAM_NAME_VALUE).build(); - private static final AwsProxyRequest REQUEST_FORM_URLENCODED_AND_QUERY = new AwsProxyRequestBuilder("/hello", "POST") + private static final AwsProxyRequestBuilder REQUEST_FORM_URLENCODED = new AwsProxyRequestBuilder("/hello", "POST") + .form(FORM_PARAM_NAME, FORM_PARAM_NAME_VALUE); + private static final AwsProxyRequestBuilder REQUEST_INVALID_FORM_URLENCODED = new AwsProxyRequestBuilder("/hello", "GET") + .form(FORM_PARAM_NAME, FORM_PARAM_NAME_VALUE); + private static final AwsProxyRequestBuilder REQUEST_FORM_URLENCODED_AND_QUERY = new AwsProxyRequestBuilder("/hello", "POST") .form(FORM_PARAM_NAME, FORM_PARAM_NAME_VALUE) - .queryString(FORM_PARAM_NAME, QUERY_STRING_NAME_VALUE).build(); - private static final AwsProxyRequest REQUEST_SINGLE_COOKIE = new AwsProxyRequestBuilder("/hello", "GET") - .cookie(FORM_PARAM_NAME, FORM_PARAM_NAME_VALUE).build(); - private static final AwsProxyRequest REQUEST_MULTIPLE_COOKIES = new AwsProxyRequestBuilder("/hello", "GET") + .queryString(FORM_PARAM_NAME, QUERY_STRING_NAME_VALUE); + private static final AwsProxyRequestBuilder REQUEST_SINGLE_COOKIE = new AwsProxyRequestBuilder("/hello", "GET") + .cookie(FORM_PARAM_NAME, FORM_PARAM_NAME_VALUE); + private static final AwsProxyRequestBuilder REQUEST_MULTIPLE_COOKIES = new AwsProxyRequestBuilder("/hello", "GET") .cookie(FORM_PARAM_NAME, FORM_PARAM_NAME_VALUE) - .cookie(FORM_PARAM_TEST, FORM_PARAM_NAME_VALUE).build(); - private static final AwsProxyRequest REQUEST_MALFORMED_COOKIE = new AwsProxyRequestBuilder("/hello", "GET") - .header(HttpHeaders.COOKIE, QUERY_STRING_NAME_VALUE).build(); + .cookie(FORM_PARAM_TEST, FORM_PARAM_NAME_VALUE); + private static final AwsProxyRequestBuilder REQUEST_MALFORMED_COOKIE = new AwsProxyRequestBuilder("/hello", "GET") + .header(HttpHeaders.COOKIE, QUERY_STRING_NAME_VALUE); + private static final AwsProxyRequestBuilder REQUEST_MULTIPLE_FORM_AND_QUERY = new AwsProxyRequestBuilder("/hello", "POST") + .form(FORM_PARAM_NAME, FORM_PARAM_NAME_VALUE) + .queryString(FORM_PARAM_TEST, QUERY_STRING_NAME_VALUE); + private static final AwsProxyRequestBuilder REQUEST_USER_AGENT_REFERER = new AwsProxyRequestBuilder("/hello", "POST") + .userAgent(USER_AGENT) + .referer(REFERER); + private static final AwsProxyRequestBuilder REQUEST_WITH_DATE = new AwsProxyRequestBuilder("/hello", "GET") + .header(HttpHeaders.DATE, AwsHttpServletRequest.dateFormatter.format(REQUEST_DATE)); + private static final AwsProxyRequestBuilder REQUEST_WITH_LOWERCASE_HEADER = new AwsProxyRequestBuilder("/hello", "POST") + .header(HttpHeaders.CONTENT_TYPE.toLowerCase(Locale.getDefault()), MediaType.APPLICATION_JSON); + + private static final AwsProxyRequestBuilder REQUEST_NULL_QUERY_STRING; - private static final AwsProxyRequest REQUEST_NULL_QUERY_STRING; static { AwsProxyRequest awsProxyRequest = new AwsProxyRequestBuilder("/hello", "GET").build(); - awsProxyRequest.setQueryStringParameters(null); - REQUEST_NULL_QUERY_STRING = awsProxyRequest; + awsProxyRequest.setMultiValueQueryStringParameters(null); + REQUEST_NULL_QUERY_STRING = new AwsProxyRequestBuilder(awsProxyRequest); } - private static final AwsProxyRequest REQUEST_QUERY = new AwsProxyRequestBuilder("/hello", "POST") - .queryString(FORM_PARAM_NAME, QUERY_STRING_NAME_VALUE).build(); + private static final AwsProxyRequestBuilder REQUEST_QUERY = new AwsProxyRequestBuilder("/hello", "POST") + .queryString(QUERY_STRING_NAME, QUERY_STRING_NAME_VALUE); + private static final AwsProxyRequestBuilder REQUEST_QUERY_EMPTY_VALUE = new AwsProxyRequestBuilder("/hello", "POST") + .queryString(QUERY_STRING_NAME, ""); + public void initAwsProxyHttpServletRequestTest(String type) { + requestType = type; + } - @Test - public void headers_getHeader_validRequest() { - HttpServletRequest request = new AwsProxyHttpServletRequest(REQUEST_WITH_HEADERS, null, null); + public static Collection data() { + return Arrays.asList(new Object[]{"API_GW", "ALB", "HTTP_API", "WRAP"}); + } + + private HttpServletRequest getRequest(AwsProxyRequestBuilder req, Context lambdaCtx, SecurityContext securityCtx) { + switch (requestType) { + case "API_GW": + return new AwsProxyHttpServletRequest(req.build(), lambdaCtx, securityCtx); + case "ALB": + return new AwsProxyHttpServletRequest(req.alb().build(), lambdaCtx, securityCtx); + case "HTTP_API": + return new AwsHttpApiV2ProxyHttpServletRequest(req.toHttpApiV2Request(), lambdaCtx, securityCtx, LambdaContainerHandler.getContainerConfig()); + case "WRAP": + HttpServletRequest servletRequest = new AwsProxyHttpServletRequest(req.build(), lambdaCtx, securityCtx); + return new AwsHttpServletRequestWrapper(servletRequest, req.build().getPath()); + default: + throw new RuntimeException("Unknown test variant: " + requestType); + } + } + + + @MethodSource("data") + @ParameterizedTest + void headers_getHeader_validRequest(String type) { + initAwsProxyHttpServletRequestTest(type); + HttpServletRequest request = getRequest(getRequestWithHeaders(), null, null); assertNotNull(request.getHeader(CUSTOM_HEADER_KEY)); assertEquals(CUSTOM_HEADER_VALUE, request.getHeader(CUSTOM_HEADER_KEY)); assertEquals(MediaType.APPLICATION_JSON, request.getContentType()); } - @Test - public void formParams_getParameter_validForm() { - HttpServletRequest request = new AwsProxyHttpServletRequest(REQUEST_FORM_URLENCODED, null, null); + @MethodSource("data") + @ParameterizedTest + void headers_getRefererAndUserAgent_returnsContextValues(String type) { + initAwsProxyHttpServletRequestTest(type); + assumeFalse("ALB".equals(requestType)); + HttpServletRequest request = getRequest(REQUEST_USER_AGENT_REFERER, null, null); + assertNotNull(request.getHeader("Referer")); + assertEquals(REFERER, request.getHeader("Referer")); + assertEquals(REFERER, request.getHeader("referer")); + + assertNotNull(request.getHeader("User-Agent")); + assertEquals(USER_AGENT, request.getHeader("User-Agent")); + assertEquals(USER_AGENT, request.getHeader("user-agent")); + } + + @MethodSource("data") + @ParameterizedTest + void formParams_getParameter_validForm(String type) { + initAwsProxyHttpServletRequestTest(type); + HttpServletRequest request = getRequest(REQUEST_FORM_URLENCODED, null, null); assertNotNull(request); assertNotNull(request.getParameter(FORM_PARAM_NAME)); assertEquals(FORM_PARAM_NAME_VALUE, request.getParameter(FORM_PARAM_NAME)); } - @Test - public void formParams_getParameter_null() { - HttpServletRequest request = new AwsProxyHttpServletRequest(REQUEST_INVALID_FORM_URLENCODED, null, null); + @MethodSource("data") + @ParameterizedTest + void formParams_getParameter_null(String type) { + initAwsProxyHttpServletRequestTest(type); + HttpServletRequest request = getRequest(REQUEST_INVALID_FORM_URLENCODED, null, null); assertNotNull(request); assertNull(request.getParameter(FORM_PARAM_NAME)); } - @Test - public void formParams_getParameter_multipleParams() { - HttpServletRequest request = new AwsProxyHttpServletRequest(REQUEST_FORM_URLENCODED_AND_QUERY, null, null); + @MethodSource("data") + @ParameterizedTest + void formParams_getParameter_multipleParams(String type) { + initAwsProxyHttpServletRequestTest(type); + HttpServletRequest request = getRequest(REQUEST_FORM_URLENCODED_AND_QUERY, null, null); assertNotNull(request); assertEquals(2, request.getParameterValues(FORM_PARAM_NAME).length); } - @Test - public void formParams_getParameter_queryStringPrecendence() { - HttpServletRequest request = new AwsProxyHttpServletRequest(REQUEST_FORM_URLENCODED_AND_QUERY, null, null); + @MethodSource("data") + @ParameterizedTest + void formParams_getParameter_queryStringPrecendence(String type) { + initAwsProxyHttpServletRequestTest(type); + HttpServletRequest request = getRequest(REQUEST_FORM_URLENCODED_AND_QUERY, null, null); assertNotNull(request); assertEquals(2, request.getParameterValues(FORM_PARAM_NAME).length); assertEquals(QUERY_STRING_NAME_VALUE, request.getParameter(FORM_PARAM_NAME)); } - @Test - public void scheme_getScheme_https() { - HttpServletRequest request = new AwsProxyHttpServletRequest(REQUEST_FORM_URLENCODED, null, null); + @MethodSource("data") + @ParameterizedTest + void dateHeader_noDate_returnNegativeOne(String type) { + initAwsProxyHttpServletRequestTest(type); + HttpServletRequest request = getRequest(REQUEST_FORM_URLENCODED_AND_QUERY, null, null); + assertNotNull(request); + assertEquals(-1L, request.getDateHeader(HttpHeaders.DATE)); + } + + @MethodSource("data") + @ParameterizedTest + void dateHeader_correctDate_parseToCorrectLong(String type) { + initAwsProxyHttpServletRequestTest(type); + HttpServletRequest request = getRequest(REQUEST_WITH_DATE, null, null); + assertNotNull(request); + + String instantString = AwsHttpServletRequest.dateFormatter.format(REQUEST_DATE); + assertEquals(Instant.from(AwsHttpServletRequest.dateFormatter.parse(instantString)).toEpochMilli(), request.getDateHeader(HttpHeaders.DATE)); + assertEquals(-1L, request.getDateHeader(HttpHeaders.IF_MODIFIED_SINCE)); + } + + @MethodSource("data") + @ParameterizedTest + void scheme_getScheme_https(String type) { + initAwsProxyHttpServletRequestTest(type); + HttpServletRequest request = getRequest(REQUEST_FORM_URLENCODED, null, null); assertNotNull(request); assertNotNull(request.getScheme()); assertEquals("https", request.getScheme()); } - @Test - public void scheme_getScheme_http() { - HttpServletRequest request = new AwsProxyHttpServletRequest(REQUEST_WITH_HEADERS, null, null); + @MethodSource("data") + @ParameterizedTest + void scheme_getScheme_http(String type) { + initAwsProxyHttpServletRequestTest(type); + HttpServletRequest request = getRequest(getRequestWithHeaders(), null, null); assertNotNull(request); assertNotNull(request.getScheme()); assertEquals(REQUEST_SCHEME_HTTP, request.getScheme()); } - @Test - public void cookie_getCookies_noCookies() { - HttpServletRequest request = new AwsProxyHttpServletRequest(REQUEST_WITH_HEADERS, null, null); + @MethodSource("data") + @ParameterizedTest + void cookie_getCookies_noCookies(String type) { + initAwsProxyHttpServletRequestTest(type); + HttpServletRequest request = getRequest(getRequestWithHeaders(), null, null); assertNotNull(request); assertNotNull(request.getCookies()); assertEquals(0, request.getCookies().length); } - @Test - public void cookie_getCookies_singleCookie() { - HttpServletRequest request = new AwsProxyHttpServletRequest(REQUEST_SINGLE_COOKIE, null, null); + @MethodSource("data") + @ParameterizedTest + void cookie_getCookies_singleCookie(String type) { + initAwsProxyHttpServletRequestTest(type); + HttpServletRequest request = getRequest(REQUEST_SINGLE_COOKIE, null, null); assertNotNull(request); assertNotNull(request.getCookies()); assertEquals(1, request.getCookies().length); @@ -126,9 +229,11 @@ public void cookie_getCookies_singleCookie() { assertEquals(FORM_PARAM_NAME_VALUE, request.getCookies()[0].getValue()); } - @Test - public void cookie_getCookies_multipleCookies() { - HttpServletRequest request = new AwsProxyHttpServletRequest(REQUEST_MULTIPLE_COOKIES, null, null); + @MethodSource("data") + @ParameterizedTest + void cookie_getCookies_multipleCookies(String type) { + initAwsProxyHttpServletRequestTest(type); + HttpServletRequest request = getRequest(REQUEST_MULTIPLE_COOKIES, null, null); assertNotNull(request); assertNotNull(request.getCookies()); assertEquals(2, request.getCookies().length); @@ -138,43 +243,443 @@ public void cookie_getCookies_multipleCookies() { assertEquals(FORM_PARAM_NAME_VALUE, request.getCookies()[1].getValue()); } - @Test - public void cookie_getCookies_emptyCookies() { - HttpServletRequest request = new AwsProxyHttpServletRequest(REQUEST_MALFORMED_COOKIE, null, null); + @MethodSource("data") + @ParameterizedTest + void cookie_getCookies_emptyCookies(String type) { + initAwsProxyHttpServletRequestTest(type); + HttpServletRequest request = getRequest(REQUEST_MALFORMED_COOKIE, null, null); assertNotNull(request); assertNotNull(request.getCookies()); assertEquals(0, request.getCookies().length); } - @Test - public void queryParameters_getParameterMap_null() { - HttpServletRequest request = new AwsProxyHttpServletRequest(REQUEST_NULL_QUERY_STRING, null, null); + @MethodSource("data") + @ParameterizedTest + void queryParameters_getParameterMap_null(String type) { + initAwsProxyHttpServletRequestTest(type); + HttpServletRequest request = getRequest(REQUEST_NULL_QUERY_STRING, null, null); assertNotNull(request); assertEquals(0, request.getParameterMap().size()); } - @Test - public void queryParameters_getParameterMap_nonNull() { - HttpServletRequest request = new AwsProxyHttpServletRequest(REQUEST_QUERY, null, null); + @MethodSource("data") + @ParameterizedTest + void queryParameters_getParameterMap_nonNull(String type) { + initAwsProxyHttpServletRequestTest(type); + HttpServletRequest request = getRequest(REQUEST_QUERY, null, null); assertNotNull(request); assertEquals(1, request.getParameterMap().size()); - assertEquals(QUERY_STRING_NAME_VALUE, request.getParameterMap().get(FORM_PARAM_NAME)[0]); + assertEquals(QUERY_STRING_NAME_VALUE, request.getParameterMap().get(QUERY_STRING_NAME)[0]); } - @Test - public void queryParameters_getParameterNames_null() { - HttpServletRequest request = new AwsProxyHttpServletRequest(REQUEST_NULL_QUERY_STRING, null, null); + @MethodSource("data") + @ParameterizedTest + void queryParameters_getParameterMap_nonNull_EmptyParamValue(String type) { + initAwsProxyHttpServletRequestTest(type); + HttpServletRequest request = getRequest(REQUEST_QUERY_EMPTY_VALUE, null, null); + assertNotNull(request); + assertEquals(1, request.getParameterMap().size()); + assertEquals("", request.getParameterMap().get(QUERY_STRING_NAME)[0]); + } + + @MethodSource("data") + @ParameterizedTest + void queryParameters_getParameterNames_null(String type) { + initAwsProxyHttpServletRequestTest(type); + HttpServletRequest request = getRequest(REQUEST_NULL_QUERY_STRING, null, null); List parameterNames = Collections.list(request.getParameterNames()); assertNotNull(request); assertEquals(0, parameterNames.size()); } - @Test - public void queryParameters_getParameterNames_nonNull() { - HttpServletRequest request = new AwsProxyHttpServletRequest(REQUEST_QUERY, null, null); + @MethodSource("data") + @ParameterizedTest + void queryParameters_getParameterNames_notNull(String type) { + initAwsProxyHttpServletRequestTest(type); + HttpServletRequest request = getRequest(REQUEST_QUERY, null, null); List parameterNames = Collections.list(request.getParameterNames()); assertNotNull(request); assertEquals(1, parameterNames.size()); - assertTrue(parameterNames.contains(FORM_PARAM_NAME)); + assertTrue(parameterNames.contains(QUERY_STRING_NAME)); + } + + @MethodSource("data") + @ParameterizedTest + void queryParameter_getParameterMap_avoidDuplicationOnMultipleCalls(String type) { + initAwsProxyHttpServletRequestTest(type); + HttpServletRequest request = getRequest(REQUEST_MULTIPLE_FORM_AND_QUERY, null, null); + + Map params = request.getParameterMap(); + assertNotNull(params); + assertEquals(2, params.size()); + assertNotNull(params.get(FORM_PARAM_NAME)); + assertEquals(1, params.get(FORM_PARAM_NAME).length); + assertNotNull(params.get(FORM_PARAM_TEST)); + assertEquals(1, params.get(FORM_PARAM_TEST).length); + + params = request.getParameterMap(); + assertNotNull(params); + assertEquals(2, params.size()); + assertNotNull(params.get(FORM_PARAM_NAME)); + assertEquals(1, params.get(FORM_PARAM_NAME).length); + assertNotNull(params.get(FORM_PARAM_TEST)); + assertEquals(1, params.get(FORM_PARAM_TEST).length); + } + + @MethodSource("data") + @ParameterizedTest + void charEncoding_getEncoding_expectNoEncodingWithoutContentType(String type) { + initAwsProxyHttpServletRequestTest(type); + HttpServletRequest request = getRequest(REQUEST_SINGLE_COOKIE, null, null); + try { + request.setCharacterEncoding(StandardCharsets.UTF_8.name()); + // we have not specified a content type so the encoding will not be set + assertNull(request.getCharacterEncoding()); + assertNull(request.getContentType()); + } catch (UnsupportedEncodingException e) { + e.printStackTrace(); + fail("Unsupported encoding"); + + } + } + + @MethodSource("data") + @ParameterizedTest + void charEncoding_getEncoding_expectContentTypeOnly(String type) { + initAwsProxyHttpServletRequestTest(type); + HttpServletRequest request = getRequest(getRequestWithHeaders(), null, null); + // we have not specified a content type so the encoding will not be set + assertNull(request.getCharacterEncoding()); + assertEquals(MediaType.APPLICATION_JSON, request.getContentType()); + try { + request.setCharacterEncoding(StandardCharsets.UTF_8.name()); + String newHeaderValue = MediaType.APPLICATION_JSON + "; charset=" + StandardCharsets.UTF_8.name(); + assertEquals(newHeaderValue, request.getHeader(HttpHeaders.CONTENT_TYPE)); + assertEquals(newHeaderValue, request.getContentType()); + assertEquals(StandardCharsets.UTF_8.name(), request.getCharacterEncoding()); + } catch (UnsupportedEncodingException e) { + e.printStackTrace(); + fail("Unsupported encoding"); + } + } + + @MethodSource("data") + @ParameterizedTest + void charEncoding_addCharEncodingTwice_expectSingleMediaTypeAndEncoding(String type) { + initAwsProxyHttpServletRequestTest(type); + HttpServletRequest request = getRequest(getRequestWithHeaders(), null, null); + // we have not specified a content type so the encoding will not be set + assertNull(request.getCharacterEncoding()); + assertEquals(MediaType.APPLICATION_JSON, request.getContentType()); + + try { + request.setCharacterEncoding(StandardCharsets.UTF_8.name()); + String newHeaderValue = MediaType.APPLICATION_JSON + "; charset=" + StandardCharsets.UTF_8.name(); + assertEquals(newHeaderValue, request.getHeader(HttpHeaders.CONTENT_TYPE)); + assertEquals(newHeaderValue, request.getContentType()); + assertEquals(StandardCharsets.UTF_8.name(), request.getCharacterEncoding()); + + + request.setCharacterEncoding(StandardCharsets.ISO_8859_1.name()); + newHeaderValue = MediaType.APPLICATION_JSON + "; charset=" + StandardCharsets.ISO_8859_1.name(); + assertEquals(newHeaderValue, request.getHeader(HttpHeaders.CONTENT_TYPE)); + assertEquals(newHeaderValue, request.getContentType()); + assertEquals(StandardCharsets.ISO_8859_1.name(), request.getCharacterEncoding()); + } catch (UnsupportedEncodingException e) { + e.printStackTrace(); + fail("Unsupported encoding"); + } + } + + @MethodSource("data") + @ParameterizedTest + void contentType_lowerCaseHeaderKey_expectUpdatedMediaType(String type) { + initAwsProxyHttpServletRequestTest(type); + HttpServletRequest request = getRequest(REQUEST_WITH_LOWERCASE_HEADER, null, null); + try { + request.setCharacterEncoding(StandardCharsets.UTF_8.name()); + String newHeaderValue = MediaType.APPLICATION_JSON + "; charset=" + StandardCharsets.UTF_8.name(); + assertEquals(newHeaderValue, request.getHeader(HttpHeaders.CONTENT_TYPE)); + assertEquals(newHeaderValue, request.getContentType()); + assertEquals(StandardCharsets.UTF_8.name(), request.getCharacterEncoding()); + } catch (UnsupportedEncodingException e) { + e.printStackTrace(); + fail("Unsupported encoding"); + } + } + + @MethodSource("data") + @ParameterizedTest + void contentType_duplicateCase_expectSingleContentTypeHeader(String type) { + initAwsProxyHttpServletRequestTest(type); + AwsProxyRequestBuilder proxyRequest = getRequestWithHeaders(); + HttpServletRequest request = getRequest(proxyRequest, null, null); + + try { + request.setCharacterEncoding(StandardCharsets.ISO_8859_1.name()); + assertNotNull(request.getHeader(HttpHeaders.CONTENT_TYPE)); + assertNotNull(request.getHeader(HttpHeaders.CONTENT_TYPE.toLowerCase(Locale.getDefault()))); + } catch (UnsupportedEncodingException e) { + e.printStackTrace(); + fail("Unsupported encoding"); + } + } + + @MethodSource("data") + @ParameterizedTest + void requestURL_getUrl_expectHttpSchemaAndLocalhostForLocalTesting(String type) { + initAwsProxyHttpServletRequestTest(type); + assumeFalse("ALB".equals(requestType)); + AwsProxyRequestBuilder req = getRequestWithHeaders(); + req.apiId("test-id"); + LambdaContainerHandler.getContainerConfig().enableLocalhost(); + HttpServletRequest servletRequest = getRequest(req, null, null); + String requestUrl = servletRequest.getRequestURL().toString(); + assertTrue(requestUrl.contains("http://")); + assertTrue(requestUrl.contains("test-id.execute-api.")); + assertTrue(requestUrl.endsWith(".com/hello")); + + // set localhost + req.header("Host", "localhost"); + servletRequest = getRequest(req, null, null); + requestUrl = servletRequest.getRequestURL().toString(); + assertTrue(requestUrl.contains("http://localhost")); + assertTrue(requestUrl.endsWith("localhost/hello")); + LambdaContainerHandler.getContainerConfig().getCustomDomainNames().remove("localhost"); + } + + @MethodSource("data") + @ParameterizedTest + void requestURL_getUrlWithCustomBasePath_expectCustomBasePath(String type) { + initAwsProxyHttpServletRequestTest(type); + AwsProxyRequestBuilder req = getRequestWithHeaders(); + LambdaContainerHandler.getContainerConfig().setServiceBasePath("test"); + HttpServletRequest servletRequest = getRequest(req, null, null); + String requestUrl = servletRequest.getRequestURL().toString(); + assertTrue(requestUrl.contains("/test/hello")); + LambdaContainerHandler.getContainerConfig().setServiceBasePath(null); + } + + @MethodSource("data") + @ParameterizedTest + void requestURL_getUrlWithContextPath_expectStageAsContextPath(String type) { + initAwsProxyHttpServletRequestTest(type); + assumeFalse("ALB".equals(requestType)); + AwsProxyRequestBuilder req = getRequestWithHeaders(); + req.stage("test-stage"); + LambdaContainerHandler.getContainerConfig().setUseStageAsServletContext(true); + HttpServletRequest servletRequest = getRequest(req, null, null); + String requestUrl = servletRequest.getRequestURL().toString(); + assertTrue(requestUrl.contains("/test-stage/")); + LambdaContainerHandler.getContainerConfig().setUseStageAsServletContext(false); + } + + @MethodSource("data") + @ParameterizedTest + void getLocales_emptyAcceptHeader_expectDefaultLocale(String type) { + initAwsProxyHttpServletRequestTest(type); + AwsProxyRequestBuilder req = getRequestWithHeaders(); + HttpServletRequest servletRequest = getRequest(req, null, null); + Enumeration locales = servletRequest.getLocales(); + int localesNo = 0; + while (locales.hasMoreElements()) { + Locale defaultLocale = locales.nextElement(); + assertEquals(Locale.getDefault(), defaultLocale); + localesNo++; + } + assertEquals(1, localesNo); + } + + @MethodSource("data") + @ParameterizedTest + void getLocales_validAcceptHeader_expectSingleLocale(String type) { + initAwsProxyHttpServletRequestTest(type); + AwsProxyRequestBuilder req = getRequestWithHeaders(); + req.header(HttpHeaders.ACCEPT_LANGUAGE, "fr-CH"); + HttpServletRequest servletRequest = getRequest(req, null, null); + Enumeration locales = servletRequest.getLocales(); + int localesNo = 0; + while (locales.hasMoreElements()) { + Locale defaultLocale = locales.nextElement(); + assertEquals(new Locale("fr", "CH"), defaultLocale); + localesNo++; + } + assertEquals(1, localesNo); + } + + @MethodSource("data") + @ParameterizedTest + void getLocales_validAcceptHeaderMultipleLocales_expectFullLocaleList(String type) { + initAwsProxyHttpServletRequestTest(type); + AwsProxyRequestBuilder req = getRequestWithHeaders(); + req.header(HttpHeaders.ACCEPT_LANGUAGE, "fr-CA, fr;q=0.9, en;q=0.8, de;q=0.7, *;q=0.5"); + HttpServletRequest servletRequest = getRequest(req, null, null); + Enumeration locales = servletRequest.getLocales(); + List localesList = new ArrayList<>(); + while (locales.hasMoreElements()) { + localesList.add(locales.nextElement()); + } + assertEquals(5, localesList.size()); + assertEquals(Locale.CANADA_FRENCH, localesList.get(0)); + assertEquals(Locale.FRENCH, localesList.get(1)); + assertEquals(Locale.ENGLISH, localesList.get(2)); + assertEquals(new Locale("de"), localesList.get(3)); + assertEquals(new Locale("*"), localesList.get(4)); + + assertNotNull(servletRequest.getLocale()); + assertEquals(Locale.CANADA_FRENCH, servletRequest.getLocale()); + } + + @MethodSource("data") + @ParameterizedTest + void getLocales_validAcceptHeaderMultipleLocales_expectFullLocaleListOrdered(String type) { + initAwsProxyHttpServletRequestTest(type); + AwsProxyRequestBuilder req = getRequestWithHeaders(); + req.header(HttpHeaders.ACCEPT_LANGUAGE, "fr-CA, en;q=0.8, de;q=0.7, *;q=0.5, fr;q=0.9"); + HttpServletRequest servletRequest = getRequest(req, null, null); + Enumeration locales = servletRequest.getLocales(); + List localesList = new ArrayList<>(); + while (locales.hasMoreElements()) { + localesList.add(locales.nextElement()); + } + assertEquals(5, localesList.size()); + assertEquals(Locale.CANADA_FRENCH, localesList.get(0)); + assertEquals(Locale.FRENCH, localesList.get(1)); + assertEquals(Locale.ENGLISH, localesList.get(2)); + assertEquals(new Locale("de"), localesList.get(3)); + assertEquals(new Locale("*"), localesList.get(4)); + } + + @MethodSource("data") + @ParameterizedTest + void nullQueryString_expectNoExceptions(String type) { + initAwsProxyHttpServletRequestTest(type); + AwsProxyRequestBuilder req = new AwsProxyRequestBuilder("/hello", "GET"); + HttpServletRequest servletReq = getRequest(req, null, null); + assertNull(servletReq.getQueryString()); + assertEquals(0, servletReq.getParameterMap().size()); + assertFalse(servletReq.getParameterNames().hasMoreElements()); + assertNull(servletReq.getParameter("param")); + assertNull(servletReq.getParameterValues("param")); + } + + @MethodSource("data") + @ParameterizedTest + void inputStream_emptyBody_expectNullInputStream(String type) { + initAwsProxyHttpServletRequestTest(type); + AwsProxyRequestBuilder proxyReq = getRequestWithHeaders(); + assertNull(proxyReq.build().getBody()); + HttpServletRequest req = getRequest(proxyReq, null, null); + + try { + InputStream is = req.getInputStream(); + assertTrue(is.getClass() == AwsServletInputStream.class); + assertEquals(0, is.available()); + } catch (IOException e) { + fail("Could not get input stream"); + } + } + + @MethodSource("data") + @ParameterizedTest + void getHeaders_emptyHeaders_expectEmptyEnumeration(String type) { + initAwsProxyHttpServletRequestTest(type); + AwsProxyRequestBuilder proxyReq = new AwsProxyRequestBuilder("/hello", "GET"); + HttpServletRequest req = getRequest(proxyReq, null, null); + assertFalse(req.getHeaders("param").hasMoreElements()); + } + + @MethodSource("data") + @ParameterizedTest + void getServerPort_defaultPort_expect443(String type) { + initAwsProxyHttpServletRequestTest(type); + HttpServletRequest req = getRequest(getRequestWithHeaders(), null, null); + assertEquals(443, req.getServerPort()); + } + + @MethodSource("data") + @ParameterizedTest + void getServerPort_customPortFromHeader_expectCustomPort(String type) { + initAwsProxyHttpServletRequestTest(type); + AwsProxyRequestBuilder proxyReq = getRequestWithHeaders(); + proxyReq.header(AwsProxyHttpServletRequest.PORT_HEADER_NAME, "80"); + HttpServletRequest req = getRequest(proxyReq, null, null); + assertEquals(80, req.getServerPort()); + } + + @MethodSource("data") + @ParameterizedTest + void getServerPort_invalidCustomPortFromHeader_expectDefaultPort(String type) { + initAwsProxyHttpServletRequestTest(type); + AwsProxyRequestBuilder proxyReq = getRequestWithHeaders(); + proxyReq.header(AwsProxyHttpServletRequest.PORT_HEADER_NAME, "7200"); + HttpServletRequest req = getRequest(proxyReq, null, null); + assertEquals(443, req.getServerPort()); + } + + @MethodSource("data") + @ParameterizedTest + void serverName_emptyHeaders_doesNotThrowNullPointer(String type) { + initAwsProxyHttpServletRequestTest(type); + AwsProxyRequestBuilder proxyReq = new AwsProxyRequestBuilder("/test", "GET"); + proxyReq.multiValueHeaders(null); + HttpServletRequest servletReq = getRequest(proxyReq, null, null); + String serverName = servletReq.getServerName(); + assertTrue(serverName.startsWith("null.execute-api")); + } + + @MethodSource("data") + @ParameterizedTest + void serverName_hostHeader_returnsHostHeaderOnly(String type) { + initAwsProxyHttpServletRequestTest(type); + AwsProxyRequestBuilder proxyReq = new AwsProxyRequestBuilder("/test", "GET") + .header(HttpHeaders.HOST, "testapi.com"); + LambdaContainerHandler.getContainerConfig().addCustomDomain("testapi.com"); + HttpServletRequest servletReq = getRequest(proxyReq, null, null); + String serverName = servletReq.getServerName(); + assertEquals("testapi.com", serverName); + } + + @Test + void serverName_albHostHeader_returnsHostHeader() { + initAwsProxyHttpServletRequestTest("ALB"); + AwsProxyRequestBuilder proxyReq = new AwsProxyRequestBuilder("/test", "GET") + .header(HttpHeaders.HOST, "testapi.us-east-1.elb.amazonaws.com"); + HttpServletRequest servletReq = getRequest(proxyReq, null, null); + String serverName = servletReq.getServerName(); + assertEquals("testapi.us-east-1.elb.amazonaws.com", serverName); + } + + @Test + void getRemoteHost_albHostHeader_singleValue_returnsHostHeader() { + initAwsProxyHttpServletRequestTest("ALB"); + AwsProxyRequest proxyReq = new AwsProxyRequestBuilder("/test", "GET") + .alb().build(); + proxyReq.setMultiValueHeaders(null); + proxyReq.getHeaders().put(HttpHeaders.HOST, "testapi.us-east-1.elb.amazonaws.com"); + HttpServletRequest servletRequest = new AwsProxyHttpServletRequest(proxyReq, null, null); + + String host = servletRequest.getRemoteHost(); + assertEquals("testapi.us-east-1.elb.amazonaws.com", host); + } + + @Test + void getRemoteHost_albHostHeader_multiValue_returnsHostHeader() { + initAwsProxyHttpServletRequestTest("ALB"); + AwsProxyRequest proxyReq = new AwsProxyRequestBuilder("/test", "GET") + .header(HttpHeaders.HOST, "testapi.us-east-1.elb.amazonaws.com") + .alb().build(); + proxyReq.setHeaders(null); + HttpServletRequest servletRequest = new AwsProxyHttpServletRequest(proxyReq, null, null); + + String host = servletRequest.getRemoteHost(); + assertEquals("testapi.us-east-1.elb.amazonaws.com", host); + } + + private AwsProxyRequestBuilder getRequestWithHeaders() { + return new AwsProxyRequestBuilder("/hello", "GET") + .header(CUSTOM_HEADER_KEY, CUSTOM_HEADER_VALUE) + .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON) + .header(AwsProxyHttpServletRequest.CF_PROTOCOL_HEADER_NAME, REQUEST_SCHEME_HTTP); } } diff --git a/aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/internal/servlet/AwsProxyRequestDispatcherTest.java b/aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/internal/servlet/AwsProxyRequestDispatcherTest.java new file mode 100644 index 00000000..254ce04a --- /dev/null +++ b/aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/internal/servlet/AwsProxyRequestDispatcherTest.java @@ -0,0 +1,215 @@ +package com.amazonaws.serverless.proxy.internal.servlet; + +import com.amazonaws.serverless.exceptions.ContainerInitializationException; +import com.amazonaws.serverless.exceptions.InvalidRequestEventException; +import com.amazonaws.serverless.proxy.AwsProxyExceptionHandler; +import com.amazonaws.serverless.proxy.AwsProxySecurityContextWriter; +import com.amazonaws.serverless.proxy.internal.testutils.AwsProxyRequestBuilder; +import com.amazonaws.serverless.proxy.internal.testutils.MockLambdaContext; +import com.amazonaws.serverless.proxy.model.AwsProxyRequest; +import com.amazonaws.serverless.proxy.model.AwsProxyResponse; +import com.amazonaws.serverless.proxy.model.ContainerConfig; +import com.amazonaws.services.lambda.runtime.Context; +import org.junit.jupiter.api.Test; +import org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestWrapper; + +import jakarta.servlet.Servlet; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.nio.charset.Charset; +import java.util.concurrent.CountDownLatch; + +import static org.junit.jupiter.api.Assertions.*; + +public class AwsProxyRequestDispatcherTest { + public static final String FORWARD_PATH = "/newpath"; + static AwsProxyHttpServletRequestReader requestReader = new AwsProxyHttpServletRequestReader(); + + + @Test + void setPath_forwardByPath_proxyRequestObjectInPropertyReferencesSameProxyRequest() throws InvalidRequestEventException { + AwsProxyRequest proxyRequest = new AwsProxyRequestBuilder("/hello", "GET").build(); + HttpServletRequest servletRequest = requestReader.readRequest(proxyRequest, null, new MockLambdaContext(), ContainerConfig.defaultConfig()); + + AwsProxyRequestDispatcher dispatcher = new AwsProxyRequestDispatcher(FORWARD_PATH, false, null); + dispatcher.setRequestPath(servletRequest, FORWARD_PATH); + assertEquals(FORWARD_PATH, servletRequest.getRequestURI()); + } + + @Test + void setPathForWrappedRequest_forwardByPath_proxyRequestObjectInPropertyReferencesSameProxyRequest() throws InvalidRequestEventException { + AwsProxyRequest proxyRequest = new AwsProxyRequestBuilder("/hello", "GET").build(); + HttpServletRequest servletRequest = requestReader.readRequest(proxyRequest, null, new MockLambdaContext(), ContainerConfig.defaultConfig()); + SecurityContextHolderAwareRequestWrapper springSecurityRequest = new SecurityContextHolderAwareRequestWrapper(servletRequest, "ADMIN"); + + AwsProxyRequestDispatcher dispatcher = new AwsProxyRequestDispatcher(FORWARD_PATH, false, null); + dispatcher.setRequestPath(springSecurityRequest, FORWARD_PATH); + assertEquals(FORWARD_PATH, springSecurityRequest.getRequestURI()); + } + + @Test + void setPathForWrappedRequestWithoutGatewayEvent_forwardByPath_throwsException() { + AwsProxyRequest proxyRequest = new AwsProxyRequestBuilder("/hello", "GET").build(); + AwsProxyHttpServletRequest servletRequest = new AwsProxyHttpServletRequest(proxyRequest, new MockLambdaContext(), null); + SecurityContextHolderAwareRequestWrapper springSecurityRequest = new SecurityContextHolderAwareRequestWrapper(servletRequest, "ADMIN"); + + AwsProxyRequestDispatcher dispatcher = new AwsProxyRequestDispatcher(FORWARD_PATH, false, null); + try { + dispatcher.setRequestPath(springSecurityRequest, FORWARD_PATH); + } catch (Exception e) { + assertTrue(e instanceof IllegalStateException); + return; + } + fail(); + } + + @Test + void forwardRequest_nullHandler_throwsIllegalStateException() throws InvalidRequestEventException { + AwsProxyRequest proxyRequest = new AwsProxyRequestBuilder("/hello", "GET").build(); + HttpServletRequest servletRequest = requestReader.readRequest(proxyRequest, null, new MockLambdaContext(), ContainerConfig.defaultConfig()); + AwsProxyRequestDispatcher dispatcher = new AwsProxyRequestDispatcher(FORWARD_PATH, false, null); + try { + dispatcher.forward(servletRequest, new AwsHttpServletResponse(servletRequest, new CountDownLatch(1))); + } catch (ServletException e) { + fail("Unexpected ServletException"); + } catch (IOException e) { + fail("Unexpected IOException"); + } catch (Exception e) { + assertTrue(e instanceof IllegalStateException); + return; + } + fail(); + } + + @Test + void forwardRequest_committedResponse_throwsIllegalStateException() throws InvalidRequestEventException { + AwsProxyRequest proxyRequest = new AwsProxyRequestBuilder("/hello", "GET").build(); + HttpServletRequest servletRequest = requestReader.readRequest(proxyRequest, null, new MockLambdaContext(), ContainerConfig.defaultConfig()); + AwsProxyRequestDispatcher dispatcher = new AwsProxyRequestDispatcher(FORWARD_PATH, false, mockLambdaHandler(null)); + AwsHttpServletResponse resp = new AwsHttpServletResponse(servletRequest, new CountDownLatch(1)); + + try { + resp.flushBuffer(); + dispatcher.forward(servletRequest, resp); + } catch (ServletException e) { + fail("Unexpected ServletException"); + } catch (IOException e) { + fail("Unexpected IOException"); + } catch (Exception e) { + assertTrue(e instanceof IllegalStateException); + return; + } + fail(); + } + + @Test + void forwardRequest_partiallyWrittenResponse_resetsBuffer() throws InvalidRequestEventException { + AwsProxyRequest proxyRequest = new AwsProxyRequestBuilder("/hello", "GET").build(); + HttpServletRequest servletRequest = requestReader.readRequest(proxyRequest, null, new MockLambdaContext(), ContainerConfig.defaultConfig()); + AwsProxyRequestDispatcher dispatcher = new AwsProxyRequestDispatcher(FORWARD_PATH, false, mockLambdaHandler(null)); + AwsHttpServletResponse resp = new AwsHttpServletResponse(servletRequest, new CountDownLatch(1)); + + try { + resp.getOutputStream().write("this is a test write".getBytes()); + assertEquals("this is a test write", new String(resp.getAwsResponseBodyBytes(), Charset.defaultCharset())); + dispatcher.forward(servletRequest, resp); + assertEquals(0, resp.getAwsResponseBodyBytes().length); + + } catch (ServletException e) { + fail("Unexpected ServletException"); + } catch (IOException e) { + fail("Unexpected IOException"); + } + } + + @Test + void include_addsToResponse_appendsCorrectly() throws InvalidRequestEventException, IOException { + final String firstPart = "first"; + final String secondPart = "second"; + AwsProxyRequest proxyRequest = new AwsProxyRequestBuilder("/hello", "GET").build(); + + AwsProxyResponse resp = mockLambdaHandler((AwsProxyHttpServletRequest req, AwsHttpServletResponse res) -> { + if (req.getAttribute("cnt") == null) { + res.getOutputStream().write(firstPart.getBytes()); + req.setAttribute("cnt", 1); + req.getRequestDispatcher("/includer").include(req, res); + res.setStatus(200); + res.flushBuffer(); + } else { + res.getOutputStream().write(secondPart.getBytes()); + } + }).proxy(proxyRequest, new MockLambdaContext()); + assertEquals(firstPart + secondPart, resp.getBody()); + } + + @Test + void include_appendsNewHeader_cannotAppendNewHeaders() throws InvalidRequestEventException, IOException { + final String firstPart = "first"; + final String secondPart = "second"; + final String headerKey = "X-Custom-Header"; + AwsProxyRequest proxyRequest = new AwsProxyRequestBuilder("/hello", "GET").build(); + + AwsProxyResponse resp = mockLambdaHandler((AwsProxyHttpServletRequest req, AwsHttpServletResponse res) -> { + if (req.getAttribute("cnt") == null) { + res.getOutputStream().write(firstPart.getBytes()); + req.setAttribute("cnt", 1); + req.getRequestDispatcher("/includer").include(req, res); + res.setStatus(200); + res.flushBuffer(); + } else { + res.getOutputStream().write(secondPart.getBytes()); + res.addHeader(headerKey, "value"); + } + }).proxy(proxyRequest, new MockLambdaContext()); + assertEquals(firstPart + secondPart, resp.getBody()); + assertFalse(resp.getMultiValueHeaders().containsKey(headerKey)); + } + + private interface RequestHandler { + void handleRequest(AwsProxyHttpServletRequest req, AwsHttpServletResponse resp) throws ServletException, IOException; + } + + + private AwsLambdaServletContainerHandler mockLambdaHandler(RequestHandler h) { + return new AwsLambdaServletContainerHandler( + AwsProxyRequest.class, + AwsProxyResponse.class, + new AwsProxyHttpServletRequestReader(), + new AwsProxyHttpServletResponseWriter(), + new AwsProxySecurityContextWriter(), + new AwsProxyExceptionHandler() + ) { + + @Override + protected void doFilter(HttpServletRequest request, HttpServletResponse response, Servlet servlet) throws IOException, ServletException { + if (h != null) { + h.handleRequest((AwsProxyHttpServletRequest)request, (AwsHttpServletResponse)response); + } + } + + @Override + protected AwsHttpServletResponse getContainerResponse(HttpServletRequest request, CountDownLatch latch) { + return new AwsHttpServletResponse(request, latch); + } + + @Override + protected void handleRequest(HttpServletRequest containerRequest, AwsHttpServletResponse containerResponse, Context lambdaContext) throws Exception { + if (h != null) { + + setServletContext(new AwsServletContext(this)); + ((AwsHttpServletRequest)containerRequest).setServletContext(getServletContext()); + + h.handleRequest((AwsProxyHttpServletRequest)containerRequest, containerResponse); + } + containerResponse.flushBuffer(); + } + + @Override + public void initialize() throws ContainerInitializationException { + + } + }; + } +} diff --git a/aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/internal/servlet/AwsServletContextTest.java b/aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/internal/servlet/AwsServletContextTest.java new file mode 100644 index 00000000..58d0b054 --- /dev/null +++ b/aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/internal/servlet/AwsServletContextTest.java @@ -0,0 +1,299 @@ +package com.amazonaws.serverless.proxy.internal.servlet; + +import com.amazonaws.serverless.proxy.internal.LambdaContainerHandler; +import com.amazonaws.serverless.proxy.internal.servlet.filters.UrlPathValidator; + +import com.amazonaws.serverless.proxy.internal.testutils.AwsProxyRequestBuilder; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; + +import jakarta.servlet.*; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import java.io.File; +import java.io.IOException; +import java.util.concurrent.CountDownLatch; + +import static org.junit.jupiter.api.Assertions.*; + +public class AwsServletContextTest { + private static String TMP_DIR = System.getProperty("java.io.tmpdir"); + private static final AwsServletContext STATIC_CTX = new AwsServletContext(null); + + @BeforeAll + public static void setUp() { + LambdaContainerHandler.getContainerConfig().addValidFilePath("/private/var/task"); + File tmpFile = new File(TMP_DIR); + try { + LambdaContainerHandler.getContainerConfig().addValidFilePath(tmpFile.getCanonicalPath()); + } catch (IOException e) { + e.printStackTrace(); + fail("Could not add tmp dir to valid paths"); + } + LambdaContainerHandler.getContainerConfig().addValidFilePath("C:\\MyTestFolder"); + } + + @Test + @Disabled + void getMimeType_disabledPath_expectException() { + AwsServletContext ctx = new AwsServletContext(null); + try { + assertNull(ctx.getMimeType("/usr/local/lib/nothing")); + } catch (IllegalArgumentException e) { + assertTrue(e.getMessage().startsWith("File path not allowed")); + } catch (Exception e) { + e.printStackTrace(); + fail("Unrecognized exception"); + } + } + + @Test + void getMimeType_nonExistentFileInTaskPath_expectNull() { + AwsServletContext ctx = new AwsServletContext(null); + assertNull(ctx.getMimeType("/var/task/nothing")); + } + + @Test + void getMimeType_mimeTypeOfCorrectFile_expectMime() { + String tmpFilePath = TMP_DIR + "test_text.txt"; + AwsServletContext ctx = new AwsServletContext(null); + String mimeType = ctx.getMimeType(tmpFilePath); + assertEquals("text/plain", mimeType); + + mimeType = ctx.getMimeType("file://" + tmpFilePath); + assertEquals("text/plain", mimeType); + } + @Test + void getMimeType_mimeTypeOfJavascript_expectApplicationJavascript() { + String tmpFilePath = TMP_DIR + "some.js"; + AwsServletContext ctx = new AwsServletContext(null); + String mimeType = ctx.getMimeType(tmpFilePath); + assertEquals("text/javascript", mimeType); + } + + @Test + void getMimeType_unknownExtension_expectNull() { + AwsServletContext ctx = new AwsServletContext(null); + String mimeType = ctx.getMimeType("myfile.unkext"); + assertNull(mimeType); + } + + + @Test + void addFilter_nonExistentFilterClass_expectException() { + AwsServletContext ctx = new AwsServletContext(null); + String filterClass = "com.amazonaws.serverless.TestingFilterClassNonExistent"; + try { + ctx.addFilter("filter", filterClass); + } catch (IllegalStateException e) { + assertTrue(e.getMessage().startsWith("Filter class " + filterClass)); + return; + } + fail("Expected IllegalStateException"); + } + + @Test + void addFilter_doesNotImplementFilter_expectException() { + AwsServletContext ctx = new AwsServletContext(null); + try { + ctx.addFilter("filter", this.getClass().getName()); + } catch (IllegalArgumentException e) { + assertTrue(e.getMessage().startsWith(this.getClass().getName() + " does not implement Filter")); + return; + } + fail("Expected IllegalArgumentException"); + } + + @Test + void addFilter_validFilter_expectSuccess() { + AwsServletContext ctx = new AwsServletContext(null); + FilterRegistration.Dynamic reg = ctx.addFilter("filter", UrlPathValidator.class.getName()); + assertNotNull(reg); + assertNotNull(ctx.getFilterHolders()); + assertEquals(1, ctx.getFilterHolders().size()); + // uses annotated filter name + assertEquals(reg, ctx.getFilterRegistration("UrlPathValidator")); + + assertNotNull(ctx.getFilterRegistrations()); + assertEquals(1, ctx.getFilterRegistrations().size()); + } + + @Test + void addFilter_validFilter_expectSuccessWithCustomFilterName() { + AwsServletContext ctx = new AwsServletContext(null); + FilterRegistration.Dynamic reg = ctx.addFilter("filter", TestFilter.class.getName()); + assertNotNull(reg); + assertNotNull(ctx.getFilterHolders()); + assertEquals(1, ctx.getFilterHolders().size()); + // uses annotated filter name + assertEquals(reg, ctx.getFilterRegistration("filter")); + + assertNotNull(ctx.getFilterRegistrations()); + assertEquals(1, ctx.getFilterRegistrations().size()); + } + + @Test + void getContextPath_expectEmpty() { + assertEquals("", STATIC_CTX.getContextPath()); + } + + @Test + void getContext_returnsSameContext() { + assertEquals(STATIC_CTX, STATIC_CTX.getContext("1")); + assertEquals(STATIC_CTX, STATIC_CTX.getContext("2")); + } + + @Test + void getVersions_expectStaticVersions() { + assertEquals(AwsServletContext.SERVLET_API_MAJOR_VERSION, STATIC_CTX.getMajorVersion()); + assertEquals(AwsServletContext.SERVLET_API_MINOR_VERSION, STATIC_CTX.getMinorVersion()); + assertEquals(AwsServletContext.SERVLET_API_MAJOR_VERSION, STATIC_CTX.getEffectiveMajorVersion()); + assertEquals(AwsServletContext.SERVLET_API_MINOR_VERSION, STATIC_CTX.getEffectiveMinorVersion()); + } + + @Test + void startAsync_expectPopulatedAsyncContext() { + HttpServletRequest req = new AwsProxyHttpServletRequest( + new AwsProxyRequestBuilder("/", "GET").build(), + null, + null + ); + assertNotNull(req); + AsyncContext ctx = req.startAsync(); + assertNotNull(ctx); + assertEquals(req, ctx.getRequest()); + } + + @Test + void startAsyncWithNewRequest_expectPopulatedAsyncContext() { + HttpServletRequest req = new AwsProxyHttpServletRequest( + new AwsProxyRequestBuilder("/", "GET").build(), + null, + null + ); + assertNotNull(req); + HttpServletRequest newReq = new AwsHttpServletRequestWrapper(req, "/new"); + HttpServletResponse newResp = new AwsHttpServletResponse(newReq, new CountDownLatch(1)); + AsyncContext ctx = req.startAsync(newReq, newResp); + assertNotNull(ctx); + assertNotNull(req.getAsyncContext()); + assertNotNull(newReq.getAsyncContext()); + assertEquals(newReq, ctx.getRequest()); + assertEquals(newResp, ctx.getResponse()); + } + + @Test + void unsupportedOperations_expectExceptions() { + int exCount = 0; + try { + STATIC_CTX.getResourcePaths("1"); + } catch (UnsupportedOperationException e) { + exCount++; + } + assertEquals(1, exCount); + + assertNull(STATIC_CTX.getServletRegistration("1")); + } + + @Test + void servletMappings_expectCorrectServlet() { + AwsServletContext ctx = new AwsServletContext(null); + TestServlet srv1 = new TestServlet("srv1"); + TestServlet srv2 = new TestServlet("srv2"); + + ServletRegistration.Dynamic reg1 = ctx.addServlet("srv1", srv1); + ServletRegistration.Dynamic reg2 = ctx.addServlet("srv2", srv2); + + reg1.addMapping("/srv1"); + reg2.addMapping("/srv2"); + + assertEquals(srv1, ctx.getServletForPath("/srv1/hello")); + assertEquals(srv1, ctx.getServletForPath("/srv1/hello/test")); + assertEquals(srv2, ctx.getServletForPath("/srv2")); + assertEquals(srv2, ctx.getServletForPath("/srv2/hello")); + assertNull(ctx.getServletForPath("/srv3")); + assertNull(ctx.getServletForPath("")); + + reg2.addMapping("/"); + assertEquals(srv2, ctx.getServletForPath("/srv3")); + } + + @Test + void addServlet_callsDefaultConstructor() throws ServletException { + AwsServletContext ctx = new AwsServletContext(null); + ctx.addServlet("srv1", TestServlet.class); + assertNotNull(((AwsServletRegistration) ctx.getServletRegistration("srv1")).getServlet()); + assertEquals("", ((TestServlet)((AwsServletRegistration) ctx.getServletRegistration("srv1")).getServlet()).getId()); + } + + @Test + void getNamedDispatcher_returnsDispatcher() { + AwsServletContext ctx = new AwsServletContext(null); + assertNotNull(ctx.getNamedDispatcher("/hello")); + } + + public static class TestServlet implements Servlet { + private String id; + + public TestServlet() { + this(""); + } + + public TestServlet(String identifier) { + id = identifier; + } + + @Override + public void init(ServletConfig servletConfig) throws ServletException { + + } + + @Override + public ServletConfig getServletConfig() { + return null; + } + + @Override + public void service(ServletRequest servletRequest, ServletResponse servletResponse) throws ServletException, IOException { + + } + + @Override + public String getServletInfo() { + return null; + } + + @Override + public void destroy() { + + } + + public String getId() { + return id; + } + } + + public static class TestFilter implements Filter { + + @Override + public void init(FilterConfig filterConfig) + throws ServletException { + + } + + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) + throws IOException, ServletException { + + } + + + @Override + public void destroy() { + + } + } +} diff --git a/aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/internal/servlet/AwsServletRegistrationTest.java b/aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/internal/servlet/AwsServletRegistrationTest.java new file mode 100644 index 00000000..80bb3806 --- /dev/null +++ b/aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/internal/servlet/AwsServletRegistrationTest.java @@ -0,0 +1,109 @@ +package com.amazonaws.serverless.proxy.internal.servlet; + +import org.junit.jupiter.api.Test; + +import jakarta.servlet.*; + +import java.io.IOException; +import java.util.Enumeration; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; + +public class AwsServletRegistrationTest { + + @Test + void getMappings_singleMapping_savedCorrectly() { + ServletRegistration.Dynamic reg = new AwsServletRegistration("test", null, new AwsServletContext(null)); + reg.addMapping("/"); + assertEquals(1, reg.getMappings().size()); + Set invalidMappings = reg.addMapping("/"); + assertEquals(1, invalidMappings.size()); + assertEquals("/", invalidMappings.toArray(new String[]{})[0]); + reg.addMapping("/hello", "/world"); + assertEquals(3, reg.getMappings().size()); + } + + @Test + void metadata_savedAndReturnedCorrectly() { + ServletRegistration.Dynamic reg = new AwsServletRegistration("test", null, new AwsServletContext(null)); + assertEquals("test", reg.getName()); + reg.setLoadOnStartup(2); + assertEquals(2, ((AwsServletRegistration)reg).getLoadOnStartup()); + assertNull(reg.getRunAsRole()); + reg.setRunAsRole("role"); + assertEquals("role", reg.getRunAsRole()); + reg.setAsyncSupported(true); + assertTrue(((AwsServletRegistration)reg).isAsyncSupported()); + } + + @Test + void setInitParameter_savedCorrectly() { + ServletRegistration.Dynamic reg = new AwsServletRegistration("test", null, new AwsServletContext(null)); + assertTrue(reg.setInitParameter("param", "value")); + assertFalse(reg.setInitParameter("param", "value")); + Map params = new HashMap<>(); + params.put("param2", "value2"); + params.put("param", "value"); + Set invalidParams = reg.setInitParameters(params); + assertEquals(1, invalidParams.size()); + assertEquals("param", invalidParams.toArray(new String[]{})[0]); + assertEquals(2, reg.getInitParameters().size()); + assertEquals("value2", reg.getInitParameter("param2")); + } + + @Test + void servletConfig_populatesConfig() throws ServletException { + AwsServletContext servletCtx = new AwsServletContext(null); + TestServlet servlet = new TestServlet(); + ServletRegistration.Dynamic reg = new AwsServletRegistration("test", servlet, servletCtx); + assertEquals(servlet, ((AwsServletRegistration)reg).getServlet()); + Map params = new HashMap<>(); + params.put("param2", "value2"); + params.put("param", "value"); + Set invalidParams = reg.setInitParameters(params); + assertEquals(0, invalidParams.size()); + ServletConfig config = ((AwsServletRegistration)reg).getServletConfig(); + assertNotNull(config); + assertEquals("test", config.getServletName()); + assertEquals(servletCtx, config.getServletContext()); + int paramCnt = 0; + Enumeration paramNames = config.getInitParameterNames(); + while (paramNames.hasMoreElements()) { + paramNames.nextElement(); + paramCnt++; + } + assertEquals(2, paramCnt); + + } + + private class TestServlet implements Servlet { + + @Override + public void init(ServletConfig servletConfig) throws ServletException { + + } + + @Override + public ServletConfig getServletConfig() { + return null; + } + + @Override + public void service(ServletRequest servletRequest, ServletResponse servletResponse) throws ServletException, IOException { + + } + + @Override + public String getServletInfo() { + return null; + } + + @Override + public void destroy() { + + } + } +} diff --git a/aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/internal/servlet/FilterHolderTest.java b/aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/internal/servlet/FilterHolderTest.java index 31461d06..1180a5d1 100644 --- a/aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/internal/servlet/FilterHolderTest.java +++ b/aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/internal/servlet/FilterHolderTest.java @@ -3,16 +3,16 @@ import com.amazonaws.serverless.proxy.internal.servlet.filters.UrlPathValidator; import com.amazonaws.serverless.proxy.internal.testutils.MockLambdaContext; import com.amazonaws.services.lambda.runtime.Context; -import org.junit.Test; +import org.junit.jupiter.api.Test; -import static org.junit.Assert.*; +import static org.junit.jupiter.api.Assertions.*; public class FilterHolderTest { private static Context lambdaContext = new MockLambdaContext(); @Test - public void annotation_filterRegistration_pathValidator() { - FilterHolder holder = new FilterHolder(new UrlPathValidator(), AwsServletContext.getInstance(lambdaContext)); + void annotation_filterRegistration_pathValidator() { + FilterHolder holder = new FilterHolder(new UrlPathValidator(), new AwsServletContext(null)); assertTrue(holder.isAnnotated()); assertNotEquals(UrlPathValidator.class.getName(), holder.getRegistration().getName()); diff --git a/aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/internal/servlet/ServletLambdaContainerHandlerBuilderTest.java b/aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/internal/servlet/ServletLambdaContainerHandlerBuilderTest.java new file mode 100644 index 00000000..73f8d790 --- /dev/null +++ b/aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/internal/servlet/ServletLambdaContainerHandlerBuilderTest.java @@ -0,0 +1,107 @@ +package com.amazonaws.serverless.proxy.internal.servlet; + +import com.amazonaws.serverless.exceptions.ContainerInitializationException; +import com.amazonaws.serverless.proxy.AwsProxyExceptionHandler; +import com.amazonaws.serverless.proxy.AwsProxySecurityContextWriter; +import com.amazonaws.serverless.proxy.model.AwsProxyRequest; +import com.amazonaws.serverless.proxy.model.AwsProxyResponse; +import com.amazonaws.services.lambda.runtime.Context; +import org.junit.jupiter.api.Test; + +import jakarta.servlet.http.HttpServletRequest; +import java.util.concurrent.CountDownLatch; + +import static org.junit.jupiter.api.Assertions.*; + +public class ServletLambdaContainerHandlerBuilderTest { + + @Test + void validation_throwsException() { + TestBuilder testBuilder = new TestBuilder(); + try { + testBuilder.validate(); + } catch (ContainerInitializationException e) { + return; + } + fail("Did not throw exception"); + } + + @Test + void additionalMethod_testSetter() { + TestBuilder test = new TestBuilder().exceptionHandler(new AwsProxyExceptionHandler()).name("test"); + assertEquals("test", test.getName()); + } + + @Test + void defaultProxy_setsValuesCorrectly() { + TestBuilder test = new TestBuilder().defaultProxy().name("test"); + assertNotNull(test.initializationWrapper); + assertTrue(test.exceptionHandler instanceof AwsProxyExceptionHandler); + assertTrue(test.requestReader instanceof AwsProxyHttpServletRequestReader); + assertTrue(test.responseWriter instanceof AwsProxyHttpServletResponseWriter); + assertTrue(test.securityContextWriter instanceof AwsProxySecurityContextWriter); + assertSame(AwsProxyRequest.class, test.requestTypeClass); + assertSame(AwsProxyResponse.class, test.responseTypeClass); + assertEquals("test", test.name); + } + + public static final class TestHandler extends AwsLambdaServletContainerHandler { + + public TestHandler() { + super(AwsProxyRequest.class, AwsProxyResponse.class, new AwsProxyHttpServletRequestReader(), new AwsProxyHttpServletResponseWriter(), new AwsProxySecurityContextWriter(), new AwsProxyExceptionHandler()); + } + @Override + protected AwsHttpServletResponse getContainerResponse(HttpServletRequest request, CountDownLatch latch) { + return null; + } + + @Override + protected void handleRequest(HttpServletRequest containerRequest, AwsHttpServletResponse containerResponse, Context lambdaContext) throws Exception { + + } + + @Override + public void initialize() throws ContainerInitializationException { + + } + } + + public static final class TestBuilder + extends ServletLambdaContainerHandlerBuilder< + AwsProxyRequest, + AwsProxyResponse, + HttpServletRequest, + TestHandler, + TestBuilder> { + + public TestBuilder() { + super(); + } + + private String name; + + public TestBuilder name(String n) { + name = n; + return this; + } + + public String getName() { + return name; + } + + @Override + protected TestBuilder self() { + return this; + } + + @Override + public TestHandler build() throws ContainerInitializationException { + return null; + } + + @Override + public TestHandler buildAndInitialize() throws ContainerInitializationException { + return null; + } + } +} diff --git a/aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/internal/servlet/filters/UrlPathValidatorTest.java b/aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/internal/servlet/filters/UrlPathValidatorTest.java new file mode 100644 index 00000000..fa6712c7 --- /dev/null +++ b/aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/internal/servlet/filters/UrlPathValidatorTest.java @@ -0,0 +1,117 @@ +package com.amazonaws.serverless.proxy.internal.servlet.filters; + +import com.amazonaws.serverless.proxy.internal.servlet.AwsHttpServletResponse; +import com.amazonaws.serverless.proxy.internal.servlet.AwsProxyHttpServletRequest; +import com.amazonaws.serverless.proxy.internal.testutils.AwsProxyRequestBuilder; +import org.junit.jupiter.api.Test; + +import jakarta.servlet.FilterConfig; +import jakarta.servlet.ServletContext; +import jakarta.servlet.ServletException; + +import java.util.Collections; +import java.util.Enumeration; +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.fail; + +public class UrlPathValidatorTest { + @Test + void init_noConfig_setsDefaultStatusCode() { + UrlPathValidator pathValidator = new UrlPathValidator(); + try { + pathValidator.init(null); + assertEquals(UrlPathValidator.DEFAULT_ERROR_CODE, pathValidator.getInvalidStatusCode()); + } catch (ServletException e) { + e.printStackTrace(); + fail("Unexpected ServletException"); + } + } + + @Test + void init_withConfig_setsCorrectStatusCode() { + UrlPathValidator pathValidator = new UrlPathValidator(); + Map params = new HashMap<>(); + params.put(UrlPathValidator.PARAM_INVALID_STATUS_CODE, "401"); + FilterConfig cnf = mockFilterConfig(params); + try { + pathValidator.init(cnf); + assertEquals(401, pathValidator.getInvalidStatusCode()); + } catch (ServletException e) { + e.printStackTrace(); + fail("Unexpected ServletException"); + } + } + + @Test + void init_withWrongConfig_setsDefaultStatusCode() { + UrlPathValidator pathValidator = new UrlPathValidator(); + Map params = new HashMap<>(); + params.put(UrlPathValidator.PARAM_INVALID_STATUS_CODE, "hello"); + FilterConfig cnf = mockFilterConfig(params); + try { + pathValidator.init(cnf); + assertEquals(UrlPathValidator.DEFAULT_ERROR_CODE, pathValidator.getInvalidStatusCode()); + } catch (ServletException e) { + e.printStackTrace(); + fail("Unexpected ServletException"); + } + } + + @Test + void doFilter_invalidRelativePathUri_setsDefaultStatusCode() { + AwsProxyHttpServletRequest req = new AwsProxyHttpServletRequest(new AwsProxyRequestBuilder("../..", "GET").build(), null, null); + AwsHttpServletResponse resp = new AwsHttpServletResponse(req, null); + UrlPathValidator pathValidator = new UrlPathValidator(); + try { + pathValidator.init(null); + pathValidator.doFilter(req, resp, null); + assertEquals(UrlPathValidator.DEFAULT_ERROR_CODE, resp.getStatus()); + } catch (Exception e) { + e.printStackTrace(); + fail("Unexpected exception"); + } + } + + @Test + void doFilter_invalidUri_setsDefaultStatusCode() { + AwsProxyHttpServletRequest req = new AwsProxyHttpServletRequest(new AwsProxyRequestBuilder("wonkyprotocol://˝Ó#\u0009", "GET").build(), null, null); + AwsHttpServletResponse resp = new AwsHttpServletResponse(req, null); + UrlPathValidator pathValidator = new UrlPathValidator(); + try { + pathValidator.init(null); + pathValidator.doFilter(req, resp, null); + assertEquals(UrlPathValidator.DEFAULT_ERROR_CODE, resp.getStatus()); + } catch (Exception e) { + e.printStackTrace(); + fail("Unexpected exception"); + } + } + + + private FilterConfig mockFilterConfig(Map initParams) { + return new FilterConfig() { + @Override + public String getFilterName() { + return null; + } + + @Override + public ServletContext getServletContext() { + return null; + } + + @Override + public String getInitParameter(String s) { + return initParams.get(s); + } + + @Override + public Enumeration getInitParameterNames() { + return Collections.enumeration(initParams.keySet()); + } + }; + } +} diff --git a/aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/internal/testutils/AwsProxyRequestBuilder.java b/aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/internal/testutils/AwsProxyRequestBuilder.java new file mode 100644 index 00000000..a817bd2b --- /dev/null +++ b/aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/internal/testutils/AwsProxyRequestBuilder.java @@ -0,0 +1,545 @@ +/* + * Copyright 2016 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance + * with the License. A copy of the License is located at + * + * http://aws.amazon.com/apache2.0/ + * + * or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES + * OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions + * and limitations under the License. + */ +package com.amazonaws.serverless.proxy.internal.testutils; + +import com.amazonaws.serverless.proxy.internal.LambdaContainerHandler; +import com.amazonaws.serverless.proxy.model.*; + +import tools.jackson.core.JacksonException; +import tools.jackson.databind.ObjectMapper; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import org.apache.commons.io.IOUtils; +import org.apache.hc.core5.http.ContentType; +import org.apache.hc.core5.http.HttpEntity; +import org.apache.hc.client5.http.entity.mime.MultipartEntityBuilder; +import org.apache.hc.client5.http.entity.mime.ByteArrayBody; +import org.apache.hc.client5.http.entity.mime.StringBody; + +import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.core.MediaType; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.UnsupportedEncodingException; +import java.net.URLEncoder; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.*; + + +/** + * Request builder object. This is used by unit proxy to quickly create an AWS_PROXY request object + */ +public class AwsProxyRequestBuilder { + + //------------------------------------------------------------- + // Variables - Private + //------------------------------------------------------------- + + private AwsProxyRequest request; + private MultipartEntityBuilder multipartBuilder; + + //------------------------------------------------------------- + // Constructors + //------------------------------------------------------------- + + public AwsProxyRequestBuilder() { + this(null, null); + } + + + public AwsProxyRequestBuilder(String path) { + this(path, null); + } + + public AwsProxyRequestBuilder(AwsProxyRequest req) { + request = req; + } + + + public AwsProxyRequestBuilder(String path, String httpMethod) { + this.request = new AwsProxyRequest(); + this.request.setMultiValueHeaders(new Headers());// avoid NPE + this.request.setHeaders(new SingleValueHeaders()); + this.request.setHttpMethod(httpMethod); + this.request.setPath(path); + this.request.setMultiValueQueryStringParameters(new MultiValuedTreeMap<>()); + this.request.setRequestContext(new AwsProxyRequestContext()); + this.request.getRequestContext().setRequestId(UUID.randomUUID().toString()); + this.request.getRequestContext().setExtendedRequestId(UUID.randomUUID().toString()); + this.request.getRequestContext().setStage("test"); + this.request.getRequestContext().setProtocol("HTTP/1.1"); + this.request.getRequestContext().setRequestTimeEpoch(System.currentTimeMillis()); + ApiGatewayRequestIdentity identity = new ApiGatewayRequestIdentity(); + identity.setSourceIp("127.0.0.1"); + this.request.getRequestContext().setIdentity(identity); + } + + //------------------------------------------------------------- + // Methods - Public + //------------------------------------------------------------- + + public AwsProxyRequestBuilder alb() { + /* + * This method sets up the requestContext to look like an ALB request and also + * re-encodes URL query params, since ALBs do not decode them. This now returns + * a new AwsProxyRequestBuilder with the new query param state, so the original + * builder maintains the original configured state and can be then be reused in + * further unit tests. For now the simplest way to accomplish a deep copy is by + * serializing to JSON then deserializing. + */ + + ObjectMapper objectMapper = new ObjectMapper(); + AwsProxyRequest albRequest = null; + try { + String json = objectMapper.writeValueAsString(this.request); + albRequest = objectMapper.readValue(json, AwsProxyRequest.class); + } catch (JacksonException jpe) { + throw new RuntimeException(jpe); + } + + if (albRequest.getRequestContext() == null) { + albRequest.setRequestContext(new AwsProxyRequestContext()); + } + albRequest.getRequestContext().setElb(new AlbContext()); + albRequest.getRequestContext().getElb().setTargetGroupArn( + "arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/lambda-target/d6190d154bc908a5" + ); + + // ALB does not decode query string parameters so we re-encode them all + if (albRequest.getMultiValueQueryStringParameters() != null) { + MultiValuedTreeMap newQs = new MultiValuedTreeMap<>(); + for (Map.Entry> e : albRequest.getMultiValueQueryStringParameters().entrySet()) { + for (String v : e.getValue()) { + try { + // this is a terrible hack. In our Spring tests we use the comma as a control character for lists + // this is allowed by the HTTP specs although not recommended. + String key = URLEncoder.encode(e.getKey(), "UTF-8").replaceAll("%2C", ","); + String value = URLEncoder.encode(v, "UTF-8").replaceAll("%2C", ","); + newQs.add(key, value); + } catch (UnsupportedEncodingException ex) { + throw new RuntimeException("Could not encode query string parameters: " + e.getKey() + "=" + v, ex); + } + } + } + albRequest.setMultiValueQueryStringParameters(newQs); + } + return new AwsProxyRequestBuilder(albRequest); + } + + public AwsProxyRequestBuilder stage(String stageName) { + this.request.getRequestContext().setStage(stageName); + return this; + } + + public AwsProxyRequestBuilder method(String httpMethod) { + this.request.setHttpMethod(httpMethod); + return this; + } + + + public AwsProxyRequestBuilder path(String path) { + this.request.setPath(path); + return this; + } + + + public AwsProxyRequestBuilder json() { + return this.header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON); + } + + + public AwsProxyRequestBuilder form(String key, String value) { + if (key == null || value == null) { + throw new IllegalArgumentException("form() does not support null key or value"); + } + if (request.getMultiValueHeaders() == null) { + request.setMultiValueHeaders(new Headers()); + } + request.getMultiValueHeaders().add(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_FORM_URLENCODED); + String body = request.getBody(); + if (body == null) { + body = ""; + } + // URL-encode key and value to form expected body of a form post + try { + body += (body.equals("") ? "" : "&") + URLEncoder.encode(key, "UTF-8") + "=" + URLEncoder.encode(value, "UTF-8"); + } catch (UnsupportedEncodingException ex) { + throw new RuntimeException("Could not encode form parameter: " + key + "=" + value, ex); + } + request.setBody(body); + return this; + } + + public AwsProxyRequestBuilder formFilePart(String fieldName, String fileName, byte[] content) throws IOException { + if (multipartBuilder == null) { + multipartBuilder = MultipartEntityBuilder.create(); + } + multipartBuilder.addPart(fieldName, new ByteArrayBody(content, fileName)); + buildMultipartBody(); + return this; + } + + public AwsProxyRequestBuilder formTextFieldPart(String fieldName, String fieldValue) + throws IOException { + if (request.getMultiValueHeaders() == null) { + request.setMultiValueHeaders(new Headers()); + } + if (multipartBuilder == null) { + multipartBuilder = MultipartEntityBuilder.create(); + } + multipartBuilder.addPart(fieldName, new StringBody(fieldValue, ContentType.TEXT_PLAIN)); + buildMultipartBody(); + return this; + } + + private void buildMultipartBody() + throws IOException { + HttpEntity bodyEntity = multipartBuilder.build(); + InputStream bodyStream = bodyEntity.getContent(); + byte[] buffer = new byte[bodyStream.available()]; + IOUtils.readFully(bodyStream, buffer); + byte[] finalBuffer = new byte[buffer.length + 1]; + byte[] newLineBytes = "\n\n".getBytes(LambdaContainerHandler.getContainerConfig().getDefaultContentCharset()); + System.arraycopy(newLineBytes, 0, finalBuffer, 0, newLineBytes.length); + System.arraycopy(buffer, 0, finalBuffer, newLineBytes.length - 1, buffer.length); + request.setBody(Base64.getMimeEncoder().encodeToString(finalBuffer)); + request.setIsBase64Encoded(true); + this.request.setMultiValueHeaders(new Headers()); + header(HttpHeaders.CONTENT_TYPE, bodyEntity.getContentType()); + header(HttpHeaders.CONTENT_LENGTH, bodyEntity.getContentLength() + ""); + } + + + public AwsProxyRequestBuilder header(String key, String value) { + if (this.request.getMultiValueHeaders() == null) { + this.request.setMultiValueHeaders(new Headers()); + } + + this.request.getMultiValueHeaders().add(key, value); + return this; + } + + public AwsProxyRequestBuilder multiValueHeaders(Headers h) { + this.request.setMultiValueHeaders(h); + return this; + } + + public AwsProxyRequestBuilder multiValueQueryString(MultiValuedTreeMap params) { + this.request.setMultiValueQueryStringParameters(params); + return this; + } + + public AwsProxyRequestBuilder queryString(String key, String value) { + if (this.request.getMultiValueQueryStringParameters() == null) { + this.request.setMultiValueQueryStringParameters(new MultiValuedTreeMap<>()); + } + + this.request.getMultiValueQueryStringParameters().add(key, value); + return this; + } + + public AwsProxyRequestBuilder body(String body) { + this.request.setBody(body); + return this; + } + + public AwsProxyRequestBuilder nullBody() { + this.request.setBody(null); + return this; + } + + public AwsProxyRequestBuilder body(Object body) { + if (request.getMultiValueHeaders() != null && request.getMultiValueHeaders().getFirst(HttpHeaders.CONTENT_TYPE).startsWith(MediaType.APPLICATION_JSON)) { + try { + return body(LambdaContainerHandler.getObjectMapper().writeValueAsString(body)); + } catch (JacksonException e) { + throw new UnsupportedOperationException("Could not serialize object: " + e.getMessage()); + } + } else { + throw new UnsupportedOperationException("Unsupported content type in request"); + } + } + + public AwsProxyRequestBuilder apiId(String id) { + if (request.getRequestContext() == null) { + request.setRequestContext(new AwsProxyRequestContext()); + } + request.getRequestContext().setApiId(id); + return this; + } + + public AwsProxyRequestBuilder binaryBody(InputStream is) + throws IOException { + this.request.setIsBase64Encoded(true); + return body(Base64.getMimeEncoder().encodeToString(IOUtils.toByteArray(is))); + } + + + public AwsProxyRequestBuilder authorizerPrincipal(String principal) { + if (this.request.getRequestSource() == RequestSource.API_GATEWAY) { + if (this.request.getRequestContext().getAuthorizer() == null) { + this.request.getRequestContext().setAuthorizer(new ApiGatewayAuthorizerContext()); + } + this.request.getRequestContext().getAuthorizer().setPrincipalId(principal); + if (this.request.getRequestContext().getAuthorizer().getClaims() == null) { + this.request.getRequestContext().getAuthorizer().setClaims(new CognitoAuthorizerClaims()); + } + this.request.getRequestContext().getAuthorizer().getClaims().setSubject(principal); + } + if (this.request.getRequestSource() == RequestSource.ALB) { + header("x-amzn-oidc-identity", principal); + try { + header( + "x-amzn-oidc-accesstoken", + Base64.getMimeEncoder().encodeToString( + "test-token".getBytes(ContainerConfig.DEFAULT_CONTENT_CHARSET) + ) + ); + } catch (UnsupportedEncodingException e) { + throw new RuntimeException(e); + } + } + return this; + } + + public AwsProxyRequestBuilder authorizerContextValue(String key, String value) { + if (this.request.getRequestContext().getAuthorizer() == null) { + this.request.getRequestContext().setAuthorizer(new ApiGatewayAuthorizerContext()); + } + this.request.getRequestContext().getAuthorizer().setContextValue(key, value); + return this; + } + + + public AwsProxyRequestBuilder cognitoUserPool(String identityId) { + this.request.getRequestContext().getIdentity().setCognitoAuthenticationType("POOL"); + this.request.getRequestContext().getIdentity().setCognitoIdentityId(identityId); + if (this.request.getRequestContext().getAuthorizer() == null) { + this.request.getRequestContext().setAuthorizer(new ApiGatewayAuthorizerContext()); + } + this.request.getRequestContext().getAuthorizer().setClaims(new CognitoAuthorizerClaims()); + this.request.getRequestContext().getAuthorizer().getClaims().setSubject(identityId); + + return this; + } + + public AwsProxyRequestBuilder claim(String claim, String value) { + this.request.getRequestContext().getAuthorizer().getClaims().setClaim(claim, value); + + return this; + } + + + public AwsProxyRequestBuilder cognitoIdentity(String identityId, String identityPoolId) { + this.request.getRequestContext().getIdentity().setCognitoAuthenticationType("IDENTITY"); + this.request.getRequestContext().getIdentity().setCognitoIdentityId(identityId); + this.request.getRequestContext().getIdentity().setCognitoIdentityPoolId(identityPoolId); + return this; + } + + + public AwsProxyRequestBuilder cookie(String name, String value) { + if (request.getMultiValueHeaders() == null) { + request.setMultiValueHeaders(new Headers()); + } + + String cookies = request.getMultiValueHeaders().getFirst(HttpHeaders.COOKIE); + if (cookies == null) { + cookies = ""; + } + + cookies += (cookies.equals("")?"":"; ") + name + "=" + value; + request.getMultiValueHeaders().putSingle(HttpHeaders.COOKIE, cookies); + return this; + } + + public AwsProxyRequestBuilder scheme(String scheme) { + if (request.getMultiValueHeaders() == null) { + request.setMultiValueHeaders(new Headers()); + } + + request.getMultiValueHeaders().putSingle("CloudFront-Forwarded-Proto", scheme); + return this; + } + + public AwsProxyRequestBuilder serverName(String serverName) { + if (request.getMultiValueHeaders() == null) { + request.setMultiValueHeaders(new Headers()); + } + + request.getMultiValueHeaders().putSingle("Host", serverName); + return this; + } + + public AwsProxyRequestBuilder userAgent(String agent) { + if (request.getRequestContext() == null) { + request.setRequestContext(new AwsProxyRequestContext()); + } + if (request.getRequestContext().getIdentity() == null) { + request.getRequestContext().setIdentity(new ApiGatewayRequestIdentity()); + } + + request.getRequestContext().getIdentity().setUserAgent(agent); + return this; + } + + public AwsProxyRequestBuilder referer(String referer) { + if (request.getRequestContext() == null) { + request.setRequestContext(new AwsProxyRequestContext()); + } + if (request.getRequestContext().getIdentity() == null) { + request.getRequestContext().setIdentity(new ApiGatewayRequestIdentity()); + } + + request.getRequestContext().getIdentity().setCaller(referer); + return this; + } + + + public AwsProxyRequestBuilder basicAuth(String username, String password) { + // we remove the existing authorization strategy + request.getMultiValueHeaders().remove(HttpHeaders.AUTHORIZATION); + String authHeader = "Basic " + Base64.getMimeEncoder().encodeToString((username + ":" + password).getBytes(Charset.defaultCharset())); + request.getMultiValueHeaders().add(HttpHeaders.AUTHORIZATION, authHeader); + return this; + } + + public AwsProxyRequestBuilder fromJsonString(String jsonContent) + throws IOException { + request = LambdaContainerHandler.getObjectMapper().readValue(jsonContent, AwsProxyRequest.class); + return this; + } + + @SuppressFBWarnings("PATH_TRAVERSAL_IN") + public AwsProxyRequestBuilder fromJsonPath(String filePath) + throws IOException { + request = LambdaContainerHandler.getObjectMapper().readValue(new File(filePath), AwsProxyRequest.class); + return this; + } + + public AwsProxyRequest build() { + return this.request; + } + + public InputStream buildStream() { + try { + String requestJson = LambdaContainerHandler.getObjectMapper().writeValueAsString(request); + return new ByteArrayInputStream(requestJson.getBytes(StandardCharsets.UTF_8)); + } catch (JacksonException e) { + return null; + } + } + + public InputStream toHttpApiV2RequestStream() { + HttpApiV2ProxyRequest req = toHttpApiV2Request(); + try { + String requestJson = LambdaContainerHandler.getObjectMapper().writeValueAsString(req); + return new ByteArrayInputStream(requestJson.getBytes(StandardCharsets.UTF_8)); + } catch (JacksonException e) { + return null; + } + } + + public HttpApiV2ProxyRequest toHttpApiV2Request() { + HttpApiV2ProxyRequest req = new HttpApiV2ProxyRequest(); + req.setRawPath(request.getPath()); + req.setBase64Encoded(request.isBase64Encoded()); + req.setBody(request.getBody()); + if (request.getMultiValueHeaders() != null && request.getMultiValueHeaders().containsKey(HttpHeaders.COOKIE)) { + req.setCookies(Arrays.asList(request.getMultiValueHeaders().getFirst(HttpHeaders.COOKIE).split(";"))); + } + req.setHeaders(new TreeMap<>(String.CASE_INSENSITIVE_ORDER)); + if (request.getMultiValueHeaders() != null) { + request.getMultiValueHeaders().forEach((key, value) -> { + if (!HttpHeaders.COOKIE.equals(key)) { + req.getHeaders().put(key, value.get(0)); + } + }); + } + if (request.getRequestContext() != null && request.getRequestContext().getIdentity() != null) { + if (request.getRequestContext().getIdentity().getCaller() != null) { + req.getHeaders().put("Referer", request.getRequestContext().getIdentity().getCaller()); + } + if (request.getRequestContext().getIdentity().getUserAgent() != null) { + req.getHeaders().put(HttpHeaders.USER_AGENT, request.getRequestContext().getIdentity().getUserAgent()); + } + + } + if (request.getMultiValueQueryStringParameters() != null) { + StringBuilder rawQueryString = new StringBuilder(); + request.getMultiValueQueryStringParameters().forEach((k, v) -> { + for (String s : v) { + rawQueryString.append("&"); + try { + // same terrible hack as the alb() method. Because our spring tests use commas as control characters + // we do not encode it + rawQueryString.append(URLEncoder.encode(k, "UTF-8").replaceAll("%2C", ",")); + rawQueryString.append("="); + rawQueryString.append(URLEncoder.encode(s, "UTF-8").replaceAll("%2C", ",")); + } catch (UnsupportedEncodingException e) { + throw new RuntimeException(e); + } + } + }); + String qs = rawQueryString.toString(); + if (qs.length() > 1) { + req.setRawQueryString(qs.substring(1)); + } + } + req.setRouteKey("$default"); + req.setVersion("2.0"); + req.setStageVariables(request.getStageVariables()); + + HttpApiV2ProxyRequestContext ctx = new HttpApiV2ProxyRequestContext(); + HttpApiV2HttpContext httpCtx = new HttpApiV2HttpContext(); + httpCtx.setMethod(request.getHttpMethod()); + httpCtx.setPath(request.getPath()); + httpCtx.setProtocol("HTTP/1.1"); + if (request.getRequestContext() != null && request.getRequestContext().getIdentity() != null && request.getRequestContext().getIdentity().getSourceIp() != null) { + httpCtx.setSourceIp(request.getRequestContext().getIdentity().getSourceIp()); + } else { + httpCtx.setSourceIp("127.0.0.1"); + } + if (request.getRequestContext() != null && request.getRequestContext().getIdentity() != null && request.getRequestContext().getIdentity().getUserAgent() != null) { + httpCtx.setUserAgent(request.getRequestContext().getIdentity().getUserAgent()); + } + ctx.setHttp(httpCtx); + if (request.getRequestContext() != null) { + ctx.setAccountId(request.getRequestContext().getAccountId()); + ctx.setApiId(request.getRequestContext().getApiId()); + ctx.setDomainName(request.getRequestContext().getApiId() + ".execute-api.us-east-1.apigateway.com"); + ctx.setDomainPrefix(request.getRequestContext().getApiId()); + ctx.setRequestId(request.getRequestContext().getRequestId()); + ctx.setRouteKey("$default"); + ctx.setStage(request.getRequestContext().getStage()); + ctx.setTimeEpoch(request.getRequestContext().getRequestTimeEpoch()); + ctx.setTime(request.getRequestContext().getRequestTime()); + + if (request.getRequestContext().getAuthorizer() != null) { + HttpApiV2AuthorizerMap auth = new HttpApiV2AuthorizerMap(); + HttpApiV2JwtAuthorizer jwt = new HttpApiV2JwtAuthorizer(); + // TODO: Anything we should map here? + jwt.setClaims(new HashMap<>()); + jwt.setScopes(new ArrayList<>()); + auth.putJwtAuthorizer(jwt); + ctx.setAuthorizer(auth); + } + } + req.setRequestContext(ctx); + + return req; + } +} diff --git a/aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/internal/testutils/AwsProxyRequestBuilderTest.java b/aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/internal/testutils/AwsProxyRequestBuilderTest.java new file mode 100644 index 00000000..b851e31d --- /dev/null +++ b/aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/internal/testutils/AwsProxyRequestBuilderTest.java @@ -0,0 +1,157 @@ +package com.amazonaws.serverless.proxy.internal.testutils; + +import java.io.IOException; +import java.net.URLEncoder; +import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.core.MediaType; + +import com.amazonaws.serverless.proxy.model.*; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +public class AwsProxyRequestBuilderTest { + + private static final String TEST_KEY = "testkey"; + private static final String TEST_VALUE = "testvalue"; + private static final String TEST_KEY_FOR_ENCODING = "test@key 1"; + private static final String TEST_VALUE_FOR_ENCODING = "test value!!"; + + + void baseConstructorAsserts(AwsProxyRequest request) { + assertEquals(0, request.getMultiValueHeaders().size()); + assertEquals(0, request.getHeaders().size()); + assertEquals(0, request.getMultiValueQueryStringParameters().size()); + assertNotNull(request.getRequestContext()); + assertNotNull(request.getRequestContext().getRequestId()); + assertNotNull(request.getRequestContext().getExtendedRequestId()); + assertEquals("test", request.getRequestContext().getStage()); + assertEquals("HTTP/1.1", request.getRequestContext().getProtocol()); + assertNotNull(request.getRequestContext().getRequestTimeEpoch()); + assertNotNull(request.getRequestContext().getIdentity()); + assertEquals("127.0.0.1", request.getRequestContext().getIdentity().getSourceIp()); + } + + @Test + void constructor_path_httpMethod() { + + AwsProxyRequestBuilder builder = new AwsProxyRequestBuilder("/path", "GET"); + AwsProxyRequest request = builder.build(); + assertEquals("/path", request.getPath()); + assertEquals("GET", request.getHttpMethod()); + baseConstructorAsserts(request); + } + + @Test + void constructor_path_nullHttpMethod() { + AwsProxyRequestBuilder builder = new AwsProxyRequestBuilder("/path"); + AwsProxyRequest request = builder.build(); + assertNull(request.getHttpMethod()); + assertEquals("/path", request.getPath()); + baseConstructorAsserts(request); + } + + @Test + void constructor_nullPath_nullHttpMethod() { + AwsProxyRequestBuilder builder = new AwsProxyRequestBuilder(); + AwsProxyRequest request = builder.build(); + assertNull(request.getHttpMethod()); + assertNull(request.getPath()); + baseConstructorAsserts(request); + } + + @Test + void form_key_value() { + AwsProxyRequestBuilder builder = new AwsProxyRequestBuilder("/path", "POST"); + builder.form(TEST_KEY, TEST_VALUE); + AwsProxyRequest request = builder.build(); + assertEquals(1, request.getMultiValueHeaders().get(HttpHeaders.CONTENT_TYPE).size()); + assertEquals(MediaType.APPLICATION_FORM_URLENCODED, request.getMultiValueHeaders().getFirst(HttpHeaders.CONTENT_TYPE)); + assertNull(request.getHeaders().get(HttpHeaders.CONTENT_TYPE)); + assertNotNull(request.getBody()); + assertEquals(TEST_KEY + "=" + TEST_VALUE, request.getBody()); + } + + @Test + void form_key_nullKey_nullValue() { + AwsProxyRequestBuilder builder = new AwsProxyRequestBuilder("/path", "POST"); + assertThrows(IllegalArgumentException.class, () -> builder.form(null, TEST_VALUE)); + assertThrows(IllegalArgumentException.class, () -> builder.form(TEST_KEY, null)); + assertThrows(IllegalArgumentException.class, () -> builder.form(null, null)); + } + + @Test + void form_keyEncoded_valueEncoded() throws IOException { + AwsProxyRequestBuilder builder = new AwsProxyRequestBuilder("/path", "POST"); + builder.form(TEST_KEY_FOR_ENCODING, TEST_VALUE_FOR_ENCODING); + AwsProxyRequest request = builder.build(); + + assertEquals(1, request.getMultiValueHeaders().get(HttpHeaders.CONTENT_TYPE).size()); + assertEquals(MediaType.APPLICATION_FORM_URLENCODED, request.getMultiValueHeaders().getFirst(HttpHeaders.CONTENT_TYPE)); + assertNull(request.getHeaders().get(HttpHeaders.CONTENT_TYPE)); + assertNotNull(request.getBody()); + String expected = URLEncoder.encode(TEST_KEY_FOR_ENCODING, "UTF-8") + "=" + URLEncoder.encode(TEST_VALUE_FOR_ENCODING, "UTF-8"); + assertEquals(expected, request.getBody()); + } + + @Test + void queryString_key_value() { + AwsProxyRequestBuilder builder = new AwsProxyRequestBuilder("/path", "POST"); + builder.queryString(TEST_KEY, TEST_VALUE); + AwsProxyRequest request = builder.build(); + + assertNull(request.getQueryStringParameters()); + assertEquals(1, request.getMultiValueQueryStringParameters().size()); + assertEquals(TEST_KEY, request.getMultiValueQueryStringParameters().keySet().iterator().next()); + assertEquals(TEST_VALUE, request.getMultiValueQueryStringParameters().get(TEST_KEY).get(0)); + assertEquals(TEST_VALUE, request.getMultiValueQueryStringParameters().getFirst(TEST_KEY)); + } + + @Test + void queryString_keyNotEncoded_valueNotEncoded() { + // builder should not URL encode key or value for query string + // in the case of an ALB where values should be encoded, the builder alb() method will handle it + AwsProxyRequestBuilder builder = new AwsProxyRequestBuilder("/path", "POST"); + builder.queryString(TEST_KEY_FOR_ENCODING, TEST_VALUE_FOR_ENCODING); + AwsProxyRequest request = builder.build(); + + assertNull(request.getQueryStringParameters()); + assertEquals(1, request.getMultiValueQueryStringParameters().size()); + assertEquals(TEST_KEY_FOR_ENCODING, request.getMultiValueQueryStringParameters().keySet().iterator().next()); + assertEquals(TEST_VALUE_FOR_ENCODING, request.getMultiValueQueryStringParameters().get(TEST_KEY_FOR_ENCODING).get(0)); + assertEquals(TEST_VALUE_FOR_ENCODING, request.getMultiValueQueryStringParameters().getFirst(TEST_KEY_FOR_ENCODING)); + } + + @Test + void queryString_alb_key_value() { + AwsProxyRequestBuilder builder = new AwsProxyRequestBuilder("/path", "POST"); + builder.queryString(TEST_KEY, TEST_VALUE); + AwsProxyRequest request = builder.alb().build(); + + assertNull(request.getQueryStringParameters()); + assertEquals(1, request.getMultiValueQueryStringParameters().size()); + assertEquals(TEST_KEY, request.getMultiValueQueryStringParameters().keySet().iterator().next()); + assertEquals(TEST_VALUE, request.getMultiValueQueryStringParameters().get(TEST_KEY).get(0)); + assertEquals(TEST_VALUE, request.getMultiValueQueryStringParameters().getFirst(TEST_KEY)); + } + + @Test + void alb_keyEncoded_valueEncoded() throws IOException { + AwsProxyRequestBuilder builder = new AwsProxyRequestBuilder("/path", "POST"); + MultiValuedTreeMap map = new MultiValuedTreeMap<>(); + map.add(TEST_KEY_FOR_ENCODING, TEST_VALUE_FOR_ENCODING); + builder.multiValueQueryString(map); + AwsProxyRequest request = builder.alb().build(); + + String expectedKey = URLEncoder.encode(TEST_KEY_FOR_ENCODING, "UTF-8"); + String expectedValue = URLEncoder.encode(TEST_VALUE_FOR_ENCODING, "UTF-8"); + assertEquals(1, request.getMultiValueQueryStringParameters().size()); + assertEquals(expectedKey, request.getMultiValueQueryStringParameters().keySet().iterator().next()); + assertEquals(expectedValue, request.getMultiValueQueryStringParameters().get(expectedKey).get(0)); + assertEquals(expectedValue, request.getMultiValueQueryStringParameters().getFirst(expectedKey)); + assertEquals(expectedKey, request.getMultiValueQueryStringParameters().keySet().iterator().next()); + assertEquals(expectedValue, request.getMultiValueQueryStringParameters().get(expectedKey).get(0)); + assertEquals(expectedValue, request.getMultiValueQueryStringParameters().getFirst(expectedKey)); + } + +} diff --git a/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/testutils/MockLambdaConsoleLogger.java b/aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/internal/testutils/MockLambdaConsoleLogger.java similarity index 86% rename from aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/testutils/MockLambdaConsoleLogger.java rename to aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/internal/testutils/MockLambdaConsoleLogger.java index 71d12f54..25f370cc 100644 --- a/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/testutils/MockLambdaConsoleLogger.java +++ b/aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/internal/testutils/MockLambdaConsoleLogger.java @@ -14,6 +14,9 @@ import com.amazonaws.services.lambda.runtime.LambdaLogger; +import java.nio.charset.Charset; + + /** * Mock LambdaLogger object that prints output to the console */ @@ -27,4 +30,10 @@ public class MockLambdaConsoleLogger implements LambdaLogger { public void log(String s) { System.out.println(s); } + + + @Override + public void log(byte[] bytes) { + System.out.println(new String(bytes, Charset.defaultCharset())); + } } diff --git a/aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/testutils/MockLambdaContext.java b/aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/internal/testutils/MockLambdaContext.java similarity index 100% rename from aws-serverless-java-container-core/src/main/java/com/amazonaws/serverless/proxy/internal/testutils/MockLambdaContext.java rename to aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/internal/testutils/MockLambdaContext.java diff --git a/aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/internal/testutils/MockServlet.java b/aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/internal/testutils/MockServlet.java new file mode 100644 index 00000000..6dd2ade6 --- /dev/null +++ b/aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/internal/testutils/MockServlet.java @@ -0,0 +1,23 @@ +package com.amazonaws.serverless.proxy.internal.testutils; + +import java.io.IOException; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServlet; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +public class MockServlet extends HttpServlet { + + private int serviceCalls = 0; + + @Override + protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { + super.service(req, resp); + serviceCalls++; + } + + public int getServiceCalls() { + return serviceCalls; + } +} diff --git a/aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/internal/model/ApiGatewayAuthorizerContextTest.java b/aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/model/ApiGatewayAuthorizerContextTest.java similarity index 97% rename from aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/internal/model/ApiGatewayAuthorizerContextTest.java rename to aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/model/ApiGatewayAuthorizerContextTest.java index 75b80d81..196552c1 100644 --- a/aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/internal/model/ApiGatewayAuthorizerContextTest.java +++ b/aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/model/ApiGatewayAuthorizerContextTest.java @@ -1,12 +1,12 @@ -package com.amazonaws.serverless.proxy.internal.model; +package com.amazonaws.serverless.proxy.model; import com.amazonaws.serverless.proxy.internal.testutils.AwsProxyRequestBuilder; -import org.junit.Test; +import org.junit.jupiter.api.Test; import java.io.IOException; -import static org.junit.Assert.*; +import static org.junit.jupiter.api.Assertions.*; public class ApiGatewayAuthorizerContextTest { private static final String FIELD_NAME_1 = "CUSTOM_FIELD_1"; @@ -60,7 +60,7 @@ public class ApiGatewayAuthorizerContextTest { + "}"; @Test - public void authorizerContext_serialize_customValues() { + void authorizerContext_serialize_customValues() { try { AwsProxyRequest req = new AwsProxyRequestBuilder().fromJsonString(AUTHORIZER_REQUEST).build(); diff --git a/aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/model/AwsProxyRequestTest.java b/aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/model/AwsProxyRequestTest.java new file mode 100644 index 00000000..07bdff97 --- /dev/null +++ b/aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/model/AwsProxyRequestTest.java @@ -0,0 +1,136 @@ +package com.amazonaws.serverless.proxy.model; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.jupiter.api.Assertions.*; + +import java.io.IOException; +import org.junit.jupiter.api.Test; +import com.amazonaws.serverless.proxy.internal.testutils.AwsProxyRequestBuilder; +import tools.jackson.databind.ObjectMapper; + +public class AwsProxyRequestTest { + private static final String CUSTOM_HEADER_KEY_LOWER_CASE = "custom-header"; + private static final String CUSTOM_HEADER_VALUE = "123456"; + + @Test + void deserialize_multiValuedHeaders_caseInsensitive() throws IOException { + AwsProxyRequest req = new AwsProxyRequestBuilder() + .fromJsonString(getRequestJson(true, CUSTOM_HEADER_KEY_LOWER_CASE, CUSTOM_HEADER_VALUE)).build(); + assertNotNull(req.getMultiValueHeaders().get(CUSTOM_HEADER_KEY_LOWER_CASE.toUpperCase())); + assertEquals(CUSTOM_HEADER_VALUE, req.getMultiValueHeaders().get(CUSTOM_HEADER_KEY_LOWER_CASE.toUpperCase()).get(0)); + assertTrue(req.isBase64Encoded()); + } + + @Test + void deserialize_base64Encoded_readsBoolCorrectly() throws IOException { + AwsProxyRequest req = new AwsProxyRequestBuilder() + .fromJsonString(getRequestJson(true, CUSTOM_HEADER_KEY_LOWER_CASE, CUSTOM_HEADER_VALUE)).build(); + assertTrue(req.isBase64Encoded()); + req = new AwsProxyRequestBuilder() + .fromJsonString(getRequestJson(false, CUSTOM_HEADER_KEY_LOWER_CASE, CUSTOM_HEADER_VALUE)).build(); + assertFalse(req.isBase64Encoded()); + } + + @Test + void serialize_base64Encoded_fieldContainsIsPrefix() throws IOException { + AwsProxyRequest req = new AwsProxyRequestBuilder() + .fromJsonString(getRequestJson(true, CUSTOM_HEADER_KEY_LOWER_CASE, CUSTOM_HEADER_VALUE)).build(); + ObjectMapper mapper = new ObjectMapper(); + String serializedRequest = mapper.writeValueAsString(req); + + assertTrue(serializedRequest.contains("\"isBase64Encoded\":true")); + } + + private String getRequestJson(boolean base64Encoded, String headerKey, String headerValue) { + return "{\n" + + " \"resource\": \"/api/{proxy+}\",\n" + + " \"path\": \"/api/endpoint\",\n" + + " \"httpMethod\": \"OPTIONS\",\n" + + " \"headers\": {\n" + + " \"Accept\": \"*/*\",\n" + + " \"User-Agent\": \"PostmanRuntime/7.1.1\",\n" + + " \"" + headerKey +"\":" + "\"" + headerValue + "\"\n" + + " },\n" + + " \"multiValueHeaders\": {\n" + + " \"Accept\": [\n" + + " \"*/*\"\n" + + " ],\n" + + " \"User-Agent\": [\n" + + " \"PostmanRuntime/7.1.1\"\n" + + " ],\n" + + " \"" + headerKey + "\": [\n" + + " \"" + headerValue + "\"\n" + + " ]\n" + + " },\n" + + " \"queryStringParameters\": null,\n" + + " \"multiValueQueryStringParameters\": null,\n" + + " \"pathParameters\": {\n" + + " \"proxy\": \"endpoint\"\n" + + " },\n" + + " \"stageVariables\": null,\n" + + " \"requestContext\": {\n" + + " \"resourceId\": null,\n" + + " \"resourcePath\": \"/api/{proxy+}\",\n" + + " \"httpMethod\": \"OPTIONS\",\n" + + " \"extendedRequestId\": null,\n" + + " \"requestTime\": \"15/Dec/2018:20:37:47 +0000\",\n" + + " \"path\": \"/api/endpoint\",\n" + + " \"accountId\": null,\n" + + " \"protocol\": \"HTTP/1.1\",\n" + + " \"stage\": \"stage_name\",\n" + + " \"domainPrefix\": null,\n" + + " \"requestTimeEpoch\": 1544906267828,\n" + + " \"requestId\": null,\n" + + " \"identity\": {\n" + + " \"cognitoIdentityPoolId\": null,\n" + + " \"accountId\": null,\n" + + " \"cognitoIdentityId\": null,\n" + + " \"caller\": null,\n" + + " \"sourceIp\": \"54.240.196.171\",\n" + + " \"accessKey\": null,\n" + + " \"cognitoAuthenticationType\": null,\n" + + " \"cognitoAuthenticationProvider\": null,\n" + + " \"userArn\": null,\n" + + " \"userAgent\": \"PostmanRuntime/7.1.1\",\n" + + " \"user\": null\n" + + " },\n" + + " \"domainName\": \"https://apiId.execute-api.eu-central-1.amazonaws.com/\",\n" + + " \"apiId\": \"apiId\"\n" + + " },\n" + + " \"body\": null,\n" + + " \"isBase64Encoded\": " + (base64Encoded?"true":"false") + "\n" + + "}"; + } + + @Test + void deserialize_singleValuedHeaders() throws IOException { + AwsProxyRequest req = + new AwsProxyRequestBuilder().fromJsonString(getSingleValueRequestJson()).build(); + + assertThat(req.getHeaders().get("accept"), is("*")); + } + + /** + * Captured from a live request to an ALB with a Lambda integration with + * lambda.multi_value_headers.enabled=false. + */ + private String getSingleValueRequestJson() { + return "{\n" + " \"requestContext\": {\n" + " \"elb\": {\n" + + " \"targetGroupArn\": \"arn:aws:elasticloadbalancing:us-east-2:123456789012:targetgroup/prod-example-function/e77803ebb6d2c24\"\n" + + " }\n" + " },\n" + " \"httpMethod\": \"PUT\",\n" + + " \"path\": \"/path/to/resource\",\n" + " \"queryStringParameters\": {},\n" + + " \"headers\": {\n" + " \"accept\": \"*\",\n" + + " \"content-length\": \"17\",\n" + + " \"content-type\": \"application/json\",\n" + + " \"host\": \"stackoverflow.name\",\n" + + " \"user-agent\": \"curl/7.77.0\",\n" + + " \"x-amzn-trace-id\": \"Root=1-62e22402-3a5f246225e45edd7735c182\",\n" + + " \"x-forwarded-for\": \"24.14.13.186\",\n" + + " \"x-forwarded-port\": \"443\",\n" + + " \"x-forwarded-proto\": \"https\",\n" + + " \"x-jersey-tracing-accept\": \"true\"\n" + " },\n" + + " \"body\": \"{\\\"alpha\\\":\\\"bravo\\\"}\",\n" + + " \"isBase64Encoded\": false\n" + "} \n"; + } +} diff --git a/aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/internal/model/CognitoAuthorizerClaimsTest.java b/aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/model/CognitoAuthorizerClaimsTest.java similarity index 95% rename from aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/internal/model/CognitoAuthorizerClaimsTest.java rename to aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/model/CognitoAuthorizerClaimsTest.java index 980ee188..1ad2314b 100644 --- a/aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/internal/model/CognitoAuthorizerClaimsTest.java +++ b/aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/model/CognitoAuthorizerClaimsTest.java @@ -1,14 +1,15 @@ -package com.amazonaws.serverless.proxy.internal.model; +package com.amazonaws.serverless.proxy.model; import com.amazonaws.serverless.proxy.internal.testutils.AwsProxyRequestBuilder; -import org.junit.Test; +import org.junit.jupiter.api.Test; import java.io.IOException; import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; +import java.util.Locale; -import static org.junit.Assert.*; +import static org.junit.jupiter.api.Assertions.*; public class CognitoAuthorizerClaimsTest { @@ -19,7 +20,7 @@ public class CognitoAuthorizerClaimsTest { private static final String EXP_TIME = "Mon Apr 17 23:12:49 UTC 2017"; private static final String ISSUE_TIME = "Mon Apr 17 22:12:49 UTC 2017"; - static final DateTimeFormatter TOKEN_DATE_FORMATTER = DateTimeFormatter.ofPattern("EEE MMM dd HH:mm:ss zzz yyyy"); + static final DateTimeFormatter TOKEN_DATE_FORMATTER = DateTimeFormatter.ofPattern("EEE MMM dd HH:mm:ss zzz yyyy").withLocale(Locale.ENGLISH); private static final String USER_POOLS_REQUEST = "{\n" + " \"resource\": \"/restaurants\",\n" @@ -76,7 +77,7 @@ public class CognitoAuthorizerClaimsTest { @Test - public void claims_serialize_validJsonString() { + void claims_serialize_validJsonString() { try { AwsProxyRequest req = new AwsProxyRequestBuilder().fromJsonString(USER_POOLS_REQUEST).build(); @@ -84,13 +85,12 @@ public void claims_serialize_validJsonString() { assertEquals(EMAIL, req.getRequestContext().getAuthorizer().getClaims().getEmail()); assertTrue(req.getRequestContext().getAuthorizer().getClaims().isEmailVerified()); } catch (IOException e) { - e.printStackTrace(); fail(); } } @Test - public void claims_dateParse_issueTime() { + void claims_dateParse_issueTime() { try { AwsProxyRequest req = new AwsProxyRequestBuilder().fromJsonString(USER_POOLS_REQUEST).build(); diff --git a/aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/model/HttpApiV2ProxyRequestTest.java b/aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/model/HttpApiV2ProxyRequestTest.java new file mode 100644 index 00000000..e51a1602 --- /dev/null +++ b/aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/model/HttpApiV2ProxyRequestTest.java @@ -0,0 +1,285 @@ +package com.amazonaws.serverless.proxy.model; + +import com.amazonaws.serverless.proxy.internal.LambdaContainerHandler; +import tools.jackson.core.JacksonException; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; + +import static org.junit.jupiter.api.Assertions.*; + +public class HttpApiV2ProxyRequestTest { + + private static final String BASE_PROXY_REQUEST = "{\n" + + " \"version\": \"2.0\",\n" + + " \"routeKey\": \"$default\",\n" + + " \"rawPath\": \"/my/path\",\n" + + " \"rawQueryString\": \"parameter1=value1¶meter1=value2¶meter2=value\",\n" + + " \"cookies\": [ \"cookie1\", \"cookie2\" ],\n" + + " \"headers\": {\n" + + " \"Header1\": \"value1\",\n" + + " \"Header2\": \"value2\"\n" + + " },\n" + + " \"queryStringParameters\": { \"parameter1\": \"value1,value2\", \"parameter2\": \"value\" },\n" + + " \"requestContext\": {\n" + + " \"accountId\": \"123456789012\",\n" + + " \"apiId\": \"api-id\",\n" + + " \"authorizer\": { \"jwt\": {\n" + + " \"claims\": {\"claim1\": \"value1\", \"claim2\": \"value2\"},\n" + + " \"scopes\": [\"scope1\", \"scope2\"]\n" + + " }\n" + + " },\n" + + " \"domainName\": \"id.execute-api.us-east-1.amazonaws.com\",\n" + + " \"domainPrefix\": \"id\",\n" + + " \"http\": {\n" + + " \"method\": \"POST\",\n" + + " \"path\": \"/my/path\",\n" + + " \"protocol\": \"HTTP/1.1\",\n" + + " \"sourceIp\": \"IP\",\n" + + " \"userAgent\": \"agent\"\n" + + " },\n" + + " \"requestId\": \"id\",\n" + + " \"routeKey\": \"$default\",\n" + + " \"stage\": \"$default\",\n" + + " \"time\": \"12/Mar/2020:19:03:58 +0000\",\n" + + " \"timeEpoch\": 1583348638390\n" + + " },\n" + + " \"body\": \"Hello from Lambda\",\n" + + " \"isBase64Encoded\": false,\n" + + " \"stageVariables\": {\"stageVariable1\": \"value1\", \"stageVariable2\": \"value2\"}\n" + + " }\n"; + private static final String NO_AUTH_PROXY = "{\n" + + " \"version\": \"2.0\",\n" + + " \"routeKey\": \"$default\",\n" + + " \"rawPath\": \"/my/path\",\n" + + " \"rawQueryString\": \"parameter1=value1¶meter1=value2¶meter2=value\",\n" + + " \"cookies\": [ \"cookie1\", \"cookie2\" ],\n" + + " \"headers\": {\n" + + " \"Header1\": \"value1\",\n" + + " \"Header2\": \"value2\"\n" + + " },\n" + + " \"queryStringParameters\": { \"parameter1\": \"value1,value2\", \"parameter2\": \"value\" },\n" + + " \"requestContext\": {\n" + + " \"accountId\": \"123456789012\",\n" + + " \"apiId\": \"api-id\",\n" + + " \"authorizer\": {\n " + + " },\n" + + " \"domainName\": \"id.execute-api.us-east-1.amazonaws.com\",\n" + + " \"domainPrefix\": \"id\",\n" + + " \"http\": {\n" + + " \"method\": \"POST\",\n" + + " \"path\": \"/my/path\",\n" + + " \"protocol\": \"HTTP/1.1\",\n" + + " \"sourceIp\": \"IP\",\n" + + " \"userAgent\": \"agent\"\n" + + " },\n" + + " \"requestId\": \"id\",\n" + + " \"routeKey\": \"$default\",\n" + + " \"stage\": \"$default\",\n" + + " \"time\": \"12/Mar/2020:19:03:58 +0000\",\n" + + " \"timeEpoch\": 1583348638390\n" + + " },\n" + + " \"body\": \"Hello from Lambda\",\n" + + " \"isBase64Encoded\": true,\n" + + " \"stageVariables\": {\"stageVariable1\": \"value1\", \"stageVariable2\": \"value2\"}\n" + + " }\n"; + private static final String LAMBDA_AUTHORIZER = "{\n" + + " \"version\": \"2.0\",\n" + + " \"routeKey\": \"$default\",\n" + + " \"rawPath\": \"/my/path\",\n" + + " \"rawQueryString\": \"parameter1=value1¶meter1=value2¶meter2=value\",\n" + + " \"cookies\": [ \"cookie1\", \"cookie2\" ],\n" + + " \"headers\": {\n" + + " \"Header1\": \"value1\",\n" + + " \"Header2\": \"value2\"\n" + + " },\n" + + " \"queryStringParameters\": { \"parameter1\": \"value1,value2\", \"parameter2\": \"value\" },\n" + + " \"requestContext\": {\n" + + " \"accountId\": \"123456789012\",\n" + + " \"apiId\": \"api-id\",\n" + + " \"authorizer\": { \"lambda\": {\n" + + " \"arrayKey\": [\n" + + " \"value1\",\n" + + " \"value2\"\n" + + " ],\n" + + " \"booleanKey\": true,\n" + + " \"mapKey\": {\n" + + " \"value1\": \"value2\"\n" + + " },\n" + + " \"numberKey\": 1,\n" + + " \"stringKey\": \"value\"\n" + + " }" + + " },\n" + + " \"domainName\": \"id.execute-api.us-east-1.amazonaws.com\",\n" + + " \"domainPrefix\": \"id\",\n" + + " \"http\": {\n" + + " \"method\": \"POST\",\n" + + " \"path\": \"/my/path\",\n" + + " \"protocol\": \"HTTP/1.1\",\n" + + " \"sourceIp\": \"IP\",\n" + + " \"userAgent\": \"agent\"\n" + + " },\n" + + " \"requestId\": \"id\",\n" + + " \"routeKey\": \"$default\",\n" + + " \"stage\": \"$default\",\n" + + " \"time\": \"12/Mar/2020:19:03:58 +0000\",\n" + + " \"timeEpoch\": 1583348638390\n" + + " },\n" + + " \"body\": \"Hello from Lambda\",\n" + + " \"isBase64Encoded\": false,\n" + + " \"stageVariables\": {\"stageVariable1\": \"value1\", \"stageVariable2\": \"value2\"}\n" + + " }\n"; + private static final String IAM_AUTHORIZER = "{\n" + + " \"version\": \"2.0\",\n" + + " \"routeKey\": \"$default\",\n" + + " \"rawPath\": \"/my/path\",\n" + + " \"rawQueryString\": \"parameter1=value1¶meter1=value2¶meter2=value\",\n" + + " \"cookies\": [ \"cookie1\", \"cookie2\" ],\n" + + " \"headers\": {\n" + + " \"Header1\": \"value1\",\n" + + " \"Header2\": \"value2\"\n" + + " },\n" + + " \"queryStringParameters\": { \"parameter1\": \"value1,value2\", \"parameter2\": \"value\" },\n" + + " \"requestContext\": {\n" + + " \"accountId\": \"123456789012\",\n" + + " \"apiId\": \"api-id\",\n" + + " \"authorizer\": { \"iam\": {\n" + + " \"accessKey\": \"AKIAIOSFODNN7EXAMPLE\",\n" + + " \"accountId\": \"123456789012\",\n" + + " \"callerId\": \"AIDACKCEVSQ6C2EXAMPLE\",\n" + + " \"cognitoIdentity\": null,\n" + + " \"principalOrgId\": \"AIDACKCEVSQORGEXAMPLE\",\n" + + " \"userArn\": \"arn:aws:iam::111122223333:user/example-user\",\n" + + " \"userId\": \"AIDACOSFODNN7EXAMPLE2\"\n" + + " }" + + " },\n" + + " \"domainName\": \"id.execute-api.us-east-1.amazonaws.com\",\n" + + " \"domainPrefix\": \"id\",\n" + + " \"http\": {\n" + + " \"method\": \"POST\",\n" + + " \"path\": \"/my/path\",\n" + + " \"protocol\": \"HTTP/1.1\",\n" + + " \"sourceIp\": \"IP\",\n" + + " \"userAgent\": \"agent\"\n" + + " },\n" + + " \"requestId\": \"id\",\n" + + " \"routeKey\": \"$default\",\n" + + " \"stage\": \"$default\",\n" + + " \"time\": \"12/Mar/2020:19:03:58 +0000\",\n" + + " \"timeEpoch\": 1583348638390\n" + + " },\n" + + " \"body\": \"Hello from Lambda\",\n" + + " \"isBase64Encoded\": false,\n" + + " \"stageVariables\": {\"stageVariable1\": \"value1\", \"stageVariable2\": \"value2\"}\n" + + " }\n"; + + @Test + void deserialize_fromJsonString_authorizerPopulatedCorrectly() { + try { + HttpApiV2ProxyRequest req = LambdaContainerHandler.getObjectMapper().readValue(BASE_PROXY_REQUEST, + HttpApiV2ProxyRequest.class); + assertTrue(req.getRequestContext().getAuthorizer().getJwtAuthorizer().getClaims().containsKey("claim1")); + assertEquals(2, req.getRequestContext().getAuthorizer().getJwtAuthorizer().getScopes().size()); + assertEquals(RequestSource.API_GATEWAY, req.getRequestSource()); + } catch (JacksonException e) { + e.printStackTrace(); + fail("Exception while parsing request" + e.getMessage()); + } + } + + @Test + void deserialize_fromJsonString_authorizerEmptyMap() { + try { + HttpApiV2ProxyRequest req = LambdaContainerHandler.getObjectMapper().readValue(NO_AUTH_PROXY, + HttpApiV2ProxyRequest.class); + assertNotNull(req.getRequestContext().getAuthorizer()); + assertFalse(req.getRequestContext().getAuthorizer().isJwt()); + assertFalse(req.getRequestContext().getAuthorizer().isLambda()); + assertFalse(req.getRequestContext().getAuthorizer().isIam()); + } catch (JacksonException e) { + e.printStackTrace(); + fail("Exception while parsing request" + e.getMessage()); + } + } + + @Test + void deserialize_fromJsonString_lambdaAuthorizer() { + try { + HttpApiV2ProxyRequest req = LambdaContainerHandler.getObjectMapper().readValue(LAMBDA_AUTHORIZER, + HttpApiV2ProxyRequest.class); + assertNotNull(req.getRequestContext().getAuthorizer()); + assertFalse(req.getRequestContext().getAuthorizer().isJwt()); + assertTrue(req.getRequestContext().getAuthorizer().isLambda()); + assertEquals(5, req.getRequestContext().getAuthorizer().getLambdaAuthorizerContext().size()); + assertEquals(1, req.getRequestContext().getAuthorizer().getLambdaAuthorizerContext().get("numberKey")); + } catch (JacksonException e) { + e.printStackTrace(); + fail("Exception while parsing request" + e.getMessage()); + } + } + + @Test + void deserialize_fromJsonString_iamAuthorizer() { + try { + HttpApiV2ProxyRequest req = LambdaContainerHandler.getObjectMapper().readValue(IAM_AUTHORIZER, + HttpApiV2ProxyRequest.class); + assertNotNull(req.getRequestContext().getAuthorizer()); + assertFalse(req.getRequestContext().getAuthorizer().isJwt()); + assertFalse(req.getRequestContext().getAuthorizer().isLambda()); + assertTrue(req.getRequestContext().getAuthorizer().isIam()); + assertEquals("AKIAIOSFODNN7EXAMPLE", + req.getRequestContext().getAuthorizer().getIamAuthorizer().getAccessKey()); + assertEquals("123456789012", req.getRequestContext().getAuthorizer().getIamAuthorizer().getAccountId()); + assertEquals("AIDACKCEVSQ6C2EXAMPLE", + req.getRequestContext().getAuthorizer().getIamAuthorizer().getCallerId()); + assertNull(req.getRequestContext().getAuthorizer().getIamAuthorizer().getCognitoIdentity()); + assertEquals("AIDACKCEVSQORGEXAMPLE", + req.getRequestContext().getAuthorizer().getIamAuthorizer().getPrincipalOrgId()); + assertEquals("arn:aws:iam::111122223333:user/example-user", + req.getRequestContext().getAuthorizer().getIamAuthorizer().getUserArn()); + assertEquals("AIDACOSFODNN7EXAMPLE2", + req.getRequestContext().getAuthorizer().getIamAuthorizer().getUserId()); + } catch (JacksonException e) { + e.printStackTrace(); + fail("Exception while parsing request" + e.getMessage()); + } + } + + @Test + void deserialize_fromJsonString_isBase64EncodedPopulates() { + try { + HttpApiV2ProxyRequest req = LambdaContainerHandler.getObjectMapper().readValue(BASE_PROXY_REQUEST, + HttpApiV2ProxyRequest.class); + assertFalse(req.isBase64Encoded()); + req = LambdaContainerHandler.getObjectMapper().readValue(NO_AUTH_PROXY, HttpApiV2ProxyRequest.class); + assertTrue(req.isBase64Encoded()); + assertEquals(RequestSource.API_GATEWAY, req.getRequestSource()); + } catch (JacksonException e) { + e.printStackTrace(); + fail("Exception while parsing request" + e.getMessage()); + } + } + + @Test + void serialize_toJsonString_authorizerPopulatesCorrectly() { + HttpApiV2ProxyRequest req = new HttpApiV2ProxyRequest(); + req.setBase64Encoded(false); + req.setRequestContext(new HttpApiV2ProxyRequestContext()); + req.getRequestContext().setAuthorizer(new HttpApiV2AuthorizerMap()); + req.getRequestContext().getAuthorizer().putJwtAuthorizer(new HttpApiV2JwtAuthorizer()); + ArrayList scopes = new ArrayList<>(); + scopes.add("first"); + scopes.add("second"); + req.getRequestContext().getAuthorizer().getJwtAuthorizer().setScopes(scopes); + + try { + String reqString = LambdaContainerHandler.getObjectMapper().writeValueAsString(req); + assertTrue(reqString.contains("\"scopes\":[\"first\",\"second\"]")); + assertTrue(reqString.contains("\"authorizer\":{\"jwt\":{")); + assertTrue(reqString.contains("\"isBase64Encoded\":false")); + } catch (JacksonException e) { + e.printStackTrace(); + fail("Exception while serializing request" + e.getMessage()); + } + } +} \ No newline at end of file diff --git a/aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/model/MultiValuedTreeMapTest.java b/aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/model/MultiValuedTreeMapTest.java new file mode 100644 index 00000000..c2566cbe --- /dev/null +++ b/aws-serverless-java-container-core/src/test/java/com/amazonaws/serverless/proxy/model/MultiValuedTreeMapTest.java @@ -0,0 +1,55 @@ +package com.amazonaws.serverless.proxy.model; + + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + + +public class MultiValuedTreeMapTest { + + @Test + void add_sameNameCaseSensitive_expectBothValues() { + MultiValuedTreeMap map = new MultiValuedTreeMap<>(); + map.add("Test", "test"); + map.add("Test", "test2"); + + assertNotNull(map.get("Test")); + assertEquals(2, map.get("Test").size()); + assertEquals("test", map.getFirst("Test")); + assertEquals("test2", map.get("Test").get(1)); + assertNull(map.get("test")); + + map.add("test", "test"); + assertNotNull(map.get("test")); + assertEquals(1, map.get("test").size()); + } + + @Test + void add_sameNameCaseInsensitive_expectOneValue() { + Headers map = new Headers(); + map.add("Test", "test"); + assertNotNull(map.get("Test")); + assertNotNull(map.get("test")); + assertEquals(1, map.get("Test").size()); + + map.add("test", "test2"); + assertNotNull(map.get("Test")); + assertEquals(2, map.get("Test").size()); + } + + @Test + void addFirst_sameNameKey_ExpectFirstReplaced() { + MultiValuedTreeMap map = new MultiValuedTreeMap<>(); + map.add("Test", "test1"); + map.add("Test", "test2"); + + assertNotNull(map.get("Test")); + assertEquals(2, map.get("Test").size()); + assertEquals("test1", map.getFirst("Test")); + + map.addFirst("Test", "test3"); + assertEquals(3, map.get("Test").size()); + assertEquals("test3", map.getFirst("Test")); + } +} diff --git a/aws-serverless-java-container-jersey/pom.xml b/aws-serverless-java-container-jersey/pom.xml index 87853317..b022eefe 100644 --- a/aws-serverless-java-container-jersey/pom.xml +++ b/aws-serverless-java-container-jersey/pom.xml @@ -6,17 +6,17 @@ AWS Serverless Java container support - Jersey implementation Allows Java applications written for Jersey to run in AWS Lambda https://aws.amazon.com/lambda - 0.5-SNAPSHOT + 3.0.3-SNAPSHOT com.amazonaws.serverless aws-serverless-java-container - 0.5-SNAPSHOT + 3.0.3-SNAPSHOT + .. - 2.8.4 - 2.24 + 3.1.10 @@ -24,69 +24,168 @@ com.amazonaws.serverless aws-serverless-java-container-core - 0.5-SNAPSHOT + ${project.version} + + + com.amazonaws.serverless + aws-serverless-java-container-core + ${project.version} + tests + test-jar + test - org.glassfish.jersey.core jersey-server ${jersey.version} - - - com.fasterxml.jackson.core - jackson-core - ${jackson.version} - - - - - com.fasterxml.jackson.core - jackson-databind - ${jackson.version} - - - - - com.fasterxml.jackson.core - jackson-annotations - ${jackson.version} - - - - junit - junit - 4.12 + org.glassfish.jersey.inject + jersey-hk2 + ${jersey.version} + true test + + + jakarta.annotation + jakarta.annotation-api + + - commons-codec commons-codec - 1.10 + 1.18.0 test - - com.fasterxml.jackson.jaxrs - jackson-jaxrs-json-provider - 2.8.4 + org.glassfish.jersey.media + jersey-media-json-jackson + ${jersey.version} test + true + + + com.fasterxml.jackson.core + jackson-annotations + + + tools.jackson.core + jackson-databind + + + tools.jackson.core + jackson-core + + - org.glassfish.jersey.media - jersey-media-json-jackson - 2.24 + jersey-media-multipart + ${jersey.version} test + + org.junit.jupiter + junit-jupiter + test + + + jakarta.ws.rs + jakarta.ws.rs-api + 3.1.0 + + + + + org.jacoco + jacoco-maven-plugin + + ${basedir}/target/coverage-reports/jacoco-unit.exec + ${basedir}/target/coverage-reports/jacoco-unit.exec + + + + default-prepare-agent + + prepare-agent + + + + jacoco-site + package + + report + + + + jacoco-check + test + + check + + + true + + BUNDLE + + + INSTRUCTION + COVEREDRATIO + ${jacoco.minCoverage} + + + + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + false + + + + com.github.spotbugs + spotbugs-maven-plugin + + + + analyze-compile + compile + + check + + + + + + org.owasp + dependency-check-maven + ${dependencyCheck.version} + + true + + ${project.basedir}/../owasp-suppression.xml + + 7 + false + + + + + diff --git a/aws-serverless-java-container-jersey/src/main/java/com/amazonaws/serverless/proxy/jersey/JerseyAwsProxyRequestReader.java b/aws-serverless-java-container-jersey/src/main/java/com/amazonaws/serverless/proxy/jersey/JerseyAwsProxyRequestReader.java deleted file mode 100644 index ced94ba7..00000000 --- a/aws-serverless-java-container-jersey/src/main/java/com/amazonaws/serverless/proxy/jersey/JerseyAwsProxyRequestReader.java +++ /dev/null @@ -1,153 +0,0 @@ -/* - * Copyright 2016 Amazon.com, Inc. or its affiliates. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance - * with the License. A copy of the License is located at - * - * http://aws.amazon.com/apache2.0/ - * - * or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES - * OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions - * and limitations under the License. - */ -package com.amazonaws.serverless.proxy.jersey; - - -import com.amazonaws.serverless.exceptions.InvalidRequestEventException; -import com.amazonaws.serverless.proxy.internal.RequestReader; -import com.amazonaws.serverless.proxy.internal.model.AwsProxyRequest; -import com.amazonaws.services.lambda.runtime.Context; -import org.glassfish.jersey.internal.MapPropertiesDelegate; -import org.glassfish.jersey.internal.PropertiesDelegate; -import org.glassfish.jersey.server.ContainerRequest; - -import javax.ws.rs.core.SecurityContext; -import java.io.ByteArrayInputStream; -import java.net.URI; -import java.net.URISyntaxException; -import java.util.Base64; - - -/** - * Default implementation of the RequestReader object. This object reads an incoming AwsProxyRequest - * event and transform it into a Jersey ContainerRequest object. The object sets three custom properties in the - * request's PropertiesDelegate object: The API Gateway request context, the Map of stage variables, and the - * Lambda context object. - * - * The useStageAsBasePath configuration variable lets you set whether the stage name should be included in the - * request path passed to the Jersey application handler. - */ -public class JerseyAwsProxyRequestReader extends RequestReader { - - //------------------------------------------------------------- - // Variables - Private - //------------------------------------------------------------- - - private boolean useStageAsBasePath = false; - - - //------------------------------------------------------------- - // Variables - Private - Static - //------------------------------------------------------------- - - private static AwsProxyRequest currentRequest; - private static Context currentLambdaContext; - - - //------------------------------------------------------------- - // Methods - Implementation - //------------------------------------------------------------- - - /** - * Reads an request object generated by an AWS_PROXY integration in API Gateway and transforms it into a Jersey - * ContainerRequest object. - * - * @param request The incoming request object - * @param securityContext A jax-rs SecurityContext object (@see com.amazonaws.serverless.proxy.internal.SecurityContextWriter) - * @param lambdaContext The AWS Lambda context for the request - * @return A populated ContainerRequest object - * @throws InvalidRequestEventException When the method fails to parse the incoming request - */ - @Override - public ContainerRequest readRequest(AwsProxyRequest request, SecurityContext securityContext, Context lambdaContext) - throws InvalidRequestEventException { - currentRequest = request; - currentLambdaContext = lambdaContext; - - URI basePathUri; - URI requestPathUri; - String basePath = useStageAsBasePath ? request.getRequestContext().getStage() : "/"; - - try { - basePathUri = new URI(basePath); - } catch (URISyntaxException e) { - throw new InvalidRequestEventException("Error while generating base path URI: " + basePath, e); - } - - try { - requestPathUri = new URI(request.getQueryString() == null ? request.getPath() : request.getPath() + request.getQueryString()); - } catch (URISyntaxException e) { - throw new InvalidRequestEventException("Error while generating request path URI: " + request.getPath(), e); - } - - PropertiesDelegate apiGatewayProperties = new MapPropertiesDelegate(); - apiGatewayProperties.setProperty(API_GATEWAY_CONTEXT_PROPERTY, request.getRequestContext()); - apiGatewayProperties.setProperty(API_GATEWAY_STAGE_VARS_PROPERTY, request.getStageVariables()); - apiGatewayProperties.setProperty(LAMBDA_CONTEXT_PROPERTY, lambdaContext); - - ContainerRequest requestContext = new ContainerRequest(basePathUri, requestPathUri, request.getHttpMethod(), securityContext, apiGatewayProperties); - - if (request.getBody() != null) { - if (request.isBase64Encoded()) { - requestContext.setEntityStream(new ByteArrayInputStream(Base64.getDecoder().decode(request.getBody()))); - } else { - requestContext.setEntityStream(new ByteArrayInputStream(request.getBody().getBytes())); - } - } - - if (request.getHeaders() != null) { - for (final String headerName : request.getHeaders().keySet()) { - requestContext.headers(headerName, request.getHeaders().get(headerName)); - } - } - - return requestContext; - } - - - //------------------------------------------------------------- - // Methods - Getter/Setter - //------------------------------------------------------------- - - /** - * Sets whether the stage name should be used as base path for the Jersey request. The default value for this property is false - * @param useStageAsBasePath True if the stage name should be included in the request path, false otherwise - */ - public void setUseStageAsBasePath(boolean useStageAsBasePath) { - this.useStageAsBasePath = useStageAsBasePath; - } - - - //------------------------------------------------------------- - // Methods - Protected - //------------------------------------------------------------- - - @Override - protected Class getRequestClass() { - return AwsProxyRequest.class; - } - - - //------------------------------------------------------------- - // Methods - Package - //------------------------------------------------------------- - - public static AwsProxyRequest getCurrentRequest() { - return currentRequest; - } - - - public static Context getCurrentLambdaContext() { - return currentLambdaContext; - } -} diff --git a/aws-serverless-java-container-jersey/src/main/java/com/amazonaws/serverless/proxy/jersey/JerseyAwsProxyResponseWriter.java b/aws-serverless-java-container-jersey/src/main/java/com/amazonaws/serverless/proxy/jersey/JerseyAwsProxyResponseWriter.java deleted file mode 100644 index 80ea48fa..00000000 --- a/aws-serverless-java-container-jersey/src/main/java/com/amazonaws/serverless/proxy/jersey/JerseyAwsProxyResponseWriter.java +++ /dev/null @@ -1,73 +0,0 @@ -/* - * Copyright 2016 Amazon.com, Inc. or its affiliates. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance - * with the License. A copy of the License is located at - * - * http://aws.amazon.com/apache2.0/ - * - * or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES - * OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions - * and limitations under the License. - */ -package com.amazonaws.serverless.proxy.jersey; - - -import com.amazonaws.serverless.exceptions.InvalidResponseObjectException; -import com.amazonaws.serverless.proxy.internal.ResponseWriter; -import com.amazonaws.serverless.proxy.internal.model.AwsProxyResponse; -import com.amazonaws.services.lambda.runtime.Context; - -import java.util.Base64; - - -/** - * Transforms the data from a JerseyResponseWriter object into a valid AwsProxyResponse object. - * - * @see com.amazonaws.serverless.proxy.jersey.JerseyResponseWriter - * @see AwsProxyResponse - */ -public class JerseyAwsProxyResponseWriter extends ResponseWriter { - - //------------------------------------------------------------- - // Methods - Implementation - //------------------------------------------------------------- - - /** - * Reads the data from the JerseyResponseWriter object and creates an AwsProxyResponse object - * - * @param containerResponse The container response or response reader object - * @param lambdaContext The context for the Lambda function execution - * @return An initialized AwsProxyResponse object - * @throws InvalidResponseObjectException When the library fails to read the JerseyResponseWriter object - */ - @Override - public AwsProxyResponse writeResponse(JerseyResponseWriter containerResponse, Context lambdaContext) - throws InvalidResponseObjectException { - try { - AwsProxyResponse response = new AwsProxyResponse(); - response.setStatusCode(containerResponse.getStatusCode()); - - if (containerResponse.getHeaders() != null && containerResponse.getHeaders().size() > 0) { - response.setHeaders(containerResponse.getHeaders()); - } - - if (containerResponse.getResponseBody() != null) { - String responseString; - - if (isValidUtf8(containerResponse.getResponseBody().toByteArray())) { - responseString = new String(containerResponse.getResponseBody().toByteArray()); - } else { - responseString = Base64.getMimeEncoder().encodeToString(containerResponse.getResponseBody().toByteArray()); - response.setBase64Encoded(true); - } - - response.setBody(responseString); - } - - return response; - } catch (Exception ex) { - throw new InvalidResponseObjectException(ex.getMessage(), ex); - } - } -} diff --git a/aws-serverless-java-container-jersey/src/main/java/com/amazonaws/serverless/proxy/jersey/JerseyHandlerFilter.java b/aws-serverless-java-container-jersey/src/main/java/com/amazonaws/serverless/proxy/jersey/JerseyHandlerFilter.java new file mode 100644 index 00000000..79d030bd --- /dev/null +++ b/aws-serverless-java-container-jersey/src/main/java/com/amazonaws/serverless/proxy/jersey/JerseyHandlerFilter.java @@ -0,0 +1,254 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance + * with the License. A copy of the License is located at + * + * http://aws.amazon.com/apache2.0/ + * + * or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES + * OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions + * and limitations under the License. + */ +package com.amazonaws.serverless.proxy.jersey; + +import com.amazonaws.serverless.proxy.internal.LambdaContainerHandler; +import com.amazonaws.serverless.proxy.internal.testutils.Timer; +import com.amazonaws.serverless.proxy.jersey.suppliers.AwsProxyServletRequestSupplier; + +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import org.glassfish.jersey.internal.MapPropertiesDelegate; +import org.glassfish.jersey.internal.PropertiesDelegate; +import org.glassfish.jersey.server.ApplicationHandler; +import org.glassfish.jersey.server.ContainerRequest; +import org.glassfish.jersey.server.ResourceConfig; +import org.glassfish.jersey.server.spi.Container; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import jakarta.servlet.Filter; +import jakarta.servlet.FilterChain; +import jakarta.servlet.FilterConfig; +import jakarta.servlet.ServletException; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.ws.rs.InternalServerErrorException; +import jakarta.ws.rs.core.Application; +import jakarta.ws.rs.core.SecurityContext; +import jakarta.ws.rs.core.UriBuilder; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.util.Collections; +import java.util.Enumeration; +import java.util.Locale; +import java.util.concurrent.CountDownLatch; + +import static com.amazonaws.serverless.proxy.RequestReader.API_GATEWAY_CONTEXT_PROPERTY; +import static com.amazonaws.serverless.proxy.RequestReader.API_GATEWAY_STAGE_VARS_PROPERTY; +import static com.amazonaws.serverless.proxy.RequestReader.JAX_SECURITY_CONTEXT_PROPERTY; +import static com.amazonaws.serverless.proxy.RequestReader.LAMBDA_CONTEXT_PROPERTY; + +/** + * Servlet filter class that calls Jersey's ApplicationHandler. Given a Jax RS Application object, this class + * initializes a Jersey {@link ApplicationHandler} and calls its handle method. Requests are transformed + * to ContainerRequest objects by the {@link #servletRequestToContainerRequest(ServletRequest)} method. + * Jersey responses are written directly to the ServletResponse by the {@link JerseyServletResponseWriter}. + */ +public class JerseyHandlerFilter implements Filter, Container { + public static final String JERSEY_SERVLET_REQUEST_PROPERTY = "com.amazonaws.serverless.jersey.servletRequest"; + public static final String JERSEY_SERVLET_RESPONSE_PROPERTY = "com.amazonaws.serverless.jersey.servletResponse"; + + private ApplicationHandler jersey; + private Application app; + private Logger log = LoggerFactory.getLogger(JerseyHandlerFilter.class); + private URI baseUri; + + + /** + * Constructs a new handler filter with a Jax RS application object. + * @param jaxApplication The JAX RS application to load + */ + JerseyHandlerFilter(Application jaxApplication) { + Timer.start("JERSEY_FILTER_CONSTRUCTOR"); + app = jaxApplication; + + jersey = new ApplicationHandler(app); + jersey.onStartup(this); + Timer.stop("JERSEY_FILTER_CONSTRUCTOR"); + } + + @Override + public void init(FilterConfig filterConfig) { + log.info("Initialize Jersey application handler"); + } + + + @Override + public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) + throws IOException, ServletException { + Timer.start("JERSEY_FILTER_DOFILTER"); + // we use a latch to make the processing inside Jersey synchronous + CountDownLatch jerseyLatch = new CountDownLatch(1); + + ContainerRequest req = servletRequestToContainerRequest(servletRequest); + req.setWriter(new JerseyServletResponseWriter(servletResponse, jerseyLatch)); + + req.setProperty(JERSEY_SERVLET_RESPONSE_PROPERTY, servletResponse); + + jersey.handle(req); + try { + jerseyLatch.await(); + } catch (InterruptedException e) { + log.error("Interrupted while processing request", e); + throw new InternalServerErrorException(e); + } + Timer.stop("JERSEY_FILTER_DOFILTER"); + filterChain.doFilter(servletRequest, servletResponse); + } + + @Override + public void destroy() { + log.info("Jersey filter destroy"); + jersey.onShutdown(this); + } + + + /** + * Given a ServletRequest generates the corresponding Jersey ContainerRequest object. The request URI is + * built from the request's getPathInfo() method. The container request also contains the + * API Gateway context, stage variables, and Lambda context properties. The original servlet request is + * also embedded in a property of the container request to allow injection by the + * {@link AwsProxyServletRequestSupplier}. + * @param request The incoming servlet request + * @return A populated ContainerRequest object. + * @throws RuntimeException if we could not read the servlet request input stream. + */ + // suppressing warnings because I expect headers and query strings to be checked by the underlying + // servlet implementation + @SuppressFBWarnings({ "SERVLET_HEADER", "SERVLET_QUERY_STRING" }) + private ContainerRequest servletRequestToContainerRequest(ServletRequest request) { + Timer.start("JERSEY_SERVLET_REQUEST_TO_CONTAINER"); + HttpServletRequest servletRequest = (HttpServletRequest)request; + + if (baseUri == null) { + baseUri = getBaseUri(request, "/"); + } + + String requestFullPath = servletRequest.getRequestURI(); + if (LambdaContainerHandler.getContainerConfig().getServiceBasePath() != null && LambdaContainerHandler.getContainerConfig().isStripBasePath()) { + if (requestFullPath.startsWith(LambdaContainerHandler.getContainerConfig().getServiceBasePath())) { + requestFullPath = requestFullPath.replaceFirst(LambdaContainerHandler.getContainerConfig().getServiceBasePath(), ""); + if (!requestFullPath.startsWith("/")) { + requestFullPath = "/" + requestFullPath; + } + } + } + UriBuilder uriBuilder = UriBuilder.fromUri(baseUri).path(requestFullPath); + uriBuilder.replaceQuery(servletRequest.getQueryString()); + + PropertiesDelegate apiGatewayProperties = new MapPropertiesDelegate(); + apiGatewayProperties.setProperty(API_GATEWAY_CONTEXT_PROPERTY, servletRequest.getAttribute(API_GATEWAY_CONTEXT_PROPERTY)); + apiGatewayProperties.setProperty(API_GATEWAY_STAGE_VARS_PROPERTY, servletRequest.getAttribute(API_GATEWAY_STAGE_VARS_PROPERTY)); + apiGatewayProperties.setProperty(LAMBDA_CONTEXT_PROPERTY, servletRequest.getAttribute(LAMBDA_CONTEXT_PROPERTY)); + apiGatewayProperties.setProperty(JERSEY_SERVLET_REQUEST_PROPERTY, servletRequest); + + ContainerRequest requestContext = new ContainerRequest( + null, // jersey uses "/" by default + uriBuilder.build(), + servletRequest.getMethod().toUpperCase(Locale.ENGLISH), + (SecurityContext)servletRequest.getAttribute(JAX_SECURITY_CONTEXT_PROPERTY), + apiGatewayProperties); + + InputStream requestInputStream; + try { + requestInputStream = servletRequest.getInputStream(); + if (requestInputStream != null) { + requestContext.setEntityStream(requestInputStream); + } + } catch (IOException e) { + log.error("Could not read input stream from request", e); + throw new RuntimeException("Could not read request input stream", e); + } + + Enumeration headerNames = servletRequest.getHeaderNames(); + + while (headerNames.hasMoreElements()) { + String headerKey = headerNames.nextElement(); + requestContext.getHeaders().addAll(headerKey, Collections.list(servletRequest.getHeaders(headerKey))); + } + + Timer.stop("JERSEY_SERVLET_REQUEST_TO_CONTAINER"); + return requestContext; + } + + @SuppressFBWarnings("SERVLET_SERVER_NAME") + private URI getBaseUri(ServletRequest request, String basePath) { + String finalBasePath = basePath; + if (!finalBasePath.startsWith("/")) { + finalBasePath = "/" + finalBasePath; + } + String uriString = new StringBuilder().append(request.getScheme()) + .append("://") + .append(request.getServerName()) + .append(":") + .append(request.getServerPort()) + .append(finalBasePath).toString(); + return UriBuilder.fromUri(uriString).build(); + } + + //------------------------------------------------------------- + // Implementation - Container + //------------------------------------------------------------- + + + @Override + public ResourceConfig getConfiguration() { + return jersey.getConfiguration(); + } + + + @Override + public ApplicationHandler getApplicationHandler() { + return jersey; + } + + + /** + * Shuts down and restarts the application handler in the current container. The ApplicationHandler + * object is re-initialized with the Application object initially set in the LambdaContainer.getInstance() + * call. + */ + @Override + public void reload() { + Timer.start("JERSEY_RELOAD_DEFAULT"); + jersey.onShutdown(this); + + jersey = new ApplicationHandler(app); + + jersey.onReload(this); + jersey.onStartup(this); + Timer.stop("JERSEY_RELOAD_DEFAULT"); + } + + + /** + * Restarts the application handler and configures a different Application object. The new application + * resets the one currently configured in the container. + * @param resourceConfig An initialized Application + */ + @Override + public void reload(ResourceConfig resourceConfig) { + Timer.start("JERSEY_RELOAD_CONFIG"); + jersey.onShutdown(this); + + app = resourceConfig; + jersey = new ApplicationHandler(resourceConfig); + + jersey.onReload(this); + jersey.onStartup(this); + Timer.stop("JERSEY_RELOAD_CONFIG"); + } +} diff --git a/aws-serverless-java-container-jersey/src/main/java/com/amazonaws/serverless/proxy/jersey/JerseyLambdaContainerHandler.java b/aws-serverless-java-container-jersey/src/main/java/com/amazonaws/serverless/proxy/jersey/JerseyLambdaContainerHandler.java index ad3a0a0c..43bd7eac 100644 --- a/aws-serverless-java-container-jersey/src/main/java/com/amazonaws/serverless/proxy/jersey/JerseyLambdaContainerHandler.java +++ b/aws-serverless-java-container-jersey/src/main/java/com/amazonaws/serverless/proxy/jersey/JerseyLambdaContainerHandler.java @@ -13,24 +13,32 @@ package com.amazonaws.serverless.proxy.jersey; -import com.amazonaws.serverless.proxy.internal.AwsProxyExceptionHandler; -import com.amazonaws.serverless.proxy.internal.AwsProxySecurityContextWriter; -import com.amazonaws.serverless.proxy.internal.ExceptionHandler; -import com.amazonaws.serverless.proxy.internal.LambdaContainerHandler; -import com.amazonaws.serverless.proxy.internal.RequestReader; -import com.amazonaws.serverless.proxy.internal.ResponseWriter; -import com.amazonaws.serverless.proxy.internal.SecurityContextWriter; -import com.amazonaws.serverless.proxy.internal.model.AwsProxyRequest; -import com.amazonaws.serverless.proxy.internal.model.AwsProxyResponse; - +import com.amazonaws.serverless.proxy.*; +import com.amazonaws.serverless.proxy.internal.servlet.*; +import com.amazonaws.serverless.proxy.internal.testutils.Timer; +import com.amazonaws.serverless.proxy.jersey.suppliers.AwsProxyServletContextSupplier; +import com.amazonaws.serverless.proxy.jersey.suppliers.AwsProxyServletRequestSupplier; +import com.amazonaws.serverless.proxy.jersey.suppliers.AwsProxyServletResponseSupplier; +import com.amazonaws.serverless.proxy.model.AwsProxyRequest; +import com.amazonaws.serverless.proxy.model.AwsProxyResponse; + +import com.amazonaws.serverless.proxy.model.HttpApiV2ProxyRequest; import com.amazonaws.services.lambda.runtime.Context; -import org.glassfish.jersey.server.ApplicationHandler; -import org.glassfish.jersey.server.ContainerRequest; + +import org.glassfish.jersey.internal.inject.AbstractBinder; +import org.glassfish.jersey.internal.inject.InjectionManager; +import org.glassfish.jersey.process.internal.RequestScoped; import org.glassfish.jersey.server.ResourceConfig; -import org.glassfish.jersey.server.spi.Container; -import javax.ws.rs.core.Application; +import jakarta.servlet.DispatcherType; +import jakarta.servlet.FilterRegistration; +import jakarta.servlet.Servlet; +import jakarta.servlet.ServletContext; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.ws.rs.core.Application; +import java.util.EnumSet; import java.util.concurrent.CountDownLatch; @@ -58,18 +66,14 @@ * @param The type for the incoming Lambda event * @param The type for Lambda's return value */ -public class JerseyLambdaContainerHandler extends LambdaContainerHandler - implements Container { +public class JerseyLambdaContainerHandler extends AwsLambdaServletContainerHandler { //------------------------------------------------------------- // Variables - Private //------------------------------------------------------------- - // The Jersey application object - private Application jaxRsApplication; - // The Jersey app handler to route requests - private ApplicationHandler applicationHandler; - + private JerseyHandlerFilter jerseyFilter; + private boolean initialized; //------------------------------------------------------------- // Methods - Public - Static @@ -85,11 +89,38 @@ public class JerseyLambdaContainerHandler extends Lam * @return A JerseyLambdaContainerHandler object */ public static JerseyLambdaContainerHandler getAwsProxyHandler(Application jaxRsApplication) { - return new JerseyLambdaContainerHandler<>(new JerseyAwsProxyRequestReader(), - new JerseyAwsProxyResponseWriter(), - new AwsProxySecurityContextWriter(), - new AwsProxyExceptionHandler(), - jaxRsApplication); + JerseyLambdaContainerHandler newHandler = new JerseyLambdaContainerHandler<>( + AwsProxyRequest.class, + AwsProxyResponse.class, + new AwsProxyHttpServletRequestReader(), + new AwsProxyHttpServletResponseWriter(), + new AwsProxySecurityContextWriter(), + new AwsProxyExceptionHandler(), + jaxRsApplication); + newHandler.initialize(); + return newHandler; + } + + /** + * Returns an initialized JerseyLambdaContainerHandler that includes RequestReader and + * ResponseWriter objects for the HttpApiV2ProxyRequest and AwsProxyResponse + * objects. + * + * @param jaxRsApplication A configured Jax-Rs application object. For Jersey apps this can be the default + * ResourceConfig object + * @return A JerseyLambdaContainerHandler object + */ + public static JerseyLambdaContainerHandler getHttpApiV2ProxyHandler(Application jaxRsApplication) { + JerseyLambdaContainerHandler newHandler = new JerseyLambdaContainerHandler<>( + HttpApiV2ProxyRequest.class, + AwsProxyResponse.class, + new AwsHttpApiV2HttpServletRequestReader(), + new AwsProxyHttpServletResponseWriter(true), + new AwsHttpApiV2SecurityContextWriter(), + new AwsProxyExceptionHandler(), + jaxRsApplication); + newHandler.initialize(); + return newHandler; } @@ -100,91 +131,102 @@ public static JerseyLambdaContainerHandler ge /** * Private constructor for a LambdaContainer. Sets the application object, sets the ApplicationHandler, * and initializes the application using the onStartup method. - * @param jaxRsApplication A Jersey application instance. + * @param requestTypeClass The class for the expected event type + * @param responseTypeClass The class for the output type + * @param requestReader A request reader instance + * @param responseWriter A response writer instance + * @param securityContextWriter A security context writer object + * @param exceptionHandler An exception handler + * @param jaxRsApplication The JaxRs application */ - public JerseyLambdaContainerHandler(RequestReader requestReader, - ResponseWriter responseWriter, + public JerseyLambdaContainerHandler(Class requestTypeClass, + Class responseTypeClass, + RequestReader requestReader, + ResponseWriter responseWriter, SecurityContextWriter securityContextWriter, ExceptionHandler exceptionHandler, Application jaxRsApplication) { - super(requestReader, responseWriter, securityContextWriter, exceptionHandler); - - this.jaxRsApplication = jaxRsApplication; - this.applicationHandler = new ApplicationHandler(jaxRsApplication); - applicationHandler.onStartup(this); + super(requestTypeClass, responseTypeClass, requestReader, responseWriter, securityContextWriter, exceptionHandler); + Timer.start("JERSEY_CONTAINER_CONSTRUCTOR"); + initialized = false; + if (jaxRsApplication instanceof ResourceConfig) { + ((ResourceConfig)jaxRsApplication).register(new AbstractBinder() { + @Override + protected void configure() { + bindFactory(AwsProxyServletContextSupplier.class) + .proxy(true) + .proxyForSameScope(true) + .to(ServletContext.class) + .in(RequestScoped.class); + bindFactory(AwsProxyServletRequestSupplier.class) + .proxy(true) + .proxyForSameScope(true) + .to(HttpServletRequest.class) + .in(RequestScoped.class); + bindFactory(AwsProxyServletResponseSupplier.class) + .proxy(true) + .proxyForSameScope(true) + .to(HttpServletResponse.class) + .in(RequestScoped.class); + } + }); + } + + this.jerseyFilter = new JerseyHandlerFilter(jaxRsApplication); + Timer.stop("JERSEY_CONTAINER_CONSTRUCTOR"); } - //------------------------------------------------------------- - // Implementation - Container + // Methods - Implementation //------------------------------------------------------------- - /** - * Gets the configuration currently set in the internal ApplicationHandler - * - * @return The Jersey's ResourceConfig object currently running in the container - */ - public ResourceConfig getConfiguration() { - return applicationHandler.getConfiguration(); - } - - - /** - * The instantiated ApplicationHandler object used by this container - * - * @return Jersey's ApplicationHander object - */ - public ApplicationHandler getApplicationHandler() { - return applicationHandler; - } - - - /** - * Shuts down and restarts the application handler in the current container. The ApplicationHandler - * object is re-initialized with the Application object initially set in the LambdaContainer.getInstance() - * call. - */ - public void reload() { - applicationHandler.onShutdown(this); - - this.applicationHandler = new ApplicationHandler(jaxRsApplication); - - applicationHandler.onReload(this); - applicationHandler.onStartup(this); + @Override + protected void handleRequest(HttpServletRequest httpServletRequest, AwsHttpServletResponse httpServletResponse, Context lambdaContext) + throws Exception { + // we retain the initialized property for backward compatibility + if (!initialized) { + initialize(); + } + Timer.start("JERSEY_HANDLE_REQUEST"); + + if (AwsHttpServletRequest.class.isAssignableFrom(httpServletRequest.getClass())) { + ((AwsHttpServletRequest)httpServletRequest).setServletContext(getServletContext()); + } + + doFilter(httpServletRequest, httpServletResponse, null); + Timer.stop("JERSEY_HANDLE_REQUEST"); } - - /** - * Restarts the application handler and configures a different Application object. The new application - * resets the one currently configured in the container. - * @param resourceConfig An initialized Application - */ - public void reload(ResourceConfig resourceConfig) { - applicationHandler.onShutdown(this); - - this.jaxRsApplication = resourceConfig; - this.applicationHandler = new ApplicationHandler(resourceConfig); - - applicationHandler.onReload(this); - applicationHandler.onStartup(this); + @Override + protected AwsHttpServletResponse getContainerResponse(HttpServletRequest request, CountDownLatch latch) { + return new AwsHttpServletResponse(request, latch); } - - //------------------------------------------------------------- - // Methods - Implementation - //------------------------------------------------------------- - @Override - protected JerseyResponseWriter getContainerResponse(CountDownLatch latch) { - return new JerseyResponseWriter(latch); + public void initialize() { + Timer.start("JERSEY_COLD_START_INIT"); + + // manually add the filter to the chain. This should the last one and match all uris + FilterRegistration.Dynamic jerseyFilterReg = getServletContext().addFilter("JerseyFilter", jerseyFilter); + jerseyFilterReg.addMappingForUrlPatterns( + EnumSet.of(DispatcherType.REQUEST, DispatcherType.ASYNC, DispatcherType.INCLUDE, DispatcherType.FORWARD), + true, "/*" + ); + + Timer.stop("JERSEY_COLD_START_INIT"); + initialized = true; } - @Override - protected void handleRequest(ContainerRequest containerRequest, JerseyResponseWriter jerseyResponseWriter, Context lambdaContext) { - containerRequest.setWriter(jerseyResponseWriter); + public InjectionManager getInjectionManager() { + if (!initialized) { + initialize(); + } + return jerseyFilter.getApplicationHandler().getInjectionManager(); + } - applicationHandler.handle(containerRequest); + public Servlet getServlet() { + return null; } } diff --git a/aws-serverless-java-container-jersey/src/main/java/com/amazonaws/serverless/proxy/jersey/JerseyResponseWriter.java b/aws-serverless-java-container-jersey/src/main/java/com/amazonaws/serverless/proxy/jersey/JerseyServletResponseWriter.java similarity index 59% rename from aws-serverless-java-container-jersey/src/main/java/com/amazonaws/serverless/proxy/jersey/JerseyResponseWriter.java rename to aws-serverless-java-container-jersey/src/main/java/com/amazonaws/serverless/proxy/jersey/JerseyServletResponseWriter.java index 78c0ae93..870390f8 100644 --- a/aws-serverless-java-container-jersey/src/main/java/com/amazonaws/serverless/proxy/jersey/JerseyResponseWriter.java +++ b/aws-serverless-java-container-jersey/src/main/java/com/amazonaws/serverless/proxy/jersey/JerseyServletResponseWriter.java @@ -13,13 +13,21 @@ package com.amazonaws.serverless.proxy.jersey; +import com.amazonaws.serverless.proxy.internal.testutils.Timer; + +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import org.glassfish.jersey.server.ContainerException; import org.glassfish.jersey.server.ContainerResponse; import org.glassfish.jersey.server.spi.ContainerResponseWriter; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import jakarta.servlet.ServletResponse; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.ws.rs.InternalServerErrorException; -import java.io.ByteArrayOutputStream; +import java.io.IOException; import java.io.OutputStream; -import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.concurrent.CountDownLatch; @@ -31,18 +39,16 @@ * AwsProxyResponse object. The response object is passed in the constructor alongside an ExceptionHandler * instance. */ -class JerseyResponseWriter +class JerseyServletResponseWriter implements ContainerResponseWriter { //------------------------------------------------------------- // Variables - Private //------------------------------------------------------------- - private CountDownLatch responseMutex; - private Map headers; - private int statusCode; - private ByteArrayOutputStream responseBody; - + private HttpServletResponse servletResponse; + private Logger log = LoggerFactory.getLogger(JerseyServletResponseWriter.class); + private CountDownLatch jerseyLatch; //------------------------------------------------------------- // Constructors @@ -50,11 +56,12 @@ class JerseyResponseWriter /** * Creates a new response writer. - * @param latch The latch object is used to synchronize the response request handling and response generation for - * AWS Lambda + * @param resp The current ServletResponse from the container */ - JerseyResponseWriter(CountDownLatch latch) { - this.responseMutex = latch; + public JerseyServletResponseWriter(ServletResponse resp, CountDownLatch latch) { + assert resp instanceof HttpServletResponse; + servletResponse = (HttpServletResponse)resp; + jerseyLatch = latch; } @@ -69,65 +76,58 @@ class JerseyResponseWriter * @return An OutputStream for Jersey to write the response body to * @throws ContainerException default Jersey declaration */ + @SuppressFBWarnings("HTTP_RESPONSE_SPLITTING") // suppress this because headers are sanitized in the setHeader method of the servlet response public OutputStream writeResponseStatusAndHeaders(long contentLength, ContainerResponse containerResponse) throws ContainerException { - statusCode = containerResponse.getStatusInfo().getStatusCode(); - - if (headers == null) { - headers = new HashMap<>(); - } - + Timer.start("JERSEY_WRITE_RESPONSE"); + servletResponse.setStatus(containerResponse.getStatusInfo().getStatusCode()); for (final Map.Entry> e : containerResponse.getStringHeaders().entrySet()) { for (final String value : e.getValue()) { - headers.put(e.getKey(), value); + servletResponse.setHeader(e.getKey(), value); } } + try { + Timer.stop("JERSEY_WRITE_RESPONSE"); + return servletResponse.getOutputStream(); + } catch (IOException e) { + log.error("Could not get servlet response output stream", e); + Timer.stop("JERSEY_WRITE_RESPONSE"); + throw new InternalServerErrorException("Could not get servlet response output stream", e); + } - responseBody = new ByteArrayOutputStream(); - - return responseBody; } public boolean suspend(long l, TimeUnit timeUnit, TimeoutHandler timeoutHandler) { + log.debug("Suspend"); return false; } public void setSuspendTimeout(long l, TimeUnit timeUnit) throws IllegalStateException { + log.debug("SuspectTimeout"); } public void commit() { - responseMutex.countDown(); + try { + log.debug("commit"); + jerseyLatch.countDown(); + servletResponse.flushBuffer(); + } catch (IOException e) { + log.error("Could not commit response", e); + throw new InternalServerErrorException(e); + } } public void failure(Throwable throwable) { - responseMutex.countDown(); + log.error("failure", throwable); + throw new InternalServerErrorException("Jersey failed to process request", throwable); } public boolean enableResponseBuffering() { return false; } - - - //------------------------------------------------------------- - // Methods - Package - //------------------------------------------------------------- - - Map getHeaders() { - return headers; - } - - - int getStatusCode() { - return statusCode; - } - - - ByteArrayOutputStream getResponseBody() { - return responseBody; - } } diff --git a/aws-serverless-java-container-jersey/src/main/java/com/amazonaws/serverless/proxy/jersey/factory/AwsProxyServletRequestFactory.java b/aws-serverless-java-container-jersey/src/main/java/com/amazonaws/serverless/proxy/jersey/factory/AwsProxyServletRequestFactory.java deleted file mode 100644 index 667d5326..00000000 --- a/aws-serverless-java-container-jersey/src/main/java/com/amazonaws/serverless/proxy/jersey/factory/AwsProxyServletRequestFactory.java +++ /dev/null @@ -1,74 +0,0 @@ -/* - * Copyright 2016 Amazon.com, Inc. or its affiliates. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance - * with the License. A copy of the License is located at - * - * http://aws.amazon.com/apache2.0/ - * - * or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES - * OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions - * and limitations under the License. - */ -package com.amazonaws.serverless.proxy.jersey.factory; - - -import com.amazonaws.serverless.exceptions.InvalidRequestEventException; -import com.amazonaws.serverless.proxy.internal.AwsProxySecurityContextWriter; -import com.amazonaws.serverless.proxy.internal.servlet.AwsProxyHttpServletRequest; -import com.amazonaws.serverless.proxy.internal.servlet.AwsProxyHttpServletRequestReader; -import com.amazonaws.serverless.proxy.jersey.JerseyAwsProxyRequestReader; - -import org.glassfish.hk2.api.Factory; - -import javax.servlet.http.HttpServletRequest; - -/** - * Implementation of Jersey's Factory object for HttpServletRequest objects. This can be used - * by Jersey to generate a Servlet request given an AwsProxyRequest event. - * - *
- * 
- *     ResourceConfig app = new ResourceConfig().packages("my.app.package")
- *         .register(new AbstractBinder() {
- *             {@literal @}Override
- *             protected void configure() {
- *                 bindFactory(AwsProxyServletRequestFactory.class)
- *                     .to(HttpServletRequest.class)
- *                     .in(RequestScoped.class);
- *            }
- *       });
- * 
- * 
- */ -public class AwsProxyServletRequestFactory - implements Factory { - - private static AwsProxyHttpServletRequestReader requestReader = new AwsProxyHttpServletRequestReader(); - - //------------------------------------------------------------- - // Implementation - Factory - //------------------------------------------------------------- - - @Override - public HttpServletRequest provide() { - return getRequest(); - } - - - @Override - public void dispose(HttpServletRequest httpServletRequest) { - } - - public static HttpServletRequest getRequest() { - try { - return requestReader.readRequest(JerseyAwsProxyRequestReader.getCurrentRequest(), - AwsProxySecurityContextWriter.getCurrentContext(), - JerseyAwsProxyRequestReader.getCurrentLambdaContext()); - } catch (InvalidRequestEventException e) { - e.printStackTrace(); - return null; - } - - } -} diff --git a/aws-serverless-java-container-jersey/src/main/java/com/amazonaws/serverless/proxy/jersey/factory/AwsProxyServletContextFactory.java b/aws-serverless-java-container-jersey/src/main/java/com/amazonaws/serverless/proxy/jersey/suppliers/AwsProxyServletContextSupplier.java similarity index 52% rename from aws-serverless-java-container-jersey/src/main/java/com/amazonaws/serverless/proxy/jersey/factory/AwsProxyServletContextFactory.java rename to aws-serverless-java-container-jersey/src/main/java/com/amazonaws/serverless/proxy/jersey/suppliers/AwsProxyServletContextSupplier.java index 4144d8db..4d93a89a 100644 --- a/aws-serverless-java-container-jersey/src/main/java/com/amazonaws/serverless/proxy/jersey/factory/AwsProxyServletContextFactory.java +++ b/aws-serverless-java-container-jersey/src/main/java/com/amazonaws/serverless/proxy/jersey/suppliers/AwsProxyServletContextSupplier.java @@ -10,12 +10,19 @@ * OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions * and limitations under the License. */ -package com.amazonaws.serverless.proxy.jersey.factory; +package com.amazonaws.serverless.proxy.jersey.suppliers; -import org.glassfish.hk2.api.Factory; +import org.glassfish.jersey.server.ContainerRequest; -import javax.servlet.ServletContext; +import jakarta.servlet.ServletContext; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.ws.rs.InternalServerErrorException; +import jakarta.ws.rs.core.Context; + +import java.util.function.Supplier; + +import static com.amazonaws.serverless.proxy.jersey.JerseyHandlerFilter.JERSEY_SERVLET_REQUEST_PROPERTY; /** @@ -28,7 +35,7 @@ * .register(new AbstractBinder() { * {@literal @}Override * protected void configure() { - * bindFactory(AwsProxyServletContextFactory.class) + * bindFactory(AwsProxyServletContextSupplier.class) * .to(ServletContext.class) * .in(RequestScoped.class); * } @@ -36,15 +43,22 @@ * * */ -public class AwsProxyServletContextFactory implements Factory { +public class AwsProxyServletContextSupplier implements Supplier { + @Context ContainerRequest currentRequest; + @Override - public ServletContext provide() { - return AwsProxyServletRequestFactory.getRequest().getServletContext(); + public ServletContext get() { + return getServletContext(); } + private ServletContext getServletContext() { + HttpServletRequest req = (HttpServletRequest)currentRequest.getProperty(JERSEY_SERVLET_REQUEST_PROPERTY); - @Override - public void dispose(ServletContext servletContext) { + if (req == null) { + throw new InternalServerErrorException("Could not find servlet request in context"); + } + ServletContext ctx = req.getServletContext(); + return ctx; } } diff --git a/aws-serverless-java-container-jersey/src/main/java/com/amazonaws/serverless/proxy/jersey/suppliers/AwsProxyServletRequestSupplier.java b/aws-serverless-java-container-jersey/src/main/java/com/amazonaws/serverless/proxy/jersey/suppliers/AwsProxyServletRequestSupplier.java new file mode 100644 index 00000000..c3a4653b --- /dev/null +++ b/aws-serverless-java-container-jersey/src/main/java/com/amazonaws/serverless/proxy/jersey/suppliers/AwsProxyServletRequestSupplier.java @@ -0,0 +1,60 @@ +/* + * Copyright 2016 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance + * with the License. A copy of the License is located at + * + * http://aws.amazon.com/apache2.0/ + * + * or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES + * OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions + * and limitations under the License. + */ +package com.amazonaws.serverless.proxy.jersey.suppliers; + + +import org.glassfish.jersey.server.ContainerRequest; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.ws.rs.core.Context; + +import java.util.function.Supplier; + +import static com.amazonaws.serverless.proxy.jersey.JerseyHandlerFilter.JERSEY_SERVLET_REQUEST_PROPERTY; + + +/** + * Implementation of Jersey's Factory object for HttpServletRequest objects. This can be used + * by Jersey to generate a Servlet request given an AwsProxyRequest event. + * + *
+ * 
+ *     ResourceConfig app = new ResourceConfig().packages("my.app.package")
+ *         .register(new AbstractBinder() {
+ *             {@literal @}Override
+ *             protected void configure() {
+ *                 bindFactory(AwsProxyServletRequestSupplier.class)
+ *                     .to(HttpServletRequest.class)
+ *                     .in(RequestScoped.class);
+ *            }
+ *       });
+ * 
+ * 
+ */ +public class AwsProxyServletRequestSupplier implements Supplier { + + @Context ContainerRequest currentRequest; + + //------------------------------------------------------------- + // Implementation - Factory + //------------------------------------------------------------- + + @Override + public HttpServletRequest get() { + return getServletRequest(); + } + + private HttpServletRequest getServletRequest() { + return (HttpServletRequest)currentRequest.getProperty(JERSEY_SERVLET_REQUEST_PROPERTY); + } +} diff --git a/aws-serverless-java-container-jersey/src/main/java/com/amazonaws/serverless/proxy/jersey/suppliers/AwsProxyServletResponseSupplier.java b/aws-serverless-java-container-jersey/src/main/java/com/amazonaws/serverless/proxy/jersey/suppliers/AwsProxyServletResponseSupplier.java new file mode 100644 index 00000000..d4123f22 --- /dev/null +++ b/aws-serverless-java-container-jersey/src/main/java/com/amazonaws/serverless/proxy/jersey/suppliers/AwsProxyServletResponseSupplier.java @@ -0,0 +1,60 @@ +/* + * Copyright 2016 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance + * with the License. A copy of the License is located at + * + * http://aws.amazon.com/apache2.0/ + * + * or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES + * OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions + * and limitations under the License. + */ +package com.amazonaws.serverless.proxy.jersey.suppliers; + + +import org.glassfish.jersey.server.ContainerRequest; + +import jakarta.servlet.http.HttpServletResponse; +import jakarta.ws.rs.core.Context; + +import java.util.function.Supplier; + +import static com.amazonaws.serverless.proxy.jersey.JerseyHandlerFilter.JERSEY_SERVLET_RESPONSE_PROPERTY; + + +/** + * Implementation of Jersey's Factory object for HttpServletResponse objects. This can be used + * to write data directly to the servlet response for the method, without using Jersey's ContainerResponse + * + *
+ * 
+ *     ResourceConfig app = new ResourceConfig().packages("my.app.package")
+ *         .register(new AbstractBinder() {
+ *             {@literal @}Override
+ *             protected void configure() {
+ *                 bindFactory(AwsProxyServletResponseSupplier.class)
+ *                     .to(HttpServletResponse.class)
+ *                     .in(RequestScoped.class);
+ *            }
+ *       });
+ * 
+ * 
+ */ +public class AwsProxyServletResponseSupplier implements Supplier { + + @Context ContainerRequest currentRequest; + + //------------------------------------------------------------- + // Implementation - Factory + //------------------------------------------------------------- + + @Override + public HttpServletResponse get() { + return getServletResponse(); + } + + private HttpServletResponse getServletResponse() { + return (HttpServletResponse)currentRequest.getProperty(JERSEY_SERVLET_RESPONSE_PROPERTY); + } +} diff --git a/aws-serverless-java-container-jersey/src/test/java/com/amazonaws/serverless/proxy/jersey/EchoJerseyResource.java b/aws-serverless-java-container-jersey/src/test/java/com/amazonaws/serverless/proxy/jersey/EchoJerseyResource.java new file mode 100644 index 00000000..d05c6814 --- /dev/null +++ b/aws-serverless-java-container-jersey/src/test/java/com/amazonaws/serverless/proxy/jersey/EchoJerseyResource.java @@ -0,0 +1,273 @@ +/* + * Copyright 2016 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance + * with the License. A copy of the License is located at + * + * http://aws.amazon.com/apache2.0/ + * + * or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES + * OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions + * and limitations under the License. + */ +package com.amazonaws.serverless.proxy.jersey; + +import com.amazonaws.serverless.proxy.RequestReader; +import com.amazonaws.serverless.proxy.jersey.providers.ServletRequestFilter; +import com.amazonaws.serverless.proxy.model.AwsProxyRequestContext; +import com.amazonaws.serverless.proxy.jersey.model.MapResponseModel; +import com.amazonaws.serverless.proxy.jersey.model.SingleValueModel; + +import org.glassfish.jersey.media.multipart.FormDataContentDisposition; +import org.glassfish.jersey.media.multipart.FormDataParam; + +import jakarta.inject.Inject; +import jakarta.servlet.ServletContext; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.ws.rs.*; +import jakarta.ws.rs.container.ContainerRequestContext; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.SecurityContext; +import jakarta.ws.rs.core.UriInfo; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.Enumeration; +import java.util.List; +import java.util.Random; + + +/** + * Jersey application class for aws-serverless-java-container unit proxy + */ +@Path("/echo") +public class EchoJerseyResource { + public static final String SERVLET_RESP_HEADER_KEY = "X-HttpServletResponse"; + public static final String EXCEPTION_MESSAGE = "Fake exception"; + + @Context + SecurityContext securityCtx; + + @Inject + JerseyDependency jerseyDependency; + + @Path("/decoded-param") @GET + @Produces(MediaType.APPLICATION_JSON) + public SingleValueModel echoDecodedParam(@QueryParam("param") String param) { + SingleValueModel model = new SingleValueModel(); + model.setValue(param); + return model; + } + + @Path("/filter-attribute") @GET + @Produces(MediaType.APPLICATION_JSON) + public SingleValueModel returnFilterAttribute(@Context HttpServletRequest req) { + SingleValueModel model = new SingleValueModel(); + if (req.getAttribute(ServletRequestFilter.FILTER_ATTRIBUTE_NAME) == null) { + model.setValue(""); + } else { + model.setValue(req.getAttribute(ServletRequestFilter.FILTER_ATTRIBUTE_NAME).toString()); + } + return model; + } + + @Path("/list-query-string") @GET + @Produces(MediaType.APPLICATION_JSON) + public SingleValueModel echoQueryStringLength(@QueryParam("list") List param) { + SingleValueModel model = new SingleValueModel(); + model.setValue(param.size() + ""); + return model; + } + + @Path("/encoded-param") @GET + @Produces(MediaType.APPLICATION_JSON) + public SingleValueModel echoEncodedParam(@QueryParam("param") @Encoded String param) { + SingleValueModel model = new SingleValueModel(); + model.setValue(param); + return model; + } + + @Path("/headers") @GET + @Produces(MediaType.APPLICATION_JSON) + public MapResponseModel echoHeaders(@Context ContainerRequestContext context) { + MapResponseModel headers = new MapResponseModel(); + for (String key : context.getHeaders().keySet()) { + headers.addValue(key, context.getHeaderString(key)); + } + + return headers; + } + + @Path("/security-context") @GET + @Produces(MediaType.APPLICATION_JSON) + public SingleValueModel getPrincipal() { + SingleValueModel output = new SingleValueModel(); + if (securityCtx != null) { + output.setValue(securityCtx.getUserPrincipal().getName()); + } + return output; + } + + @Path("/servlet-headers") @GET + @Produces(MediaType.APPLICATION_JSON) + public MapResponseModel echoServletHeaders(@Context HttpServletRequest context) { + MapResponseModel headers = new MapResponseModel(); + Enumeration headerNames = context.getHeaderNames(); + while (headerNames.hasMoreElements()) { + String headerName = headerNames.nextElement(); + headers.addValue(headerName, context.getHeader(headerName)); + } + return headers; + } + + @Path("/servlet-context") @GET + @Produces(MediaType.APPLICATION_JSON) + public SingleValueModel echoContextInformation(@Context ServletContext context) { + SingleValueModel singleValueModel = new SingleValueModel(); + singleValueModel.setValue(context.getServerInfo()); + + return singleValueModel; + } + + @Path("/query-string") @GET + @Produces(MediaType.APPLICATION_JSON) + public MapResponseModel echoQueryString(@Context UriInfo context) { + MapResponseModel queryStrings = new MapResponseModel(); + for (String key : context.getQueryParameters().keySet()) { + queryStrings.addValue(key, context.getQueryParameters().getFirst(key)); + } + + return queryStrings; + } + + @Path("/scheme") @GET + @Produces(MediaType.APPLICATION_JSON) + public SingleValueModel echoRequestScheme(@Context UriInfo context) { + SingleValueModel model = new SingleValueModel(); + model.setValue(context.getRequestUri().getScheme()); + return model; + } + + @Path("/authorizer-principal") @GET + @Produces(MediaType.APPLICATION_JSON) + public SingleValueModel echoAuthorizerPrincipal(@Context ContainerRequestContext context) { + SingleValueModel valueModel = new SingleValueModel(); + AwsProxyRequestContext awsProxyRequestContext = + (AwsProxyRequestContext) context.getProperty(RequestReader.API_GATEWAY_CONTEXT_PROPERTY); + valueModel.setValue(awsProxyRequestContext.getAuthorizer().getPrincipalId()); + + return valueModel; + } + + @Path("/authorizer-context") @GET + @Produces(MediaType.APPLICATION_JSON) + public SingleValueModel echoAuthorizerContext(@Context ContainerRequestContext context, @QueryParam("key") String key) { + SingleValueModel valueModel = new SingleValueModel(); + AwsProxyRequestContext awsProxyRequestContext = + (AwsProxyRequestContext) context.getProperty(RequestReader.API_GATEWAY_CONTEXT_PROPERTY); + valueModel.setValue(awsProxyRequestContext.getAuthorizer().getContextValue(key)); + + return valueModel; + } + + @Path("/json-body") @POST + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + public SingleValueModel echoJsonValue(final SingleValueModel requestValue) { + SingleValueModel output = new SingleValueModel(); + output.setValue(requestValue.getValue()); + + return output; + } + + @Path("/status-code") @GET + @Produces(MediaType.APPLICATION_JSON) + public Response echoCustomStatusCode(@QueryParam("status") int statusCode ) { + SingleValueModel output = new SingleValueModel(); + output.setValue("" + statusCode); + + return Response.status(statusCode).entity(output).build(); + } + + @Path("/servlet-response") @GET + @Produces(MediaType.APPLICATION_JSON) + public Response echoCustomStatusCode(@Context HttpServletResponse resp) { + SingleValueModel output = new SingleValueModel(); + output.setValue("Custom header in resp"); + resp.setHeader(SERVLET_RESP_HEADER_KEY, "1"); + return Response.ok().entity(output).build(); + } + + @Path("/binary") @GET + @Produces("application/octet-stream") + public Response echoBinaryData() { + byte[] b = new byte[128]; + new Random().nextBytes(b); + + return Response.ok(b).build(); + } + + @Path("/empty-stream/{paramId}/test/{param2}") @PUT + @Produces(MediaType.APPLICATION_JSON) + @Consumes(MediaType.APPLICATION_JSON) + public Response emptyStream(@PathParam("paramId") String paramId, @PathParam("param2") String param2) { + SingleValueModel sv = new SingleValueModel(); + sv.setValue(paramId); + return Response.ok(sv).build(); + } + + @Path("/exception") @GET + public Response throwException() { + throw new UnsupportedOperationException(EXCEPTION_MESSAGE); + } + + @Path("/encoded-path/{resource}") @GET + @Produces(MediaType.APPLICATION_JSON) + @Consumes(MediaType.APPLICATION_JSON) + public Response encodedPathParam(@Encoded @PathParam("resource") String resource) { + SingleValueModel sv = new SingleValueModel(); + sv.setValue(resource); + return Response.ok(sv).build(); + } + + @Path("/referer-header") @GET + @Produces(MediaType.APPLICATION_JSON) + @Consumes(MediaType.APPLICATION_JSON) + public Response referer(@HeaderParam("Referer") String referer) { + SingleValueModel sv = new SingleValueModel(); + sv.setValue(referer); + return Response.ok(sv).build(); + } + + @Path("/file-size") @POST + @Consumes(MediaType.MULTIPART_FORM_DATA) + @Produces(MediaType.APPLICATION_JSON) + public Response fileSize(@FormDataParam("file") final File uploadedFile, + @FormDataParam("file") FormDataContentDisposition fileDetail, + @Context ContainerRequestContext req) { + SingleValueModel sv = new SingleValueModel(); + + try { + InputStream fileIs = new FileInputStream(uploadedFile); + System.out.println("File: " + fileDetail.getName() + " " + fileDetail.getFileName() + " " + fileDetail.getSize()); + System.out.println("Size: " + fileIs.available()); + sv.setValue("" + fileIs.available()); + return Response.ok(sv).build(); + } catch (IOException e) { + e.printStackTrace(); + return Response.status(500).build(); + } + } + + @Path("/plain") @GET + @Produces(MediaType.TEXT_PLAIN) + @Consumes(MediaType.APPLICATION_JSON) + public Response plain() { + return Response.status(200).entity("Hello!").build(); + } +} diff --git a/aws-serverless-java-container-jersey/src/test/java/com/amazonaws/serverless/proxy/jersey/JerseyAwsProxyTest.java b/aws-serverless-java-container-jersey/src/test/java/com/amazonaws/serverless/proxy/jersey/JerseyAwsProxyTest.java new file mode 100644 index 00000000..23f1078f --- /dev/null +++ b/aws-serverless-java-container-jersey/src/test/java/com/amazonaws/serverless/proxy/jersey/JerseyAwsProxyTest.java @@ -0,0 +1,479 @@ +/* + * Copyright 2016 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance + * with the License. A copy of the License is located at + * + * http://aws.amazon.com/apache2.0/ + * + * or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES + * OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions + * and limitations under the License. + */ +package com.amazonaws.serverless.proxy.jersey; + + +import com.amazonaws.serverless.proxy.internal.LambdaContainerHandler; +import com.amazonaws.serverless.proxy.internal.servlet.AwsServletContext; +import com.amazonaws.serverless.proxy.internal.testutils.AwsProxyRequestBuilder; +import com.amazonaws.serverless.proxy.internal.testutils.MockLambdaContext; +import com.amazonaws.serverless.proxy.jersey.model.MapResponseModel; +import com.amazonaws.serverless.proxy.jersey.model.SingleValueModel; +import com.amazonaws.serverless.proxy.jersey.providers.ServletRequestFilter; +import com.amazonaws.serverless.proxy.model.AwsProxyRequest; +import com.amazonaws.serverless.proxy.model.AwsProxyResponse; +import com.amazonaws.serverless.proxy.model.HttpApiV2ProxyRequest; +import com.amazonaws.services.lambda.runtime.Context; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.ObjectMapper; +import org.apache.commons.codec.binary.Base64; +import org.glassfish.jersey.logging.LoggingFeature; +import org.glassfish.jersey.media.multipart.MultiPartFeature; +import org.glassfish.jersey.server.ResourceConfig; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import java.io.IOException; +import java.util.Arrays; +import java.util.Collection; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +/** + * Unit test class for the Jersey AWS_PROXY default implementation + */ +public class JerseyAwsProxyTest { + private static final String CUSTOM_HEADER_KEY = "x-custom-header"; + private static final String CUSTOM_HEADER_VALUE = "my-custom-value"; + private static final String AUTHORIZER_PRINCIPAL_ID = "test-principal-" + UUID.randomUUID().toString(); + private static final String USER_PRINCIPAL = "user1"; + + + private static ObjectMapper objectMapper = new ObjectMapper(); + + private static ResourceConfig app = new ResourceConfig().packages("com.amazonaws.serverless.proxy.jersey") + .register(LoggingFeature.class) + .register(ServletRequestFilter.class) + .register(MultiPartFeature.class) + .register(new ResourceBinder()) + .property(LoggingFeature.LOGGING_FEATURE_VERBOSITY_SERVER, LoggingFeature.Verbosity.PAYLOAD_ANY); + + private static ResourceConfig httpApiApp = new ResourceConfig().packages("com.amazonaws.serverless.proxy.jersey") + .register(LoggingFeature.class) + .register(ServletRequestFilter.class) + .register(MultiPartFeature.class) + .register(new ResourceBinder()) + .property(LoggingFeature.LOGGING_FEATURE_VERBOSITY_SERVER, LoggingFeature.Verbosity.PAYLOAD_ANY); + + private static ResourceConfig appWithoutRegisteredDependencies = new ResourceConfig() + .packages("com.amazonaws.serverless.proxy.jersey") + .register(LoggingFeature.class) + .register(ServletRequestFilter.class) + .register(MultiPartFeature.class) + .property(LoggingFeature.LOGGING_FEATURE_VERBOSITY_SERVER, LoggingFeature.Verbosity.PAYLOAD_ANY); + + private static JerseyLambdaContainerHandler handler; + private static JerseyLambdaContainerHandler httpApiHandler; + + private static JerseyLambdaContainerHandler handlerWithoutRegisteredDependencies + = JerseyLambdaContainerHandler.getAwsProxyHandler(appWithoutRegisteredDependencies); + + private static Context lambdaContext = new MockLambdaContext(); + + private String type; + + public void initJerseyAwsProxyTest(String reqType) { + type = reqType; + } + + public static Collection data() { + return Arrays.asList(new Object[]{"API_GW", "ALB", "HTTP_API"}); + } + + private AwsProxyRequestBuilder getRequestBuilder(String path, String method) { + + return new AwsProxyRequestBuilder(path, method); + } + + private AwsProxyResponse executeRequest(AwsProxyRequestBuilder requestBuilder, Context lambdaContext) { + switch (type) { + case "API_GW": + if (handler == null) { + handler = JerseyLambdaContainerHandler.getAwsProxyHandler(app); + } + return handler.proxy(requestBuilder.build(), lambdaContext); + case "ALB": + if (handler == null) { + handler = JerseyLambdaContainerHandler.getAwsProxyHandler(app); + } + return handler.proxy(requestBuilder.alb().build(), lambdaContext); + case "HTTP_API": + if (httpApiHandler == null) { + httpApiHandler = JerseyLambdaContainerHandler.getHttpApiV2ProxyHandler(httpApiApp); + } + return httpApiHandler.proxy(requestBuilder.toHttpApiV2Request(), lambdaContext); + default: + throw new RuntimeException("Unknown request type: " + type); + } + } + + private JerseyLambdaContainerHandler getHandler() { + switch (type) { + case "API_GW": + case "ALB": + return handler; + case "HTTP_API": + return httpApiHandler; + default: + throw new RuntimeException("Unknown request type: " + type); + } + } + + @MethodSource("data") + @ParameterizedTest + void headers_getHeaders_echo(String reqType) { + initJerseyAwsProxyTest(reqType); + AwsProxyRequestBuilder request = getRequestBuilder("/echo/headers", "GET") + .json() + .header(CUSTOM_HEADER_KEY, CUSTOM_HEADER_VALUE); + + AwsProxyResponse output = executeRequest(request, lambdaContext); + assertEquals(200, output.getStatusCode()); + assertEquals("application/json", output.getMultiValueHeaders().getFirst("Content-Type")); + + validateMapResponseModel(output); + } + + @MethodSource("data") + @ParameterizedTest + void headers_servletRequest_echo(String reqType) { + initJerseyAwsProxyTest(reqType); + AwsProxyRequestBuilder request = getRequestBuilder("/echo/servlet-headers", "GET") + .json() + .header(CUSTOM_HEADER_KEY, CUSTOM_HEADER_VALUE); + + AwsProxyResponse output = executeRequest(request, lambdaContext); + assertEquals(200, output.getStatusCode()); + assertEquals("application/json", output.getMultiValueHeaders().getFirst("Content-Type")); + + validateMapResponseModel(output); + } + + @MethodSource("data") + @ParameterizedTest + void headers_servletRequest_failedDependencyInjection_expectInternalServerError(String reqType) { + initJerseyAwsProxyTest(reqType); + assumeTrue("API_GW".equals(type)); + AwsProxyRequest request = getRequestBuilder("/echo/servlet-headers", "GET") + .json() + .header(CUSTOM_HEADER_KEY, CUSTOM_HEADER_VALUE) + .build(); + + AwsProxyResponse output = handlerWithoutRegisteredDependencies.proxy(request, lambdaContext); + assertEquals("application/json", output.getMultiValueHeaders().getFirst("Content-Type")); + assertEquals(Response.Status.INTERNAL_SERVER_ERROR.getStatusCode(), output.getStatusCode()); + } + + @MethodSource("data") + @ParameterizedTest + void context_servletResponse_setCustomHeader(String reqType) { + initJerseyAwsProxyTest(reqType); + AwsProxyRequestBuilder request = getRequestBuilder("/echo/servlet-response", "GET") + .json(); + + AwsProxyResponse output = executeRequest(request, lambdaContext); + assertEquals(200, output.getStatusCode()); + assertTrue(output.getMultiValueHeaders().containsKey(EchoJerseyResource.SERVLET_RESP_HEADER_KEY)); + } + + @MethodSource("data") + @ParameterizedTest + void context_serverInfo_correctContext(String reqType) { + initJerseyAwsProxyTest(reqType); + AwsProxyRequestBuilder request = getRequestBuilder("/echo/servlet-context", "GET"); + AwsProxyResponse output = executeRequest(request, lambdaContext); + assertEquals(200, output.getStatusCode()); + assertEquals("application/json", output.getMultiValueHeaders().getFirst("Content-Type")); + + validateSingleValueModel(output, AwsServletContext.SERVER_INFO); + } + + @MethodSource("data") + @ParameterizedTest + void requestScheme_valid_expectHttps(String reqType) { + initJerseyAwsProxyTest(reqType); + AwsProxyRequestBuilder request = getRequestBuilder("/echo/scheme", "GET") + .json(); + + AwsProxyResponse output = executeRequest(request, lambdaContext); + assertEquals(200, output.getStatusCode()); + assertEquals("application/json", output.getMultiValueHeaders().getFirst("Content-Type")); + + validateSingleValueModel(output, "https"); + } + + @MethodSource("data") + @ParameterizedTest + void requestFilter_injectsServletRequest_expectCustomAttribute(String reqType) { + initJerseyAwsProxyTest(reqType); + AwsProxyRequestBuilder request = getRequestBuilder("/echo/filter-attribute", "GET") + .json(); + + AwsProxyResponse output = executeRequest(request, lambdaContext); + assertEquals(200, output.getStatusCode()); + assertEquals("application/json", output.getMultiValueHeaders().getFirst("Content-Type")); + + validateSingleValueModel(output, ServletRequestFilter.FILTER_ATTRIBUTE_VALUE); + } + + @MethodSource("data") + @ParameterizedTest + void authorizer_securityContext_customPrincipalSuccess(String reqType) { + initJerseyAwsProxyTest(reqType); + assumeTrue("API_GW".equals(type)); // TODO: We should figure out a way to run this for HTTP_API too + AwsProxyRequestBuilder request = getRequestBuilder("/echo/authorizer-principal", "GET") + .json() + .authorizerPrincipal(AUTHORIZER_PRINCIPAL_ID); + + AwsProxyResponse output = executeRequest(request, lambdaContext); + assertEquals(200, output.getStatusCode()); + assertEquals("application/json", output.getMultiValueHeaders().getFirst("Content-Type")); + validateSingleValueModel(output, AUTHORIZER_PRINCIPAL_ID); + } + + @MethodSource("data") + @ParameterizedTest + void authorizer_securityContext_customAuthorizerContextSuccess(String reqType) { + initJerseyAwsProxyTest(reqType); + assumeTrue("API_GW".equals(type)); + AwsProxyRequestBuilder request = getRequestBuilder("/echo/authorizer-context", "GET") + .json() + .authorizerPrincipal(AUTHORIZER_PRINCIPAL_ID) + .authorizerContextValue(CUSTOM_HEADER_KEY, CUSTOM_HEADER_VALUE) + .queryString("key", CUSTOM_HEADER_KEY); + + AwsProxyResponse output = executeRequest(request, lambdaContext); + assertEquals(200, output.getStatusCode()); + assertEquals("application/json", output.getMultiValueHeaders().getFirst("Content-Type")); + + validateSingleValueModel(output, CUSTOM_HEADER_VALUE); + } + + @MethodSource("data") + @ParameterizedTest + void errors_unknownRoute_expect404(String reqType) { + initJerseyAwsProxyTest(reqType); + AwsProxyRequestBuilder request = getRequestBuilder("/echo/test33", "GET"); + + AwsProxyResponse output = executeRequest(request, lambdaContext); + assertEquals(404, output.getStatusCode()); + } + + @MethodSource("data") + @ParameterizedTest + void error_contentType_invalidContentType(String reqType) { + initJerseyAwsProxyTest(reqType); + AwsProxyRequestBuilder request = getRequestBuilder("/echo/json-body", "POST") + .header("Content-Type", "application/octet-stream") + .body("asdasdasd"); + + AwsProxyResponse output = executeRequest(request, lambdaContext); + assertEquals(415, output.getStatusCode()); + } + + @MethodSource("data") + @ParameterizedTest + void error_statusCode_methodNotAllowed(String reqType) { + initJerseyAwsProxyTest(reqType); + AwsProxyRequestBuilder request = getRequestBuilder("/echo/status-code", "POST") + .json() + .queryString("status", "201"); + + AwsProxyResponse output = executeRequest(request, lambdaContext); + assertEquals(405, output.getStatusCode()); + } + + @MethodSource("data") + @ParameterizedTest + void responseBody_responseWriter_validBody(String reqType) throws JacksonException { + initJerseyAwsProxyTest(reqType); + SingleValueModel singleValueModel = new SingleValueModel(); + singleValueModel.setValue(CUSTOM_HEADER_VALUE); + AwsProxyRequestBuilder request = getRequestBuilder("/echo/json-body", "POST") + .json() + .body(objectMapper.writeValueAsString(singleValueModel)); + + AwsProxyResponse output = executeRequest(request, lambdaContext); + assertEquals(200, output.getStatusCode()); + assertNotNull(output.getBody()); + + validateSingleValueModel(output, CUSTOM_HEADER_VALUE); + } + + @MethodSource("data") + @ParameterizedTest + void statusCode_responseStatusCode_customStatusCode(String reqType) { + initJerseyAwsProxyTest(reqType); + AwsProxyRequestBuilder request = getRequestBuilder("/echo/status-code", "GET") + .json() + .queryString("status", "201"); + + AwsProxyResponse output = executeRequest(request, lambdaContext); + assertEquals(201, output.getStatusCode()); + } + + @MethodSource("data") + @ParameterizedTest + void base64_binaryResponse_base64Encoding(String reqType) { + initJerseyAwsProxyTest(reqType); + AwsProxyRequestBuilder request = getRequestBuilder("/echo/binary", "GET"); + + AwsProxyResponse response = executeRequest(request, lambdaContext); + assertNotNull(response.getBody()); + assertTrue(Base64.isBase64(response.getBody())); + } + + @MethodSource("data") + @ParameterizedTest + void exception_mapException_mapToNotImplemented(String reqType) { + initJerseyAwsProxyTest(reqType); + AwsProxyRequestBuilder request = getRequestBuilder("/echo/exception", "GET"); + + AwsProxyResponse response = executeRequest(request, lambdaContext); + assertNotNull(response.getBody()); + assertEquals(EchoJerseyResource.EXCEPTION_MESSAGE, response.getBody()); + assertEquals(Response.Status.NOT_IMPLEMENTED.getStatusCode(), response.getStatusCode()); + } + + @MethodSource("data") + @ParameterizedTest + void stripBasePath_route_shouldRouteCorrectly(String reqType) { + initJerseyAwsProxyTest(reqType); + AwsProxyRequestBuilder request = getRequestBuilder("/custompath/echo/status-code", "GET") + .json() + .queryString("status", "201"); + getHandler().stripBasePath("/custompath"); + AwsProxyResponse output = executeRequest(request, lambdaContext); + assertEquals(201, output.getStatusCode()); + getHandler().stripBasePath(""); + } + + @MethodSource("data") + @ParameterizedTest + void stripBasePath_route_shouldReturn404WithStageAsContext(String reqType) { + initJerseyAwsProxyTest(reqType); + assumeTrue(!"ALB".equals(type)); + AwsProxyRequestBuilder request = getRequestBuilder("/custompath/echo/status-code", "GET") + .stage("prod") + .json() + .queryString("status", "201"); + getHandler().stripBasePath("/custompath"); + LambdaContainerHandler.getContainerConfig().setUseStageAsServletContext(true); + AwsProxyResponse output = executeRequest(request, lambdaContext); + assertEquals(404, output.getStatusCode()); + getHandler().stripBasePath(""); + LambdaContainerHandler.getContainerConfig().setUseStageAsServletContext(false); + } + + @MethodSource("data") + @ParameterizedTest + void stripBasePath_route_shouldReturn404(String reqType) { + initJerseyAwsProxyTest(reqType); + AwsProxyRequestBuilder request = getRequestBuilder("/custompath/echo/status-code", "GET") + .json() + .queryString("status", "201"); + getHandler().stripBasePath("/custom"); + AwsProxyResponse output = executeRequest(request, lambdaContext); + assertEquals(404, output.getStatusCode()); + getHandler().stripBasePath(""); + } + + @MethodSource("data") + @ParameterizedTest + void securityContext_injectPrincipal_expectPrincipalName(String reqType) { + initJerseyAwsProxyTest(reqType); + assumeTrue("API_GW".equals(type)); + AwsProxyRequestBuilder request = getRequestBuilder("/echo/security-context", "GET") + .authorizerPrincipal(USER_PRINCIPAL); + + AwsProxyResponse resp = executeRequest(request, lambdaContext); + assertEquals(200, resp.getStatusCode()); + validateSingleValueModel(resp, USER_PRINCIPAL); + } + + @MethodSource("data") + @ParameterizedTest + void emptyStream_putNullBody_expectPutToSucceed(String reqType) { + initJerseyAwsProxyTest(reqType); + AwsProxyRequestBuilder request = getRequestBuilder("/echo/empty-stream/" + CUSTOM_HEADER_KEY + "/test/2", "PUT") + .nullBody() + .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON); + AwsProxyResponse resp = executeRequest(request, lambdaContext); + assertEquals(200, resp.getStatusCode()); + validateSingleValueModel(resp, CUSTOM_HEADER_KEY); + } + + @MethodSource("data") + @ParameterizedTest + void refererHeader_headerParam_expectCorrectInjection(String reqType) { + initJerseyAwsProxyTest(reqType); + String refererValue = "test-referer"; + AwsProxyRequestBuilder request = getRequestBuilder("/echo/referer-header", "GET") + .nullBody() + .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON) + .header("Referer", refererValue); + + AwsProxyResponse resp = executeRequest(request, lambdaContext); + assertEquals(200, resp.getStatusCode()); + validateSingleValueModel(resp, refererValue); + } + + @MethodSource("data") + @ParameterizedTest + void textPlainContent_plain_responseHonorsContentType(String reqType) { + initJerseyAwsProxyTest(reqType); + AwsProxyRequestBuilder req = getRequestBuilder("/echo/plain", "GET") + .nullBody() + .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON) + .header(HttpHeaders.ACCEPT, MediaType.TEXT_PLAIN); + + AwsProxyResponse resp = executeRequest(req, lambdaContext); + assertEquals(200, resp.getStatusCode()); + assertTrue(resp.getMultiValueHeaders().containsKey(HttpHeaders.CONTENT_TYPE)); + assertEquals(MediaType.TEXT_PLAIN, resp.getMultiValueHeaders().get(HttpHeaders.CONTENT_TYPE).get(0)); + } + + private void validateMapResponseModel(AwsProxyResponse output) { + validateMapResponseModel(output, CUSTOM_HEADER_KEY, CUSTOM_HEADER_VALUE); + } + + private void validateMapResponseModel(AwsProxyResponse output, String key, String value) { + try { + MapResponseModel response = objectMapper.readValue(output.getBody(), MapResponseModel.class); + assertNotNull(response.getValues().get(key)); + assertEquals(value, response.getValues().get(key)); + } catch (JacksonException e) { + e.printStackTrace(); + fail("Exception while parsing response body: " + e.getMessage()); + } + } + + private void validateSingleValueModel(AwsProxyResponse output, String value) { + try { + SingleValueModel response = objectMapper.readValue(output.getBody(), SingleValueModel.class); + assertNotNull(response.getValue()); + assertEquals(value, response.getValue()); + } catch (JacksonException e) { + e.printStackTrace(); + fail("Exception while parsing response body: " + e.getMessage()); + } + } +} diff --git a/aws-serverless-java-container-jersey/src/test/java/com/amazonaws/serverless/proxy/jersey/JerseyDependency.java b/aws-serverless-java-container-jersey/src/test/java/com/amazonaws/serverless/proxy/jersey/JerseyDependency.java new file mode 100644 index 00000000..0633b96b --- /dev/null +++ b/aws-serverless-java-container-jersey/src/test/java/com/amazonaws/serverless/proxy/jersey/JerseyDependency.java @@ -0,0 +1,6 @@ +package com.amazonaws.serverless.proxy.jersey; + +// This class is used to test HK2 dependency injection. +public class JerseyDependency { + +} diff --git a/aws-serverless-java-container-jersey/src/test/java/com/amazonaws/serverless/proxy/jersey/JerseyInjectionTest.java b/aws-serverless-java-container-jersey/src/test/java/com/amazonaws/serverless/proxy/jersey/JerseyInjectionTest.java new file mode 100644 index 00000000..e253f34a --- /dev/null +++ b/aws-serverless-java-container-jersey/src/test/java/com/amazonaws/serverless/proxy/jersey/JerseyInjectionTest.java @@ -0,0 +1,58 @@ +/* + * Copyright 2016 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance + * with the License. A copy of the License is located at + * + * http://aws.amazon.com/apache2.0/ + * + * or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES + * OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions + * and limitations under the License. + */ +package com.amazonaws.serverless.proxy.jersey; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import jakarta.inject.Singleton; + +import org.glassfish.jersey.internal.inject.AbstractBinder; +import org.glassfish.jersey.media.multipart.MultiPartFeature; +import org.glassfish.jersey.server.ResourceConfig; +import org.junit.jupiter.api.Test; +import com.amazonaws.serverless.proxy.model.AwsProxyRequest; +import com.amazonaws.serverless.proxy.model.AwsProxyResponse; + +/** + * Test that one can access the Jersey injection manager + */ +public class JerseyInjectionTest { + + // Test resource binder + private static class ResourceBinder extends AbstractBinder { + + @Override + protected void configure() { + bind(new JerseyInjectionTest()).to(JerseyInjectionTest.class).in(Singleton.class); + } + + } + + private static ResourceConfig app = new ResourceConfig().register(MultiPartFeature.class) + .register(new ResourceBinder()); + + private static JerseyLambdaContainerHandler handler = JerseyLambdaContainerHandler.getAwsProxyHandler( + app); + + @Test + void can_get_injected_resources() throws Exception { + + JerseyInjectionTest instance1 = handler.getInjectionManager().getInstance(JerseyInjectionTest.class); + assertNotNull(instance1); + + JerseyInjectionTest instance2 = handler.getInjectionManager().getInstance(JerseyInjectionTest.class); + assertEquals(instance1, instance2); + + } +} diff --git a/aws-serverless-java-container-jersey/src/test/java/com/amazonaws/serverless/proxy/jersey/JerseyParamEncodingTest.java b/aws-serverless-java-container-jersey/src/test/java/com/amazonaws/serverless/proxy/jersey/JerseyParamEncodingTest.java new file mode 100644 index 00000000..5ca09b11 --- /dev/null +++ b/aws-serverless-java-container-jersey/src/test/java/com/amazonaws/serverless/proxy/jersey/JerseyParamEncodingTest.java @@ -0,0 +1,301 @@ +package com.amazonaws.serverless.proxy.jersey; + + +import com.amazonaws.serverless.proxy.internal.LambdaContainerHandler; +import com.amazonaws.serverless.proxy.internal.testutils.AwsProxyRequestBuilder; +import com.amazonaws.serverless.proxy.internal.testutils.MockLambdaContext; +import com.amazonaws.serverless.proxy.jersey.model.MapResponseModel; +import com.amazonaws.serverless.proxy.jersey.model.SingleValueModel; +import com.amazonaws.serverless.proxy.model.AwsProxyRequest; +import com.amazonaws.serverless.proxy.model.AwsProxyResponse; +import com.amazonaws.serverless.proxy.model.HttpApiV2ProxyRequest; +import com.amazonaws.services.lambda.runtime.Context; + +import tools.jackson.databind.ObjectMapper; +import tools.jackson.core.JacksonException; +import org.glassfish.jersey.media.multipart.MultiPartFeature; +import org.glassfish.jersey.server.ResourceConfig; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +import jakarta.ws.rs.core.MediaType; + +import java.io.IOException; +import java.io.UnsupportedEncodingException; +import java.net.URLEncoder; +import java.util.Arrays; +import java.util.Collection; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.fail; + + +public class JerseyParamEncodingTest { + + private static final String SIMPLE_ENCODED_PARAM = "p/z+3"; + private static final String JSON_ENCODED_PARAM = "{\"name\":\"faisal\"}"; + private static final String QUERY_STRING_KEY = "identifier"; + private static final String QUERY_STRING_NON_ENCODED_VALUE = "Space Test"; + private static final String QUERY_STRING_ENCODED_VALUE = "Space%20Test"; + private static final byte[] FILE_CONTENTS = new byte[]{ + (byte)47, (byte)85, (byte)135, (byte)12, (byte)53, (byte)7, (byte)158, (byte)212, (byte)55, (byte)193, (byte)149, (byte)3, (byte)166, (byte)181, + (byte)151, (byte)84, (byte)122, (byte)200, (byte)244, (byte)5, (byte)115, (byte)159, (byte)66, (byte)64, (byte)143, (byte)211, (byte)13, (byte)63, + (byte)235, (byte)184, (byte)51, (byte)49, (byte)143, (byte)167, (byte)231, (byte)31, (byte)78, (byte)234, (byte)145, (byte)105, (byte)190, (byte)170, + (byte)49, (byte)135, (byte)175, (byte)106, (byte)25, (byte)86, (byte)145, (byte)181, (byte)156, (byte)23, (byte)153, (byte)99, (byte)175, (byte)63, + (byte)43, (byte)208, (byte)5, (byte)16, (byte)140, (byte)103, (byte)146, (byte)254, (byte)155, (byte)97, (byte)53, (byte)100, (byte)137, (byte)6, + (byte)62, (byte)101, (byte)189, (byte)137, (byte)140, (byte)5, (byte)110, (byte)218, (byte)113, (byte)132, (byte)36, (byte)188, (byte)19, (byte)168, + (byte)93, (byte)169, (byte)124, (byte)253, (byte)201, (byte)233, (byte)21, (byte)80, (byte)4, (byte)56, (byte)0, (byte)204, (byte)205, (byte)232, + (byte)213, (byte)253, (byte)232, (byte)91, (byte)153, (byte)169, (byte)82, (byte)247, (byte)78, (byte)71, (byte)188, (byte)71, (byte)23, (byte)171, + (byte)232, (byte)26, (byte)146, (byte)189, (byte)145, (byte)82, (byte)79, (byte)148, (byte)1, (byte)201, (byte)243, (byte)73, (byte)98, (byte)65, + (byte)236, (byte)177, (byte)211, (byte)106, (byte)105, (byte)46, (byte)204, (byte)214, (byte)55, (byte)182, (byte)55, (byte)149, (byte)221, (byte)52, + (byte)186, (byte)122, (byte)255, (byte)195, (byte)60, (byte)146, (byte)21, (byte)212, (byte)139, (byte)38, (byte)146, (byte)166, (byte)14, (byte)174, + (byte)242, (byte)145, (byte)16, (byte)44, (byte)68, (byte)89, (byte)25, (byte)219, (byte)62, (byte)227, (byte)6, (byte)89, (byte)194, (byte)146, (byte)93, + (byte)167, (byte)230, (byte)90, (byte)59, (byte)35, (byte)136, (byte)37, (byte)196, (byte)118, (byte)16, (byte)28, (byte)107, (byte)105, (byte)87, + (byte)195, (byte)86, (byte)87, (byte)180, (byte)176, (byte)118, (byte)6, (byte)29, (byte)26, (byte)51, (byte)94, (byte)21, (byte)23, (byte)32, (byte)156, + (byte)150, (byte)204, (byte)53, (byte)110, (byte)134, (byte)153, (byte)138, (byte)247, (byte)98, (byte)135, (byte)249, (byte)119, (byte)121, (byte)2, + (byte)42, (byte)62, (byte)198, (byte)197, (byte)112, (byte)153, (byte)244, (byte)174, (byte)145, (byte)54, (byte)246, (byte)44, (byte)198, (byte)50, + (byte)2, (byte)37, (byte)102, (byte)50, (byte)103, (byte)207, (byte)81, (byte)62, (byte)138, (byte)164, (byte)140, (byte)64, (byte)247, (byte)115, + (byte)40, (byte)41, (byte)252, (byte)54, (byte)189, (byte)207, (byte)124, (byte)147, (byte)122, (byte)243, (byte)83, (byte)34, (byte)160, (byte)64, (byte)189, (byte)226, (byte)202, (byte)181, (byte)55, (byte)158, (byte)121, (byte)78, (byte)143, (byte)41, (byte)58, (byte)27, (byte)77, (byte)186, (byte)214, (byte)23, (byte)132, (byte)100, (byte)180, (byte)26, (byte)37, (byte)247, (byte)254, (byte)97, (byte)214, (byte)57, (byte)30, (byte)46, (byte)96, (byte)44, (byte)138, (byte)15, (byte)162, (byte)93, (byte)222, (byte)239, (byte)189, (byte)72, (byte)15, (byte)79, (byte)136, (byte)210, (byte)44, (byte)233, (byte)99, (byte)72, (byte)234, (byte)225, (byte)245, (byte)27, (byte)111, (byte)175, (byte)132, (byte)112, (byte)135, (byte)253, (byte)66, (byte)215, (byte)168, (byte)156, (byte)168, (byte)79, (byte)78, (byte)140, (byte)14, (byte)129, (byte)37, (byte)238, (byte)196, (byte)34, (byte)245, (byte)141, (byte)148, (byte)161, (byte)29, (byte)110, (byte)32, (byte)255, (byte)247, (byte)52, (byte)48, (byte)102, (byte)42, (byte)54, (byte)97, (byte)185, (byte)10, (byte)114, (byte)225, (byte)247, (byte)254, (byte)108, (byte)116, (byte)73, (byte)84, (byte)242, (byte)86, (byte)15, (byte)72, (byte)68, (byte)172, (byte)74, (byte)107, (byte)103, (byte)222, (byte)246, (byte)152, (byte)67, (byte)12, (byte)104, (byte)245, (byte)20, (byte)112, (byte)94, (byte)197, (byte)201, (byte)89, (byte)182, (byte)214, (byte)6, (byte)182, (byte)165, (byte)209, (byte)79, (byte)192, (byte)211, (byte)163, (byte)208, (byte)12, (byte)73, (byte)53, (byte)99, (byte)59, (byte)182, (byte)186, (byte)48, (byte)184, (byte)215, (byte)22, (byte)24, (byte)233, (byte)109, (byte)206, (byte)59, (byte)0, (byte)118, (byte)141, (byte)25, (byte)50, (byte)242, (byte)247, (byte)240, (byte)238, (byte)127, (byte)236, (byte)241, (byte)224, (byte)20, (byte)61, (byte)65, (byte)148, (byte)120, (byte)192, (byte)99, (byte)172, (byte)194, (byte)135, (byte)61, (byte)147, (byte)251, (byte)161, (byte)219, (byte)252, (byte)187, (byte)154, (byte)115, (byte)193, (byte)118, (byte)167, (byte)130, (byte)174, (byte)211, (byte)236, (byte)141, (byte)14, (byte)8, (byte)244, (byte)110, (byte)66, (byte)210, (byte)110, (byte)236, (byte)255, (byte)25, (byte)16, (byte)134, (byte)70, (byte)196, (byte)163, (byte)30, (byte)177, (byte)238, (byte)225, (byte)237, (byte)12, (byte)14, (byte)215, (byte)40, (byte)77, (byte)206, (byte)76, (byte)122, (byte)205, (byte)20, (byte)183, (byte)106, (byte)230, (byte)230, (byte)123, (byte)209, (byte)77, (byte)102, (byte)65, (byte)241, (byte)41, (byte)213, (byte)219, (byte)79, (byte)37, (byte)61, (byte)10, (byte)154, (byte)19, (byte)93, (byte)33, (byte)72, (byte)105, (byte)247, (byte)221, (byte)145, (byte)179, (byte)69, (byte)38, (byte)234, (byte)163, (byte)218, (byte)131, (byte)179, (byte)30, (byte)114, (byte)150, (byte)106, (byte)17, (byte)187, (byte)229, (byte)106, (byte)7, (byte)112 + }; + + + private static ObjectMapper objectMapper = new ObjectMapper(); + private static ResourceConfig app = new ResourceConfig().packages("com.amazonaws.serverless.proxy.jersey") + .register(MultiPartFeature.class) + .register(new ResourceBinder()) + .property("jersey.config.server.tracing.type", "ALL") + .property("jersey.config.server.tracing.threshold", "VERBOSE"); + private static JerseyLambdaContainerHandler handler; + + private static ResourceConfig httpApiApp = new ResourceConfig().packages("com.amazonaws.serverless.proxy.jersey") + .register(MultiPartFeature.class) + .register(new ResourceBinder()) + .property("jersey.config.server.tracing.type", "ALL") + .property("jersey.config.server.tracing.threshold", "VERBOSE"); + private static JerseyLambdaContainerHandler httpApiHandler; + + private static Context lambdaContext = new MockLambdaContext(); + + private String type; + + public void initJerseyParamEncodingTest(String reqType) { + type = reqType; + LambdaContainerHandler.getContainerConfig().addBinaryContentTypes(MediaType.MULTIPART_FORM_DATA); + } + + public static Collection data() { + return Arrays.asList(new Object[]{"API_GW", "ALB", "HTTP_API"}); + } + + private AwsProxyRequestBuilder getRequestBuilder(String path, String method) { + return new AwsProxyRequestBuilder(path, method); + } + + private AwsProxyResponse executeRequest(AwsProxyRequestBuilder requestBuilder, Context lambdaContext) { + switch (type) { + case "API_GW": + if (handler == null) { + handler = JerseyLambdaContainerHandler.getAwsProxyHandler(app); + } + return handler.proxy(requestBuilder.build(), lambdaContext); + case "ALB": + if (handler == null) { + handler = JerseyLambdaContainerHandler.getAwsProxyHandler(app); + } + return handler.proxy(requestBuilder.alb().build(), lambdaContext); + case "HTTP_API": + if (httpApiHandler == null) { + httpApiHandler = JerseyLambdaContainerHandler.getHttpApiV2ProxyHandler(httpApiApp); + } + return httpApiHandler.proxy(requestBuilder.toHttpApiV2Request(), lambdaContext); + default: + throw new RuntimeException("Unknown request type: " + type); + } + } + + @MethodSource("data") + @ParameterizedTest + void queryString_uriInfo_echo(String reqType) { + initJerseyParamEncodingTest(reqType); + AwsProxyRequestBuilder request = getRequestBuilder("/echo/query-string", "GET") + .json() + .queryString(QUERY_STRING_KEY, QUERY_STRING_NON_ENCODED_VALUE); + + AwsProxyResponse output = executeRequest(request, lambdaContext); + assertEquals(200, output.getStatusCode()); + assertEquals("application/json", output.getMultiValueHeaders().getFirst("Content-Type")); + + validateMapResponseModel(output, QUERY_STRING_KEY, QUERY_STRING_NON_ENCODED_VALUE); + } + + @MethodSource("data") + @ParameterizedTest + void queryString_notEncoded_echo(String reqType) { + initJerseyParamEncodingTest(reqType); + AwsProxyRequestBuilder request = getRequestBuilder("/echo/query-string", "GET") + .json() + .queryString(QUERY_STRING_KEY, QUERY_STRING_NON_ENCODED_VALUE); + + AwsProxyResponse output = executeRequest(request, lambdaContext); + assertEquals(200, output.getStatusCode()); + assertEquals("application/json", output.getMultiValueHeaders().getFirst("Content-Type")); + + validateMapResponseModel(output, QUERY_STRING_KEY, QUERY_STRING_NON_ENCODED_VALUE); + } + + @ParameterizedTest + @Disabled("We expect to only receive decoded values from API Gateway") + @MethodSource("data") + void queryString_encoded_echo(String reqType) { + initJerseyParamEncodingTest(reqType); + AwsProxyRequestBuilder request = getRequestBuilder("/echo/query-string", "GET") + .json() + .queryString(QUERY_STRING_KEY, QUERY_STRING_ENCODED_VALUE); + + AwsProxyResponse output = executeRequest(request, lambdaContext); + assertEquals(200, output.getStatusCode()); + assertEquals("application/json", output.getMultiValueHeaders().getFirst("Content-Type")); + + validateMapResponseModel(output, QUERY_STRING_KEY, QUERY_STRING_NON_ENCODED_VALUE); + } + + @MethodSource("data") + @ParameterizedTest + void simpleQueryParam_encoding_expectDecodedParam(String reqType) { + initJerseyParamEncodingTest(reqType); + AwsProxyRequestBuilder request = getRequestBuilder("/echo/decoded-param", "GET").queryString("param", SIMPLE_ENCODED_PARAM); + + AwsProxyResponse resp = executeRequest(request, lambdaContext); + assertEquals(200, resp.getStatusCode()); + validateSingleValueModel(resp, SIMPLE_ENCODED_PARAM); + } + + @MethodSource("data") + @ParameterizedTest + void jsonQueryParam_encoding_expectDecodedParam(String reqType) { + initJerseyParamEncodingTest(reqType); + AwsProxyRequestBuilder request = getRequestBuilder("/echo/decoded-param", "GET").queryString("param", JSON_ENCODED_PARAM); + + AwsProxyResponse resp = executeRequest(request, lambdaContext); + assertEquals(200, resp.getStatusCode()); + validateSingleValueModel(resp, JSON_ENCODED_PARAM); + } + + @MethodSource("data") + @ParameterizedTest + void simpleQueryParam_encoding_expectEncodedParam(String reqType) { + initJerseyParamEncodingTest(reqType); + AwsProxyRequestBuilder request = getRequestBuilder("/echo/encoded-param", "GET").queryString("param", SIMPLE_ENCODED_PARAM); + String encodedVal = ""; + try { + encodedVal = URLEncoder.encode(SIMPLE_ENCODED_PARAM, "UTF-8"); + } catch (UnsupportedEncodingException e) { + fail("Could not encode parameter value"); + } + AwsProxyResponse resp = executeRequest(request, lambdaContext); + assertEquals(200, resp.getStatusCode()); + validateSingleValueModel(resp, encodedVal); + } + + @MethodSource("data") + @ParameterizedTest + void jsonQueryParam_encoding_expectEncodedParam(String reqType) { + initJerseyParamEncodingTest(reqType); + AwsProxyRequestBuilder request = getRequestBuilder("/echo/encoded-param", "GET").queryString("param", JSON_ENCODED_PARAM); + String encodedVal = ""; + try { + encodedVal = URLEncoder.encode(JSON_ENCODED_PARAM, "UTF-8"); + } catch (UnsupportedEncodingException e) { + fail("Could not encode parameter value"); + } + AwsProxyResponse resp = executeRequest(request, lambdaContext); + assertEquals(200, resp.getStatusCode()); + validateSingleValueModel(resp, encodedVal); + } + + @MethodSource("data") + @ParameterizedTest + void queryParam_encoding_expectFullyEncodedUrl(String reqType) { + initJerseyParamEncodingTest(reqType); + String paramValue = "/+="; + AwsProxyRequestBuilder request = getRequestBuilder("/echo/encoded-param", "GET").queryString("param", paramValue); + AwsProxyResponse resp = executeRequest(request, lambdaContext); + assertNotNull(resp); + assertEquals(200, resp.getStatusCode()); + validateSingleValueModel(resp, "%2F%2B%3D"); + } + + @MethodSource("data") + @ParameterizedTest + void pathParam_encoded_routesToCorrectPath(String reqType) { + initJerseyParamEncodingTest(reqType); + String encodedParam = "http%3A%2F%2Fhelloresource.com"; + String path = "/echo/encoded-path/" + encodedParam; + AwsProxyRequestBuilder request = getRequestBuilder(path, "GET"); + AwsProxyResponse resp = executeRequest(request, lambdaContext); + assertNotNull(resp); + assertEquals(200, resp.getStatusCode()); + validateSingleValueModel(resp, encodedParam); + } + + @MethodSource("data") + @ParameterizedTest + void pathParam_encoded_returns404(String reqType) { + initJerseyParamEncodingTest(reqType); + String encodedParam = "http://helloresource.com"; + String path = "/echo/encoded-path/" + encodedParam; + AwsProxyRequestBuilder request = getRequestBuilder(path, "GET"); + AwsProxyResponse resp = executeRequest(request, lambdaContext); + assertNotNull(resp); + assertEquals(404, resp.getStatusCode()); + } + + @ParameterizedTest + @Disabled + @MethodSource("data") + void queryParam_listOfString_expectCorrectLength(String reqType) { + initJerseyParamEncodingTest(reqType); + AwsProxyRequestBuilder request = getRequestBuilder("/echo/list-query-string", "GET").queryString("list", "v1,v2,v3"); + AwsProxyResponse resp = executeRequest(request, lambdaContext); + assertNotNull(resp); + assertEquals(200, resp.getStatusCode()); + validateSingleValueModel(resp, "3"); + } + + @MethodSource("data") + @ParameterizedTest + void multipart_getFileSize_expectCorrectLength(String reqType) + throws IOException { + initJerseyParamEncodingTest(reqType); + AwsProxyRequestBuilder request = getRequestBuilder("/echo/file-size", "POST") + .formFilePart("file", "myfile.jpg", FILE_CONTENTS); + AwsProxyResponse resp = executeRequest(request, lambdaContext); + assertNotNull(resp); + assertEquals(200, resp.getStatusCode()); + validateSingleValueModel(resp, "" + FILE_CONTENTS.length); + } + + private void validateSingleValueModel(AwsProxyResponse output, String value) { + try { + SingleValueModel response = objectMapper.readValue(output.getBody(), SingleValueModel.class); + assertNotNull(response.getValue()); + assertEquals(value, response.getValue()); + } catch (JacksonException e) { + e.printStackTrace(); + fail("Exception while parsing response body: " + e.getMessage()); + } + } + + private void validateMapResponseModel(AwsProxyResponse output, String key, String value) { + try { + MapResponseModel response = objectMapper.readValue(output.getBody(), MapResponseModel.class); + assertNotNull(response.getValues().get(key)); + assertEquals(value, response.getValues().get(key)); + } catch (JacksonException e) { + e.printStackTrace(); + fail("Exception while parsing response body: " + e.getMessage()); + } + } +} diff --git a/aws-serverless-java-container-jersey/src/test/java/com/amazonaws/serverless/proxy/jersey/ResourceBinder.java b/aws-serverless-java-container-jersey/src/test/java/com/amazonaws/serverless/proxy/jersey/ResourceBinder.java new file mode 100644 index 00000000..f35bffd4 --- /dev/null +++ b/aws-serverless-java-container-jersey/src/test/java/com/amazonaws/serverless/proxy/jersey/ResourceBinder.java @@ -0,0 +1,12 @@ +package com.amazonaws.serverless.proxy.jersey; + +import org.glassfish.jersey.internal.inject.AbstractBinder; + +import jakarta.inject.Singleton; + +public class ResourceBinder extends AbstractBinder { + @Override + protected void configure() { + bind(new JerseyDependency()).to(JerseyDependency.class).in(Singleton.class); + } +} diff --git a/aws-serverless-java-container-jersey/src/test/java/com/amazonaws/serverless/proxy/test/jersey/model/MapResponseModel.java b/aws-serverless-java-container-jersey/src/test/java/com/amazonaws/serverless/proxy/jersey/model/MapResponseModel.java similarity index 94% rename from aws-serverless-java-container-jersey/src/test/java/com/amazonaws/serverless/proxy/test/jersey/model/MapResponseModel.java rename to aws-serverless-java-container-jersey/src/test/java/com/amazonaws/serverless/proxy/jersey/model/MapResponseModel.java index 48fb23fe..8e9c23ab 100644 --- a/aws-serverless-java-container-jersey/src/test/java/com/amazonaws/serverless/proxy/test/jersey/model/MapResponseModel.java +++ b/aws-serverless-java-container-jersey/src/test/java/com/amazonaws/serverless/proxy/jersey/model/MapResponseModel.java @@ -10,7 +10,7 @@ * OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions * and limitations under the License. */ -package com.amazonaws.serverless.proxy.test.jersey.model; +package com.amazonaws.serverless.proxy.jersey.model; import java.util.HashMap; import java.util.Map; diff --git a/aws-serverless-java-container-jersey/src/test/java/com/amazonaws/serverless/proxy/test/jersey/model/SingleValueModel.java b/aws-serverless-java-container-jersey/src/test/java/com/amazonaws/serverless/proxy/jersey/model/SingleValueModel.java similarity index 93% rename from aws-serverless-java-container-jersey/src/test/java/com/amazonaws/serverless/proxy/test/jersey/model/SingleValueModel.java rename to aws-serverless-java-container-jersey/src/test/java/com/amazonaws/serverless/proxy/jersey/model/SingleValueModel.java index 5ec0508f..512fe37c 100644 --- a/aws-serverless-java-container-jersey/src/test/java/com/amazonaws/serverless/proxy/test/jersey/model/SingleValueModel.java +++ b/aws-serverless-java-container-jersey/src/test/java/com/amazonaws/serverless/proxy/jersey/model/SingleValueModel.java @@ -10,7 +10,7 @@ * OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions * and limitations under the License. */ -package com.amazonaws.serverless.proxy.test.jersey.model; +package com.amazonaws.serverless.proxy.jersey.model; /** * Request/response model diff --git a/aws-serverless-java-container-jersey/src/test/java/com/amazonaws/serverless/proxy/jersey/providers/CustomExceptionMapper.java b/aws-serverless-java-container-jersey/src/test/java/com/amazonaws/serverless/proxy/jersey/providers/CustomExceptionMapper.java new file mode 100644 index 00000000..61cddee1 --- /dev/null +++ b/aws-serverless-java-container-jersey/src/test/java/com/amazonaws/serverless/proxy/jersey/providers/CustomExceptionMapper.java @@ -0,0 +1,29 @@ +package com.amazonaws.serverless.proxy.jersey.providers; + + +import jakarta.inject.Inject; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.ext.ExceptionMapper; +import jakarta.ws.rs.ext.Provider; + + +@Provider +public class CustomExceptionMapper implements ExceptionMapper { + + public CustomExceptionMapper() { + + } + + @Inject + public jakarta.inject.Provider request; + + @Override + public Response toResponse(UnsupportedOperationException throwable) { + if (request == null) { + return Response.status(Response.Status.NOT_FOUND).build(); + } else { + return Response.ok(throwable.getMessage()).status(Response.Status.NOT_IMPLEMENTED).build(); + } + } +} diff --git a/aws-serverless-java-container-jersey/src/test/java/com/amazonaws/serverless/proxy/jersey/providers/ServletRequestFilter.java b/aws-serverless-java-container-jersey/src/test/java/com/amazonaws/serverless/proxy/jersey/providers/ServletRequestFilter.java new file mode 100644 index 00000000..8ccfe488 --- /dev/null +++ b/aws-serverless-java-container-jersey/src/test/java/com/amazonaws/serverless/proxy/jersey/providers/ServletRequestFilter.java @@ -0,0 +1,22 @@ +package com.amazonaws.serverless.proxy.jersey.providers; + + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.ws.rs.container.ContainerRequestContext; +import jakarta.ws.rs.container.ContainerRequestFilter; +import jakarta.ws.rs.core.Context; + +import java.io.IOException; + + +public class ServletRequestFilter implements ContainerRequestFilter { + public static final String FILTER_ATTRIBUTE_NAME = "ServletFilter"; + public static final String FILTER_ATTRIBUTE_VALUE = "done"; + + @Context HttpServletRequest request; + + public void filter(ContainerRequestContext ctx) throws IOException { + request.setAttribute(FILTER_ATTRIBUTE_NAME, FILTER_ATTRIBUTE_VALUE); + } + +} \ No newline at end of file diff --git a/aws-serverless-java-container-jersey/src/test/java/com/amazonaws/serverless/proxy/test/jersey/EchoJerseyResource.java b/aws-serverless-java-container-jersey/src/test/java/com/amazonaws/serverless/proxy/test/jersey/EchoJerseyResource.java deleted file mode 100644 index 33d54392..00000000 --- a/aws-serverless-java-container-jersey/src/test/java/com/amazonaws/serverless/proxy/test/jersey/EchoJerseyResource.java +++ /dev/null @@ -1,130 +0,0 @@ -/* - * Copyright 2016 Amazon.com, Inc. or its affiliates. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance - * with the License. A copy of the License is located at - * - * http://aws.amazon.com/apache2.0/ - * - * or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES - * OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions - * and limitations under the License. - */ -package com.amazonaws.serverless.proxy.test.jersey; - -import com.amazonaws.serverless.proxy.internal.RequestReader; -import com.amazonaws.serverless.proxy.internal.model.ApiGatewayRequestContext; -import com.amazonaws.serverless.proxy.test.jersey.model.MapResponseModel; -import com.amazonaws.serverless.proxy.test.jersey.model.SingleValueModel; - -import javax.servlet.ServletContext; -import javax.servlet.http.HttpServletRequest; -import javax.ws.rs.*; -import javax.ws.rs.container.ContainerRequestContext; -import javax.ws.rs.core.Context; -import javax.ws.rs.core.MediaType; -import javax.ws.rs.core.Response; -import javax.ws.rs.core.UriInfo; -import java.util.Enumeration; -import java.util.Random; - -/** - * Jersey application class for aws-serverless-java-container unit proxy - */ -@Path("/echo") -public class EchoJerseyResource { - - @Path("/headers") @GET - @Produces(MediaType.APPLICATION_JSON) - public MapResponseModel echoHeaders(@Context ContainerRequestContext context) { - MapResponseModel headers = new MapResponseModel(); - for (String key : context.getHeaders().keySet()) { - headers.addValue(key, context.getHeaderString(key)); - } - - return headers; - } - - @Path("/servlet-headers") @GET - @Produces(MediaType.APPLICATION_JSON) - public MapResponseModel echoServletHeaders(@Context HttpServletRequest context) { - MapResponseModel headers = new MapResponseModel(); - Enumeration headerNames = context.getHeaderNames(); - while (headerNames.hasMoreElements()) { - String headerName = headerNames.nextElement(); - headers.addValue(headerName, context.getHeader(headerName)); - } - - return headers; - } - - @Path("/servlet-context") @GET - @Produces(MediaType.APPLICATION_JSON) - public SingleValueModel echoContextInformation(@Context ServletContext context) { - SingleValueModel singleValueModel = new SingleValueModel(); - singleValueModel.setValue(context.getServerInfo()); - - return singleValueModel; - } - - @Path("/query-string") @GET - @Produces(MediaType.APPLICATION_JSON) - public MapResponseModel echoQueryString(@Context UriInfo context) { - MapResponseModel queryStrings = new MapResponseModel(); - for (String key : context.getQueryParameters().keySet()) { - queryStrings.addValue(key, context.getQueryParameters().getFirst(key)); - } - - return queryStrings; - } - - @Path("/authorizer-principal") @GET - @Produces(MediaType.APPLICATION_JSON) - public SingleValueModel echoAuthorizerPrincipal(@Context ContainerRequestContext context) { - SingleValueModel valueModel = new SingleValueModel(); - ApiGatewayRequestContext apiGatewayRequestContext = - (ApiGatewayRequestContext) context.getProperty(RequestReader.API_GATEWAY_CONTEXT_PROPERTY); - valueModel.setValue(apiGatewayRequestContext.getAuthorizer().getPrincipalId()); - - return valueModel; - } - - @Path("/authorizer-context") @GET - @Produces(MediaType.APPLICATION_JSON) - public SingleValueModel echoAuthorizerContext(@Context ContainerRequestContext context, @QueryParam("key") String key) { - SingleValueModel valueModel = new SingleValueModel(); - ApiGatewayRequestContext apiGatewayRequestContext = - (ApiGatewayRequestContext) context.getProperty(RequestReader.API_GATEWAY_CONTEXT_PROPERTY); - valueModel.setValue(apiGatewayRequestContext.getAuthorizer().getContextValue(key)); - - return valueModel; - } - - @Path("/json-body") @POST - @Consumes(MediaType.APPLICATION_JSON) - @Produces(MediaType.APPLICATION_JSON) - public SingleValueModel echoJsonValue(final SingleValueModel requestValue) { - SingleValueModel output = new SingleValueModel(); - output.setValue(requestValue.getValue()); - - return output; - } - - @Path("/status-code") @GET - @Produces(MediaType.APPLICATION_JSON) - public Response echoCustomStatusCode(@QueryParam("status") int statusCode ) { - SingleValueModel output = new SingleValueModel(); - output.setValue("" + statusCode); - - return Response.status(statusCode).entity(output).build(); - } - - @Path("/binary") @GET - @Produces("application/octet-stream") - public Response echoBinaryData() { - byte[] b = new byte[128]; - new Random().nextBytes(b); - - return Response.ok(b).build(); - } -} diff --git a/aws-serverless-java-container-jersey/src/test/java/com/amazonaws/serverless/proxy/test/jersey/JerseyAwsProxyTest.java b/aws-serverless-java-container-jersey/src/test/java/com/amazonaws/serverless/proxy/test/jersey/JerseyAwsProxyTest.java deleted file mode 100644 index 781af55d..00000000 --- a/aws-serverless-java-container-jersey/src/test/java/com/amazonaws/serverless/proxy/test/jersey/JerseyAwsProxyTest.java +++ /dev/null @@ -1,239 +0,0 @@ -/* - * Copyright 2016 Amazon.com, Inc. or its affiliates. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance - * with the License. A copy of the License is located at - * - * http://aws.amazon.com/apache2.0/ - * - * or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES - * OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions - * and limitations under the License. - */ -package com.amazonaws.serverless.proxy.test.jersey; - - -import com.amazonaws.serverless.proxy.internal.model.AwsProxyRequest; -import com.amazonaws.serverless.proxy.internal.model.AwsProxyResponse; -import com.amazonaws.serverless.proxy.internal.servlet.AwsServletContext; -import com.amazonaws.serverless.proxy.jersey.factory.AwsProxyServletContextFactory; -import com.amazonaws.serverless.proxy.jersey.factory.AwsProxyServletRequestFactory; -import com.amazonaws.serverless.proxy.jersey.JerseyLambdaContainerHandler; -import com.amazonaws.serverless.proxy.test.jersey.model.MapResponseModel; -import com.amazonaws.serverless.proxy.test.jersey.model.SingleValueModel; -import com.amazonaws.serverless.proxy.internal.testutils.AwsProxyRequestBuilder; -import com.amazonaws.serverless.proxy.internal.testutils.MockLambdaContext; -import com.amazonaws.services.lambda.runtime.Context; - -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; -import org.apache.commons.codec.binary.Base64; -import org.glassfish.hk2.utilities.binding.AbstractBinder; -import org.glassfish.jersey.process.internal.RequestScoped; -import org.glassfish.jersey.server.ResourceConfig; -import org.junit.Test; - -import javax.servlet.ServletContext; -import javax.servlet.http.HttpServletRequest; - -import java.io.IOException; -import java.util.UUID; - -import static org.junit.Assert.*; - -/** - * Unit test class for the Jersey AWS_PROXY default implementation - */ -public class JerseyAwsProxyTest { - private static final String CUSTOM_HEADER_KEY = "x-custom-header"; - private static final String CUSTOM_HEADER_VALUE = "my-custom-value"; - private static final String AUTHORIZER_PRINCIPAL_ID = "test-principal-" + UUID.randomUUID().toString(); - - - private static ObjectMapper objectMapper = new ObjectMapper(); - private static ResourceConfig app = new ResourceConfig().packages("com.amazonaws.serverless.proxy.test.jersey") - .register(new AbstractBinder() { - @Override - protected void configure() { - bindFactory(AwsProxyServletRequestFactory.class) - .to(HttpServletRequest.class) - .in(RequestScoped.class); - bindFactory(AwsProxyServletContextFactory.class) - .to(ServletContext.class) - .in(RequestScoped.class); - } - }); - private static JerseyLambdaContainerHandler handler = JerseyLambdaContainerHandler.getAwsProxyHandler(app); - - private static Context lambdaContext = new MockLambdaContext(); - - @Test - public void headers_getHeaders_echo() { - AwsProxyRequest request = new AwsProxyRequestBuilder("/echo/headers", "GET") - .json() - .header(CUSTOM_HEADER_KEY, CUSTOM_HEADER_VALUE) - .build(); - - AwsProxyResponse output = handler.proxy(request, lambdaContext); - assertEquals(200, output.getStatusCode()); - assertEquals("application/json", output.getHeaders().get("Content-Type")); - - validateMapResponseModel(output); - } - - @Test - public void headers_servletRequest_echo() { - AwsProxyRequest request = new AwsProxyRequestBuilder("/echo/servlet-headers", "GET") - .json() - .header(CUSTOM_HEADER_KEY, CUSTOM_HEADER_VALUE) - .build(); - - AwsProxyResponse output = handler.proxy(request, lambdaContext); - assertEquals(200, output.getStatusCode()); - assertEquals("application/json", output.getHeaders().get("Content-Type")); - - validateMapResponseModel(output); - } - - @Test - public void context_serverInfo_correctContext() { - AwsProxyRequest request = new AwsProxyRequestBuilder("/echo/servlet-context", "GET").build(); - AwsProxyResponse output = handler.proxy(request, lambdaContext); - assertEquals(200, output.getStatusCode()); - assertEquals("application/json", output.getHeaders().get("Content-Type")); - - validateSingleValueModel(output, AwsServletContext.SERVER_INFO); - } - - @Test - public void queryString_uriInfo_echo() { - AwsProxyRequest request = new AwsProxyRequestBuilder("/echo/query-string", "GET") - .json() - .queryString(CUSTOM_HEADER_KEY, CUSTOM_HEADER_VALUE) - .build(); - - AwsProxyResponse output = handler.proxy(request, lambdaContext); - assertEquals(200, output.getStatusCode()); - assertEquals("application/json", output.getHeaders().get("Content-Type")); - - validateMapResponseModel(output); - } - - @Test - public void authorizer_securityContext_customPrincipalSuccess() { - AwsProxyRequest request = new AwsProxyRequestBuilder("/echo/authorizer-principal", "GET") - .json() - .authorizerPrincipal(AUTHORIZER_PRINCIPAL_ID) - .build(); - - AwsProxyResponse output = handler.proxy(request, lambdaContext); - assertEquals(200, output.getStatusCode()); - assertEquals("application/json", output.getHeaders().get("Content-Type")); - - validateSingleValueModel(output, AUTHORIZER_PRINCIPAL_ID); - } - - @Test - public void authorizer_securityContext_customAuthorizerContextSuccess() { - AwsProxyRequest request = new AwsProxyRequestBuilder("/echo/authorizer-context", "GET") - .json() - .authorizerPrincipal(AUTHORIZER_PRINCIPAL_ID) - .authorizerContextValue(CUSTOM_HEADER_KEY, CUSTOM_HEADER_VALUE) - .queryString("key", CUSTOM_HEADER_KEY) - .build(); - - AwsProxyResponse output = handler.proxy(request, lambdaContext); - assertEquals(200, output.getStatusCode()); - assertEquals("application/json", output.getHeaders().get("Content-Type")); - - validateSingleValueModel(output, CUSTOM_HEADER_VALUE); - } - - @Test - public void errors_unknownRoute_expect404() { - AwsProxyRequest request = new AwsProxyRequestBuilder("/echo/test33", "GET").build(); - - AwsProxyResponse output = handler.proxy(request, lambdaContext); - assertEquals(404, output.getStatusCode()); - } - - @Test - public void error_contentType_invalidContentType() { - AwsProxyRequest request = new AwsProxyRequestBuilder("/echo/json-body", "POST") - .header("Content-Type", "application/octet-stream") - .body("asdasdasd") - .build(); - - AwsProxyResponse output = handler.proxy(request, lambdaContext); - assertEquals(415, output.getStatusCode()); - } - - @Test - public void error_statusCode_methodNotAllowed() { - AwsProxyRequest request = new AwsProxyRequestBuilder("/echo/status-code", "POST") - .json() - .queryString("status", "201") - .build(); - - AwsProxyResponse output = handler.proxy(request, lambdaContext); - assertEquals(405, output.getStatusCode()); - } - - @Test - public void responseBody_responseWriter_validBody() throws JsonProcessingException { - SingleValueModel singleValueModel = new SingleValueModel(); - singleValueModel.setValue(CUSTOM_HEADER_VALUE); - AwsProxyRequest request = new AwsProxyRequestBuilder("/echo/json-body", "POST") - .json() - .body(objectMapper.writeValueAsString(singleValueModel)) - .build(); - - AwsProxyResponse output = handler.proxy(request, lambdaContext); - assertEquals(200, output.getStatusCode()); - assertNotNull(output.getBody()); - - validateSingleValueModel(output, CUSTOM_HEADER_VALUE); - } - - @Test - public void statusCode_responseStatusCode_customStatusCode() { - AwsProxyRequest request = new AwsProxyRequestBuilder("/echo/status-code", "GET") - .json() - .queryString("status", "201") - .build(); - - AwsProxyResponse output = handler.proxy(request, lambdaContext); - assertEquals(201, output.getStatusCode()); - } - - @Test - public void base64_binaryResponse_base64Encoding() { - AwsProxyRequest request = new AwsProxyRequestBuilder("/echo/binary", "GET").build(); - - AwsProxyResponse response = handler.proxy(request, lambdaContext); - assertNotNull(response.getBody()); - assertTrue(Base64.isBase64(response.getBody())); - } - - private void validateMapResponseModel(AwsProxyResponse output) { - try { - MapResponseModel response = objectMapper.readValue(output.getBody(), MapResponseModel.class); - assertNotNull(response.getValues().get(CUSTOM_HEADER_KEY)); - assertEquals(CUSTOM_HEADER_VALUE, response.getValues().get(CUSTOM_HEADER_KEY)); - } catch (IOException e) { - fail("Exception while parsing response body: " + e.getMessage()); - e.printStackTrace(); - } - } - - private void validateSingleValueModel(AwsProxyResponse output, String value) { - try { - SingleValueModel response = objectMapper.readValue(output.getBody(), SingleValueModel.class); - assertNotNull(response.getValue()); - assertEquals(value, response.getValue()); - } catch (IOException e) { - fail("Exception while parsing response body: " + e.getMessage()); - e.printStackTrace(); - } - } -} diff --git a/aws-serverless-java-container-spark/pom.xml b/aws-serverless-java-container-spark/pom.xml deleted file mode 100644 index 9de3a875..00000000 --- a/aws-serverless-java-container-spark/pom.xml +++ /dev/null @@ -1,67 +0,0 @@ - - - 4.0.0 - - aws-serverless-java-container-spark - AWS Serverless Java container support - Spark implementation - Allows Java applications written for Spark to run in AWS Lambda - https://aws.amazon.com/lambda - 0.5-SNAPSHOT - - - com.amazonaws.serverless - aws-serverless-java-container - 0.5-SNAPSHOT - - - - 2.8.4 - 2.5.3 - - - - - - com.amazonaws.serverless - aws-serverless-java-container-core - 0.5-SNAPSHOT - - - - - com.sparkjava - spark-core - ${spark.version} - - - - - com.fasterxml.jackson.core - jackson-core - ${jackson.version} - - - - - com.fasterxml.jackson.core - jackson-databind - ${jackson.version} - - - - - com.fasterxml.jackson.core - jackson-annotations - ${jackson.version} - - - - - junit - junit - 4.12 - test - - - - diff --git a/aws-serverless-java-container-spark/src/main/java/com/amazonaws/serverless/proxy/spark/SparkLambdaContainerHandler.java b/aws-serverless-java-container-spark/src/main/java/com/amazonaws/serverless/proxy/spark/SparkLambdaContainerHandler.java deleted file mode 100644 index 1dfce86a..00000000 --- a/aws-serverless-java-container-spark/src/main/java/com/amazonaws/serverless/proxy/spark/SparkLambdaContainerHandler.java +++ /dev/null @@ -1,162 +0,0 @@ -/* - * Copyright 2016 Amazon.com, Inc. or its affiliates. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance - * with the License. A copy of the License is located at - * - * http://aws.amazon.com/apache2.0/ - * - * or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES - * OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions - * and limitations under the License. - */ -package com.amazonaws.serverless.proxy.spark; - -import com.amazonaws.serverless.exceptions.ContainerInitializationException; -import com.amazonaws.serverless.proxy.internal.*; -import com.amazonaws.serverless.proxy.internal.model.AwsProxyRequest; -import com.amazonaws.serverless.proxy.internal.model.AwsProxyResponse; -import com.amazonaws.serverless.proxy.internal.servlet.*; -import com.amazonaws.serverless.proxy.spark.embeddedserver.LambdaEmbeddedServer; -import com.amazonaws.serverless.proxy.spark.embeddedserver.LambdaEmbeddedServerFactory; - -import com.amazonaws.services.lambda.runtime.Context; -import spark.Service; -import spark.Spark; -import spark.embeddedserver.EmbeddedServers; - -import java.lang.reflect.Field; -import java.lang.reflect.InvocationTargetException; -import java.lang.reflect.Method; -import java.util.concurrent.CountDownLatch; - -/** - * Implementation of the LambdaContainerHandler object that supports the Spark framework: http://sparkjava.com/ - * - * Because of the way this container is implemented, using reflection to change accessibility of methods in the Spark - * framework and inserting itself as the default embedded container, it is important that you initialize the Handler - * before declaring your spark routes. - * - * This implementation uses the default AwsProxyHttpServletRequest and Response implementations. - * - *
- * {@code
- *     // always initialize the handler first
- *     SparkLambdaContainerHandler handler =
- *             SparkLambdaContainerHandler.getAwsProxyHandler();
- *
- *     get("/hello", (req, res) -> {
- *         res.status(200);
- *         res.body("Hello World");
- *     });
- * }
- * 
- * @param The request object used by the RequestReader implementation passed to the constructor - * @param The response object produced by the ResponseWriter implementation in the constructor - */ -public class SparkLambdaContainerHandler extends AwsLambdaServletContainerHandler { - - //------------------------------------------------------------- - // Constants - //------------------------------------------------------------- - - private static final String LAMBDA_EMBEDDED_SERVER_CODE = "AWS_LAMBDA"; - - - //------------------------------------------------------------- - // Variables - Private - Static - //------------------------------------------------------------- - - private static LambdaEmbeddedServer embeddedServer; - - - //------------------------------------------------------------- - // Methods - Public - Static - //------------------------------------------------------------- - - /** - * Returns a new instance of an SparkLambdaContainerHandler initialized to work with AwsProxyRequest - * and AwsProxyResponse objects. - * - * @return a new instance of SparkLambdaContainerHandler - * @throws ContainerInitializationException Throws this exception if we fail to initialize the Spark container. - * This could be caused by the introspection used to insert the library as the default embedded container - */ - public static SparkLambdaContainerHandler getAwsProxyHandler() - throws ContainerInitializationException { - return new SparkLambdaContainerHandler<>(new AwsProxyHttpServletRequestReader(), - new AwsProxyHttpServletResponseWriter(), - new AwsProxySecurityContextWriter(), - new AwsProxyExceptionHandler()); - } - - - //------------------------------------------------------------- - // Constructors - //------------------------------------------------------------- - - public SparkLambdaContainerHandler(RequestReader requestReader, - ResponseWriter responseWriter, - SecurityContextWriter securityContextWriter, - ExceptionHandler exceptionHandler) - throws ContainerInitializationException { - super(requestReader, responseWriter, securityContextWriter, exceptionHandler); - - EmbeddedServers.add(LAMBDA_EMBEDDED_SERVER_CODE, new LambdaEmbeddedServerFactory()); - - // TODO: This is pretty bad but we are not given access to the embeddedServerIdentifier property of the - // Service object - try { - Method serviceInstanceMethod = Spark.class.getDeclaredMethod("getInstance"); - serviceInstanceMethod.setAccessible(true); - Service sparkService = (Service) serviceInstanceMethod.invoke(null); - Field serverIdentifierField = Service.class.getDeclaredField("embeddedServerIdentifier"); - serverIdentifierField.setAccessible(true); - serverIdentifierField.set(sparkService, LAMBDA_EMBEDDED_SERVER_CODE); - - // remove Jetty from embedded servers - EmbeddedServers.class.getDeclaredMethod("initialize"); - } catch (NoSuchFieldException e) { - e.printStackTrace(); - throw new ContainerInitializationException("Cannot find embeddedServerIdentifier field in Service class", e); - } catch (NoSuchMethodException e) { - e.printStackTrace(); - throw new ContainerInitializationException("Cannot find getInstance method in Spark class", e); - } catch (IllegalAccessException e) { - e.printStackTrace(); - throw new ContainerInitializationException("Cannot access getInstance method in Spark class", e); - } catch (InvocationTargetException e) { - e.printStackTrace(); - throw new ContainerInitializationException("Cannot invoke getInstance method in Spark class", e); - } - } - - - //------------------------------------------------------------- - // Methods - Implementation - //------------------------------------------------------------- - - @Override - protected AwsHttpServletResponse getContainerResponse(CountDownLatch latch) { - return new AwsHttpServletResponse(latch); - } - - - @Override - protected void handleRequest(AwsProxyHttpServletRequest httpServletRequest, AwsHttpServletResponse httpServletResponse, Context lambdaContext) - throws Exception { - if (embeddedServer == null) { - embeddedServer = LambdaEmbeddedServerFactory.getServerInstance(); - servletContext = httpServletRequest.getServletContext(); - - // call the onStartup event if set to give developers a chance to set filters in the context - if (startupHandler != null) { - startupHandler.onStartup(this.servletContext); - } - } - - doFilter(httpServletRequest, httpServletResponse); - - embeddedServer.handle(httpServletRequest, httpServletResponse); - } -} diff --git a/aws-serverless-java-container-spark/src/main/java/com/amazonaws/serverless/proxy/spark/embeddedserver/LambdaEmbeddedServer.java b/aws-serverless-java-container-spark/src/main/java/com/amazonaws/serverless/proxy/spark/embeddedserver/LambdaEmbeddedServer.java deleted file mode 100644 index a5dfbbab..00000000 --- a/aws-serverless-java-container-spark/src/main/java/com/amazonaws/serverless/proxy/spark/embeddedserver/LambdaEmbeddedServer.java +++ /dev/null @@ -1,83 +0,0 @@ -package com.amazonaws.serverless.proxy.spark.embeddedserver; - -import spark.embeddedserver.EmbeddedServer; -import spark.embeddedserver.jetty.websocket.WebSocketHandlerWrapper; -import spark.http.matching.MatcherFilter; -import spark.route.Routes; -import spark.ssl.SslStores; -import spark.staticfiles.StaticFilesConfiguration; - -import javax.servlet.ServletException; -import javax.servlet.http.HttpServletRequest; -import javax.servlet.http.HttpServletResponse; -import java.io.IOException; -import java.util.Map; -import java.util.Optional; -import java.util.concurrent.CountDownLatch; - -public class LambdaEmbeddedServer - implements EmbeddedServer { - - //------------------------------------------------------------- - // Variables - Private - //------------------------------------------------------------- - - private Routes applicationRoutes; - private MatcherFilter sparkFilter; - private StaticFilesConfiguration staticFilesConfiguration; - private boolean hasMultipleHandler; - - - //------------------------------------------------------------- - // Constructors - //------------------------------------------------------------- - - LambdaEmbeddedServer(Routes routes, StaticFilesConfiguration filesConfig, boolean multipleHandlers) { - applicationRoutes = routes; - staticFilesConfiguration = filesConfig; - hasMultipleHandler = multipleHandlers; - } - - - //------------------------------------------------------------- - // Implementation - EmbeddedServer - //------------------------------------------------------------- - - @Override - public int ignite(String host, - int port, - SslStores sslStores, - CountDownLatch countDownLatch, - int maxThreads, - int minThreads, - int threadIdleTimeoutMillis) { - sparkFilter = new MatcherFilter(applicationRoutes, staticFilesConfiguration, false, hasMultipleHandler); - sparkFilter.init(null); - - countDownLatch.countDown(); - - return 0; - } - - - public void configureWebSockets(Map webSocketHandlers, - Optional webSocketIdleTimeoutMillis) { - throw new UnsupportedOperationException(); - } - - - @Override - public void extinguish() { - } - - - //------------------------------------------------------------- - // Methods - Public - //------------------------------------------------------------- - - public void handle(HttpServletRequest request, HttpServletResponse response) - throws IOException, ServletException { - //RouteMatch route = applicationRoutes.find(HttpMethod.get(request.requestMethod()), request.contextPath(), "*/*"); - sparkFilter.doFilter(request, response, null); - } -} diff --git a/aws-serverless-java-container-spark/src/main/java/com/amazonaws/serverless/proxy/spark/embeddedserver/LambdaEmbeddedServerFactory.java b/aws-serverless-java-container-spark/src/main/java/com/amazonaws/serverless/proxy/spark/embeddedserver/LambdaEmbeddedServerFactory.java deleted file mode 100644 index f200f5b9..00000000 --- a/aws-serverless-java-container-spark/src/main/java/com/amazonaws/serverless/proxy/spark/embeddedserver/LambdaEmbeddedServerFactory.java +++ /dev/null @@ -1,39 +0,0 @@ -package com.amazonaws.serverless.proxy.spark.embeddedserver; - -import spark.embeddedserver.EmbeddedServer; -import spark.embeddedserver.EmbeddedServerFactory; -import spark.route.Routes; -import spark.staticfiles.StaticFilesConfiguration; - -public class LambdaEmbeddedServerFactory implements EmbeddedServerFactory { - - //------------------------------------------------------------- - // Variables - Private - Static - //------------------------------------------------------------- - - private static LambdaEmbeddedServer embeddedServer; - - - //------------------------------------------------------------- - // Implementation - EmbeddedServerFactory - //------------------------------------------------------------- - - - @Override - public EmbeddedServer create(Routes routes, StaticFilesConfiguration staticFilesConfiguration, boolean multipleHandlers) { - if (embeddedServer == null) { - embeddedServer = new LambdaEmbeddedServer(routes, staticFilesConfiguration, multipleHandlers); - } - - return embeddedServer; - } - - - //------------------------------------------------------------- - // Methods - Getter/Setter - //------------------------------------------------------------- - - public static LambdaEmbeddedServer getServerInstance() { - return embeddedServer; - } -} diff --git a/aws-serverless-java-container-spark/src/test/java/com/amazonaws/serverless/proxy/test/spark/HelloWorldSparkTest.java b/aws-serverless-java-container-spark/src/test/java/com/amazonaws/serverless/proxy/test/spark/HelloWorldSparkTest.java deleted file mode 100644 index 290330bb..00000000 --- a/aws-serverless-java-container-spark/src/test/java/com/amazonaws/serverless/proxy/test/spark/HelloWorldSparkTest.java +++ /dev/null @@ -1,39 +0,0 @@ -package com.amazonaws.serverless.proxy.test.spark; - - -import com.amazonaws.serverless.exceptions.ContainerInitializationException; -import com.amazonaws.serverless.proxy.internal.model.AwsProxyRequest; -import com.amazonaws.serverless.proxy.internal.model.AwsProxyResponse; -import com.amazonaws.serverless.proxy.spark.SparkLambdaContainerHandler; -import com.amazonaws.serverless.proxy.internal.testutils.AwsProxyRequestBuilder; -import com.amazonaws.serverless.proxy.internal.testutils.MockLambdaContext; - -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; -import junit.framework.TestCase; - -import static spark.Spark.get; - -public class HelloWorldSparkTest extends TestCase { - private ObjectMapper objectMapper = new ObjectMapper(); - - public void testSparkStart() { - try { - SparkLambdaContainerHandler handler - = SparkLambdaContainerHandler.getAwsProxyHandler(); - - get("/hello", (req, res) -> { - res.status(200); - res.body("Hello World"); - res.header("X-Custom-Header", "My Header Value"); - return "Hello World"; - }); - - AwsProxyRequest req = new AwsProxyRequestBuilder().method("GET").path("/hello").build(); - AwsProxyResponse response = handler.proxy(req, new MockLambdaContext()); - System.out.println("Response: " + objectMapper.writeValueAsString(response)); - } catch (JsonProcessingException | ContainerInitializationException e) { - e.printStackTrace(); - } - } -} diff --git a/aws-serverless-java-container-spring/pom.xml b/aws-serverless-java-container-spring/pom.xml index b435c5f9..590f3c7c 100644 --- a/aws-serverless-java-container-spring/pom.xml +++ b/aws-serverless-java-container-spring/pom.xml @@ -6,17 +6,18 @@ AWS Serverless Java container support - Spring implementation Allows Java applications written for the Spring framework to run in AWS Lambda https://aws.amazon.com/lambda - 0.5-SNAPSHOT + 3.0.3-SNAPSHOT com.amazonaws.serverless aws-serverless-java-container - 0.5-SNAPSHOT + 3.0.3-SNAPSHOT + .. - 4.3.4.RELEASE - 2.8.4 + 7.0.0 + 7.0.0 @@ -24,17 +25,24 @@ com.amazonaws.serverless aws-serverless-java-container-core - 0.5-SNAPSHOT + ${project.version} + + + com.amazonaws.serverless + aws-serverless-java-container-core + ${project.version} + tests + test-jar + test - org.springframework spring-webmvc ${spring.version} + true - org.springframework spring-test @@ -42,74 +50,184 @@ test - commons-codec commons-codec - 1.10 + 1.18.0 test - - - junit - junit - 4.12 - test - + - com.fasterxml.jackson.core - jackson-annotations - ${jackson.version} + jakarta.activation + jakarta.activation-api + 2.1.3 test + - com.fasterxml.jackson.core - jackson-core - ${jackson.version} + org.hibernate.validator + hibernate-validator + 8.0.2.Final test - - com.fasterxml.jackson.core - jackson-databind - ${jackson.version} + org.junit.jupiter + junit-jupiter test - org.hibernate - hibernate-validator - 5.4.1.Final + org.glassfish.expressly + expressly + 5.0.0 test - javax.el - javax.el-api - 2.2.4 + org.springframework.security + spring-security-config + ${spring-security.version} test + + + org.springframework + spring-aop + + + org.springframework + spring-expression + + + org.springframework + spring-context + + + org.springframework + spring-beans + + + org.springframework + spring-core + + - - org.glassfish.web - javax.el - 2.2.4 + org.springframework.security + spring-security-web + ${spring-security.version} test + + + org.springframework + spring-aop + + + org.springframework + spring-expression + + + org.springframework + spring-context + + + org.springframework + spring-beans + + + org.springframework + spring-core + + + org.springframework + spring-web + + + + org.jacoco + jacoco-maven-plugin + + ${basedir}/target/coverage-reports/jacoco-unit.exec + ${basedir}/target/coverage-reports/jacoco-unit.exec + + + + default-prepare-agent + + prepare-agent + + + + jacoco-site + package + + report + + + + jacoco-check + test + + check + + + true + + BUNDLE + + + INSTRUCTION + COVEREDRATIO + ${jacoco.minCoverage} + + + + + + + org.apache.maven.plugins maven-surefire-plugin - 2.9 - always + false + + + + com.github.spotbugs + spotbugs-maven-plugin + + + + analyze-compile + compile + + check + + + + + + org.owasp + dependency-check-maven + ${dependencyCheck.version} + + true + + ${project.basedir}/../owasp-suppression.xml + + 7 + false diff --git a/aws-serverless-java-container-spring/src/main/java/com/amazonaws/serverless/proxy/spring/LambdaSpringApplicationInitializer.java b/aws-serverless-java-container-spring/src/main/java/com/amazonaws/serverless/proxy/spring/LambdaSpringApplicationInitializer.java deleted file mode 100644 index 49ace208..00000000 --- a/aws-serverless-java-container-spring/src/main/java/com/amazonaws/serverless/proxy/spring/LambdaSpringApplicationInitializer.java +++ /dev/null @@ -1,181 +0,0 @@ -/* - * Copyright 2016 Amazon.com, Inc. or its affiliates. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance - * with the License. A copy of the License is located at - * - * http://aws.amazon.com/apache2.0/ - * - * or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES - * OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions - * and limitations under the License. - */ -package com.amazonaws.serverless.proxy.spring; - -import org.springframework.context.ApplicationEvent; -import org.springframework.context.ApplicationListener; -import org.springframework.context.event.ContextRefreshedEvent; -import org.springframework.context.event.ContextStoppedEvent; -import org.springframework.web.WebApplicationInitializer; -import org.springframework.web.context.ConfigurableWebApplicationContext; -import org.springframework.web.context.ContextLoaderListener; -import org.springframework.web.context.WebApplicationContext; -import org.springframework.web.context.support.AnnotationConfigWebApplicationContext; -import org.springframework.web.context.support.ServletRequestHandledEvent; -import org.springframework.web.servlet.DispatcherServlet; - -import javax.servlet.*; -import javax.servlet.http.HttpServletRequest; -import javax.servlet.http.HttpServletResponse; -import java.io.IOException; -import java.util.*; - -/** - * Custom implementation of Spring's `WebApplicationInitializer`. Uses internal variables to keep application state - * and creates a DispatcherServlet to handle incoming events. When the first event arrives it extracts the ServletContext - * and starts the Spring application. This class assumes that the implementation of `HttpServletRequest` that is passed - * in correctly implements the `getServletContext` method. - * - * State is kept using the `initialized` boolean variable. Each time a new event is received, the app sets the - * `currentResponse` private property to the value of the new `HttpServletResponse` object. This is used to intercept - * Spring notifications for the `ServletRequestHandledEvent` and call the flush method to release the latch. - */ -public class LambdaSpringApplicationInitializer implements WebApplicationInitializer { - public static final String ERROR_NO_CONTEXT = "No application context or configuration classes provided"; - - private static final String DEFAULT_SERVLET_NAME = "aws-servless-java-container"; - - // Configuration variables that can be passed in - private ConfigurableWebApplicationContext applicationContext; - private boolean refreshContext = true; - private List contextListeners; - private List springProfiles; - - // Dynamically instantiated properties - private ServletConfig dispatcherConfig; - private DispatcherServlet dispatcherServlet; - - // The current response is used to release the latch when Spring emits the request handled event - private HttpServletResponse currentResponse; - - /** - * Creates a new instance of the WebApplicationInitializer - * @param applicationContext A custom ConfigurableWebApplicationContext to be used - */ - public LambdaSpringApplicationInitializer(ConfigurableWebApplicationContext applicationContext) { - this.contextListeners = new ArrayList<>(); - this.applicationContext = applicationContext; - } - - /** - * Adds a new listener for the servlet context events. At the moment the library only emits events when the application - * is initialized. Because we don't have container lifecycle notifications from Lambda the `contextDestroyed` - * method is never called - * @param listener An implementation of `ServletContextListener` - */ - public void addListener(ServletContextListener listener) { - contextListeners.add(listener); - } - - public void setRefreshContext(boolean refreshContext) { - this.refreshContext = refreshContext; - } - - /** - * Given a request and response objects, triggers the filters set in the servlet context and - * @param request - * @param response - * @throws ServletException - * @throws IOException - */ - public void dispatch(HttpServletRequest request, HttpServletResponse response) - throws ServletException, IOException { - currentResponse = response; - dispatcherServlet.service(request, response); - } - - public List getSpringProfiles() { - return Collections.unmodifiableList(springProfiles); - } - - public void setSpringProfiles(List springProfiles) { - this.springProfiles = new ArrayList<>(springProfiles); - } - - @Override - public void onStartup(ServletContext servletContext) throws ServletException { - if (springProfiles != null) { - applicationContext.getEnvironment().setActiveProfiles(springProfiles.toArray(new String[0])); - } - applicationContext.setServletContext(servletContext); - - dispatcherConfig = new DefaultDispatcherConfig(servletContext); - applicationContext.setServletConfig(dispatcherConfig); - - // Configure the listener for the request handled events. All we do here is release the latch - applicationContext.addApplicationListener(new ApplicationListener() { - @Override - public void onApplicationEvent(ServletRequestHandledEvent servletRequestHandledEvent) { - try { - currentResponse.flushBuffer(); - } catch (IOException e) { - e.printStackTrace(); - throw new RuntimeException("Could not flush response buffer", e); - } - } - }); - - // Manage the lifecycle of the root application context - this.addListener(new ContextLoaderListener(applicationContext)); - - // Register and map the dispatcher servlet - dispatcherServlet = new DispatcherServlet(applicationContext); - - if (refreshContext) { - dispatcherServlet.refresh(); - } - - dispatcherServlet.onApplicationEvent(new ContextRefreshedEvent(applicationContext)); - dispatcherServlet.init(dispatcherConfig); - - notifyStartListeners(servletContext); - } - - private void notifyStartListeners(ServletContext context) { - for (ServletContextListener listener : contextListeners) { - listener.contextInitialized(new ServletContextEvent(context)); - } - } - - /** - * Default configuration class for the DispatcherServlet. This just mocks the behaviour of a default - * ServletConfig object with no init parameters - */ - private static class DefaultDispatcherConfig implements ServletConfig { - private ServletContext servletContext; - - DefaultDispatcherConfig(ServletContext context) { - servletContext = context; - } - - @Override - public String getServletName() { - return DEFAULT_SERVLET_NAME; - } - - @Override - public ServletContext getServletContext() { - return servletContext; - } - - @Override - public String getInitParameter(String s) { - return null; - } - - @Override - public Enumeration getInitParameterNames() { - return Collections.emptyEnumeration(); - } - } -} diff --git a/aws-serverless-java-container-spring/src/main/java/com/amazonaws/serverless/proxy/spring/SpringAwsProxyExceptionHandler.java b/aws-serverless-java-container-spring/src/main/java/com/amazonaws/serverless/proxy/spring/SpringAwsProxyExceptionHandler.java new file mode 100644 index 00000000..21ec9416 --- /dev/null +++ b/aws-serverless-java-container-spring/src/main/java/com/amazonaws/serverless/proxy/spring/SpringAwsProxyExceptionHandler.java @@ -0,0 +1,24 @@ +package com.amazonaws.serverless.proxy.spring; + +import com.amazonaws.serverless.proxy.AwsProxyExceptionHandler; +import com.amazonaws.serverless.proxy.ExceptionHandler; +import com.amazonaws.serverless.proxy.model.AwsProxyResponse; +import org.springframework.web.ErrorResponse; + +/** + * This ExceptionHandler implementation enhances the standard AwsProxyExceptionHandler + * by mapping additional details from org.springframework.web.ErrorResponse + */ +public class SpringAwsProxyExceptionHandler extends AwsProxyExceptionHandler + implements ExceptionHandler { + @Override + public AwsProxyResponse handle(Throwable ex) { + if (ex instanceof ErrorResponse) { + return new AwsProxyResponse(((ErrorResponse) ex).getStatusCode().value(), + HEADERS, getErrorJson(ex.getMessage())); + } else { + return super.handle(ex); + } + } + +} diff --git a/aws-serverless-java-container-spring/src/main/java/com/amazonaws/serverless/proxy/spring/SpringLambdaContainerHandler.java b/aws-serverless-java-container-spring/src/main/java/com/amazonaws/serverless/proxy/spring/SpringLambdaContainerHandler.java index 3a911d18..5e56dba6 100644 --- a/aws-serverless-java-container-spring/src/main/java/com/amazonaws/serverless/proxy/spring/SpringLambdaContainerHandler.java +++ b/aws-serverless-java-container-spring/src/main/java/com/amazonaws/serverless/proxy/spring/SpringLambdaContainerHandler.java @@ -13,16 +13,20 @@ package com.amazonaws.serverless.proxy.spring; import com.amazonaws.serverless.exceptions.ContainerInitializationException; -import com.amazonaws.serverless.proxy.internal.*; -import com.amazonaws.serverless.proxy.internal.model.AwsProxyRequest; -import com.amazonaws.serverless.proxy.internal.model.AwsProxyResponse; +import com.amazonaws.serverless.proxy.*; +import com.amazonaws.serverless.proxy.internal.testutils.Timer; +import com.amazonaws.serverless.proxy.model.AwsProxyRequest; +import com.amazonaws.serverless.proxy.model.AwsProxyResponse; import com.amazonaws.serverless.proxy.internal.servlet.*; +import com.amazonaws.serverless.proxy.model.HttpApiV2ProxyRequest; import com.amazonaws.services.lambda.runtime.Context; import org.springframework.web.context.ConfigurableWebApplicationContext; -import org.springframework.web.context.support.AnnotationConfigWebApplicationContext; +import org.springframework.web.servlet.DispatcherServlet; + +import jakarta.servlet.Servlet; +import jakarta.servlet.ServletRegistration; +import jakarta.servlet.http.HttpServletRequest; -import javax.servlet.ServletContext; -import java.util.Arrays; import java.util.concurrent.CountDownLatch; /** @@ -32,109 +36,156 @@ * @param The incoming event type * @param The expected return type */ -public class SpringLambdaContainerHandler extends AwsLambdaServletContainerHandler { - private LambdaSpringApplicationInitializer initializer; +public class SpringLambdaContainerHandler extends AwsLambdaServletContainerHandler { + protected final ConfigurableWebApplicationContext appContext; + private String[] profiles; // State vars - private boolean initialized; - + private boolean refreshContext = false; /** * Creates a default SpringLambdaContainerHandler initialized with the `AwsProxyRequest` and `AwsProxyResponse` objects * @param config A set of classes annotated with the Spring @Configuration annotation * @return An initialized instance of the `SpringLambdaContainerHandler` - * @throws ContainerInitializationException + * @throws ContainerInitializationException When the Spring framework fails to start. */ - public static SpringLambdaContainerHandler getAwsProxyHandler(Class... config) throws ContainerInitializationException { - AnnotationConfigWebApplicationContext applicationContext = new AnnotationConfigWebApplicationContext(); - applicationContext.register(config); - - return new SpringLambdaContainerHandler<>( - new AwsProxyHttpServletRequestReader(), - new AwsProxyHttpServletResponseWriter(), - new AwsProxySecurityContextWriter(), - new AwsProxyExceptionHandler(), - applicationContext - ); + public static SpringLambdaContainerHandler getAwsProxyHandler(Class... config) throws ContainerInitializationException { + return new SpringProxyHandlerBuilder() + .defaultProxy() + .initializationWrapper(new InitializationWrapper()) + .configurationClasses(config) + .buildAndInitialize(); } /** - * Creates a default SpringLambdaContainerHandler initialized with the `AwsProxyRequest` and `AwsProxyResponse` objects + * Creates a default SpringLambdaContainerHandler initialized with the `AwsProxyRequest` and `AwsProxyResponse` objects and sets the given profiles as active * @param applicationContext A custom ConfigurableWebApplicationContext to be used + * @param profiles The spring profiles to activate * @return An initialized instance of the `SpringLambdaContainerHandler` - * @throws ContainerInitializationException + * @throws ContainerInitializationException When the Spring framework fails to start. */ - public static SpringLambdaContainerHandler getAwsProxyHandler(ConfigurableWebApplicationContext applicationContext) + public static SpringLambdaContainerHandler getAwsProxyHandler(ConfigurableWebApplicationContext applicationContext, String... profiles) throws ContainerInitializationException { - return new SpringLambdaContainerHandler<>( - new AwsProxyHttpServletRequestReader(), - new AwsProxyHttpServletResponseWriter(), - new AwsProxySecurityContextWriter(), - new AwsProxyExceptionHandler(), - applicationContext - ); + return new SpringProxyHandlerBuilder() + .defaultProxy() + .initializationWrapper(new InitializationWrapper()) + .springApplicationContext(applicationContext) + .profiles(profiles) + .buildAndInitialize(); + } + + /** + * Creates a default SpringLambdaContainerHandler initialized with the `HttpApiV2ProxyRequest` and `AwsProxyResponse` objects + * @param config A set of classes annotated with the Spring @Configuration annotation + * @return An initialized instance of the `SpringLambdaContainerHandler` + * @throws ContainerInitializationException When the Spring framework fails to start. + */ + public static SpringLambdaContainerHandler getHttpApiV2ProxyHandler(Class... config) throws ContainerInitializationException { + return new SpringProxyHandlerBuilder() + .defaultHttpApiV2Proxy() + .initializationWrapper(new InitializationWrapper()) + .configurationClasses(config) + .buildAndInitialize(); } /** * Creates a new container handler with the given reader and writer objects * + * @param requestTypeClass The class for the incoming Lambda event * @param requestReader An implementation of `RequestReader` * @param responseWriter An implementation of `ResponseWriter` * @param securityContextWriter An implementation of `SecurityContextWriter` * @param exceptionHandler An implementation of `ExceptionHandler` - * @throws ContainerInitializationException */ - public SpringLambdaContainerHandler(RequestReader requestReader, + public SpringLambdaContainerHandler(Class requestTypeClass, + Class responseTypeClass, + RequestReader requestReader, ResponseWriter responseWriter, SecurityContextWriter securityContextWriter, ExceptionHandler exceptionHandler, - ConfigurableWebApplicationContext applicationContext) - throws ContainerInitializationException { - super(requestReader, responseWriter, securityContextWriter, exceptionHandler); - initializer = new LambdaSpringApplicationInitializer(applicationContext); + ConfigurableWebApplicationContext applicationContext, + InitializationWrapper init) { + super(requestTypeClass, responseTypeClass, requestReader, responseWriter, securityContextWriter, exceptionHandler); + Timer.start("SPRING_CONTAINER_HANDLER_CONSTRUCTOR"); + appContext = applicationContext; + setInitializationWrapper(init); + Timer.stop("SPRING_CONTAINER_HANDLER_CONSTRUCTOR"); } - public void setRefreshContext(boolean refreshContext) { - this.initializer.setRefreshContext(refreshContext); + + /** + * Asks the custom web application initializer to refresh the Spring context. + * @param refresh true if the context should be refreshed + */ + public void setRefreshContext(boolean refresh) { + //this.initializer.setRefreshContext(refreshContext); + refreshContext = refresh; } + @Override - protected AwsHttpServletResponse getContainerResponse(CountDownLatch latch) { - return new AwsHttpServletResponse(latch); + protected AwsHttpServletResponse getContainerResponse(HttpServletRequest request, CountDownLatch latch) { + return new AwsHttpServletResponse(request, latch); } - public void activateSpringProfiles(String... profiles) throws ContainerInitializationException { - if (initializer == null) { - throw new ContainerInitializationException(LambdaSpringApplicationInitializer.ERROR_NO_CONTEXT, null); - } - initializer.setSpringProfiles(Arrays.asList(profiles)); + /** + * Activates the given Spring profiles in the application. This method will cause the context to be + * refreshed. To use a single Spring profile, use the static method {@link SpringLambdaContainerHandler#getAwsProxyHandler(ConfigurableWebApplicationContext, String...)} + * @param p A number of spring profiles + * @throws ContainerInitializationException if the initializer is not set yet. + */ + public void activateSpringProfiles(String... p) throws ContainerInitializationException { + profiles = p; + setServletContext(new AwsServletContext(this)); + appContext.registerShutdownHook(); + appContext.close(); + initialize(); } @Override - protected void handleRequest(AwsProxyHttpServletRequest containerRequest, AwsHttpServletResponse containerResponse, Context lambdaContext) throws Exception { - if (initializer == null) { - throw new ContainerInitializationException(LambdaSpringApplicationInitializer.ERROR_NO_CONTEXT, null); + protected void handleRequest(HttpServletRequest containerRequest, AwsHttpServletResponse containerResponse, Context lambdaContext) throws Exception { + Timer.start("SPRING_HANDLE_REQUEST"); + + if (refreshContext) { + appContext.refresh(); + refreshContext = false; } - // wire up the application context on the first invocation - if (!initialized) { - // The servlet context should not be linked to a specific request object, only to the Lambda - // context so we only set it once. - setServletContext(containerRequest.getServletContext()); - initializer.onStartup(this.servletContext); + if (AwsHttpServletRequest.class.isAssignableFrom(containerRequest.getClass())) { + ((AwsHttpServletRequest)containerRequest).setServletContext(getServletContext()); + ((AwsHttpServletRequest)containerRequest).setResponse(containerResponse); + } + + // process filters + Servlet reqServlet = ((AwsServletContext)getServletContext()).getServletForPath(containerRequest.getPathInfo()); + doFilter(containerRequest, containerResponse, reqServlet); + Timer.stop("SPRING_HANDLE_REQUEST"); + } - // call the onStartup event if set to give developers a chance to set filters in the context - if (startupHandler != null) { - startupHandler.onStartup(this.servletContext); - } - initialized = true; + @Override + public void initialize() + throws ContainerInitializationException { + Timer.start("SPRING_COLD_START"); + if (profiles != null) { + appContext.getEnvironment().setActiveProfiles(profiles); } + appContext.setServletContext(getServletContext()); + registerServlets(); + // call initialize on AwsLambdaServletContainerHandler to initialize servlets that are set to load on startup + super.initialize(); + Timer.stop("SPRING_COLD_START"); + } - // process filters - doFilter(containerRequest, containerResponse); - // invoke servlet - initializer.dispatch(containerRequest, containerResponse); + /** + * Overriding this method allows to customize the standard Spring DispatcherServlet + * or to register additional servlets + */ + protected void registerServlets() { + DispatcherServlet dispatcher = new DispatcherServlet(appContext); + ServletRegistration.Dynamic reg = getServletContext().addServlet("dispatcherServlet", dispatcher); + reg.addMapping("/"); + reg.setLoadOnStartup(1); } } diff --git a/aws-serverless-java-container-spring/src/main/java/com/amazonaws/serverless/proxy/spring/SpringProxyHandlerBuilder.java b/aws-serverless-java-container-spring/src/main/java/com/amazonaws/serverless/proxy/spring/SpringProxyHandlerBuilder.java new file mode 100644 index 00000000..ba6cbba3 --- /dev/null +++ b/aws-serverless-java-container-spring/src/main/java/com/amazonaws/serverless/proxy/spring/SpringProxyHandlerBuilder.java @@ -0,0 +1,95 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance + * with the License. A copy of the License is located at + * + * http://aws.amazon.com/apache2.0/ + * + * or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES + * OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions + * and limitations under the License. + */ +package com.amazonaws.serverless.proxy.spring; + +import com.amazonaws.serverless.exceptions.ContainerInitializationException; +import com.amazonaws.serverless.proxy.ExceptionHandler; +import com.amazonaws.serverless.proxy.internal.servlet.ServletLambdaContainerHandlerBuilder; +import com.amazonaws.serverless.proxy.model.AwsProxyResponse; +import org.springframework.web.context.ConfigurableWebApplicationContext; +import org.springframework.web.context.support.AnnotationConfigWebApplicationContext; + +import jakarta.servlet.http.HttpServletRequest; + +public class SpringProxyHandlerBuilder extends ServletLambdaContainerHandlerBuilder< + RequestType, + AwsProxyResponse, + HttpServletRequest, + SpringLambdaContainerHandler, + SpringProxyHandlerBuilder> { + private ConfigurableWebApplicationContext springContext; + private Class[] configurationClasses; + private String[] profiles; + + @Override + protected SpringProxyHandlerBuilder self() { + return this; + } + + + public SpringProxyHandlerBuilder springApplicationContext(ConfigurableWebApplicationContext app) { + springContext = app; + return self(); + } + + public SpringProxyHandlerBuilder configurationClasses(Class... config) { + configurationClasses = config; + return self(); + } + + public SpringProxyHandlerBuilder profiles(String... profiles) { + this.profiles = profiles; + return self(); + } + + @Override + public SpringLambdaContainerHandler build() throws ContainerInitializationException { + validate(); + if (springContext == null && (configurationClasses == null || configurationClasses.length == 0)) { + throw new ContainerInitializationException("Missing both configuration classes and application context, at least" + + " one of the two must be populated", null); + } + ConfigurableWebApplicationContext ctx = springContext; + if (ctx == null) { + ctx = new AnnotationConfigWebApplicationContext(); + if (configurationClasses != null) { + ((AnnotationConfigWebApplicationContext)ctx).register(configurationClasses); + } + } + + SpringLambdaContainerHandler handler = createHandler(ctx); + if (profiles != null) { + handler.activateSpringProfiles(profiles); + } + return handler; + } + + protected SpringLambdaContainerHandler createHandler(ConfigurableWebApplicationContext ctx) { + return new SpringLambdaContainerHandler<>( + requestTypeClass, responseTypeClass, requestReader, responseWriter, + securityContextWriter, exceptionHandler, ctx, initializationWrapper + ); + } + + @Override + public SpringLambdaContainerHandler buildAndInitialize() throws ContainerInitializationException { + SpringLambdaContainerHandler handler = build(); + initializationWrapper.start(handler); + return handler; + } + + @Override + protected ExceptionHandler defaultExceptionHandler() { + return new SpringAwsProxyExceptionHandler(); + } +} diff --git a/aws-serverless-java-container-spring/src/test/java/com/amazonaws/serverless/proxy/spring/AsyncAppTest.java b/aws-serverless-java-container-spring/src/test/java/com/amazonaws/serverless/proxy/spring/AsyncAppTest.java new file mode 100644 index 00000000..8265e1bf --- /dev/null +++ b/aws-serverless-java-container-spring/src/test/java/com/amazonaws/serverless/proxy/spring/AsyncAppTest.java @@ -0,0 +1,46 @@ +package com.amazonaws.serverless.proxy.spring; + +import com.amazonaws.serverless.exceptions.ContainerInitializationException; +import com.amazonaws.serverless.proxy.internal.testutils.AwsProxyRequestBuilder; +import com.amazonaws.serverless.proxy.internal.testutils.MockLambdaContext; +import com.amazonaws.serverless.proxy.model.AwsProxyRequest; +import com.amazonaws.serverless.proxy.model.AwsProxyResponse; +import com.amazonaws.serverless.proxy.spring.springapp.LambdaHandler; +import com.amazonaws.serverless.proxy.spring.springapp.MessageController; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.fail; + +public class AsyncAppTest { + + private static LambdaHandler handler; + + @BeforeAll + public static void setUp() { + try { + handler = new LambdaHandler(); + } catch (ContainerInitializationException e) { + e.printStackTrace(); + fail(); + } + } + + @Test + void springApp_helloRequest_returnsCorrect() { + AwsProxyRequest req = new AwsProxyRequestBuilder("/hello", "GET").build(); + AwsProxyResponse resp = handler.handleRequest(req, new MockLambdaContext()); + assertEquals(200, resp.getStatusCode()); + assertEquals(MessageController.HELLO_MESSAGE, resp.getBody()); + } + + @Test + void springApp_asyncRequest_returnsCorrect() { + AwsProxyRequest req = new AwsProxyRequestBuilder("/async", "GET").build(); + AwsProxyResponse resp = handler.handleRequest(req, new MockLambdaContext()); + assertEquals(200, resp.getStatusCode()); + assertEquals(MessageController.HELLO_MESSAGE, resp.getBody()); + } + +} diff --git a/aws-serverless-java-container-spring/src/test/java/com/amazonaws/serverless/proxy/spring/SlowAppTest.java b/aws-serverless-java-container-spring/src/test/java/com/amazonaws/serverless/proxy/spring/SlowAppTest.java new file mode 100644 index 00000000..a33d5374 --- /dev/null +++ b/aws-serverless-java-container-spring/src/test/java/com/amazonaws/serverless/proxy/spring/SlowAppTest.java @@ -0,0 +1,40 @@ +package com.amazonaws.serverless.proxy.spring; + +import com.amazonaws.serverless.exceptions.ContainerInitializationException; +import com.amazonaws.serverless.proxy.internal.testutils.AwsProxyRequestBuilder; +import com.amazonaws.serverless.proxy.internal.testutils.MockLambdaContext; +import com.amazonaws.serverless.proxy.model.AwsProxyRequest; +import com.amazonaws.serverless.proxy.model.AwsProxyResponse; +import com.amazonaws.serverless.proxy.spring.springslowapp.LambdaHandler; +import com.amazonaws.serverless.proxy.spring.springslowapp.MessageController; +import com.amazonaws.serverless.proxy.spring.springslowapp.SlowAppConfig; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.util.Objects; + +import static org.junit.jupiter.api.Assertions.*; + +public class SlowAppTest { + + @Test + void springSlowApp_continuesInBackgroundThread_returnsCorrect() { + LambdaHandler slowApp = null; + try { + slowApp = new LambdaHandler(); + } catch (ContainerInitializationException e) { + e.printStackTrace(); + fail("Exception during initialization"); + } + System.out.println("Start time: " + slowApp.getConstructorTime()); + assertTrue(slowApp.getConstructorTime() < 10_000); + AwsProxyRequest req = new AwsProxyRequestBuilder("/hello", "GET").build(); + long startRequestTime = Instant.now().toEpochMilli(); + AwsProxyResponse resp = slowApp.handleRequest(req, new MockLambdaContext()); + long endRequestTime = Instant.now().toEpochMilli(); + assertTrue(endRequestTime - startRequestTime > SlowAppConfig.SlowDownInit.INIT_SLEEP_TIME_MS - 10_000); + assertEquals(200, resp.getStatusCode()); + assertEquals(MessageController.HELLO_MESSAGE, resp.getBody()); + } + +} diff --git a/aws-serverless-java-container-spring/src/test/java/com/amazonaws/serverless/proxy/spring/SpringAwsProxyExceptionHandlerTest.java b/aws-serverless-java-container-spring/src/test/java/com/amazonaws/serverless/proxy/spring/SpringAwsProxyExceptionHandlerTest.java new file mode 100644 index 00000000..5f4a1415 --- /dev/null +++ b/aws-serverless-java-container-spring/src/test/java/com/amazonaws/serverless/proxy/spring/SpringAwsProxyExceptionHandlerTest.java @@ -0,0 +1,21 @@ +package com.amazonaws.serverless.proxy.spring; + +import com.amazonaws.serverless.proxy.model.AwsProxyResponse; +import jakarta.ws.rs.core.Response; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.web.servlet.NoHandlerFoundException; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class SpringAwsProxyExceptionHandlerTest { + + @Test + void noHandlerFoundExceptionResultsIn404() { + AwsProxyResponse response = new SpringAwsProxyExceptionHandler(). + handle(new NoHandlerFoundException(HttpMethod.GET.name(), "https://atesturl", + HttpHeaders.EMPTY)); + assertEquals(Response.Status.NOT_FOUND.getStatusCode(), response.getStatusCode()); + } +} diff --git a/aws-serverless-java-container-spring/src/test/java/com/amazonaws/serverless/proxy/spring/SpringAwsProxyTest.java b/aws-serverless-java-container-spring/src/test/java/com/amazonaws/serverless/proxy/spring/SpringAwsProxyTest.java index a60ce9cd..4b7d3eb8 100644 --- a/aws-serverless-java-container-spring/src/test/java/com/amazonaws/serverless/proxy/spring/SpringAwsProxyTest.java +++ b/aws-serverless-java-container-spring/src/test/java/com/amazonaws/serverless/proxy/spring/SpringAwsProxyTest.java @@ -1,224 +1,482 @@ package com.amazonaws.serverless.proxy.spring; -import com.amazonaws.serverless.proxy.internal.model.AwsProxyRequest; -import com.amazonaws.serverless.proxy.internal.model.AwsProxyResponse; +import com.amazonaws.serverless.exceptions.ContainerInitializationException; +import com.amazonaws.serverless.proxy.internal.LambdaContainerHandler; +import com.amazonaws.serverless.proxy.internal.servlet.AwsLambdaServletContainerHandler; +import com.amazonaws.serverless.proxy.internal.servlet.AwsServletRegistration; +import com.amazonaws.serverless.proxy.model.*; import com.amazonaws.serverless.proxy.internal.servlet.AwsServletContext; import com.amazonaws.serverless.proxy.internal.testutils.AwsProxyRequestBuilder; import com.amazonaws.serverless.proxy.internal.testutils.MockLambdaContext; +import com.amazonaws.serverless.proxy.spring.echoapp.EchoResource; import com.amazonaws.serverless.proxy.spring.echoapp.EchoSpringAppConfig; +import com.amazonaws.serverless.proxy.spring.echoapp.RestControllerAdvice; +import com.amazonaws.serverless.proxy.spring.echoapp.UnauthenticatedFilter; import com.amazonaws.serverless.proxy.spring.echoapp.model.MapResponseModel; import com.amazonaws.serverless.proxy.spring.echoapp.model.SingleValueModel; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; +import com.amazonaws.services.lambda.runtime.Context; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.ObjectMapper; import org.apache.commons.codec.binary.Base64; -import org.junit.Before; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.test.annotation.DirtiesContext; -import org.springframework.test.context.ContextConfiguration; -import org.springframework.test.context.TestExecutionListeners; -import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; -import org.springframework.test.context.support.DependencyInjectionTestExecutionListener; -import org.springframework.test.context.web.WebAppConfiguration; - -import javax.ws.rs.core.HttpHeaders; -import javax.ws.rs.core.MediaType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.web.servlet.DispatcherServlet; + +import jakarta.servlet.DispatcherType; +import jakarta.servlet.FilterRegistration; +import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.core.MediaType; import java.io.IOException; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.time.temporal.ChronoUnit; +import java.util.Arrays; +import java.util.Collection; +import java.util.EnumSet; import java.util.UUID; -import static org.junit.Assert.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assumptions.assumeFalse; +import static org.junit.jupiter.api.Assumptions.assumeTrue; -@RunWith(SpringJUnit4ClassRunner.class) -@ContextConfiguration(classes = {EchoSpringAppConfig.class}) -@WebAppConfiguration -@TestExecutionListeners(inheritListeners = false, listeners = {DependencyInjectionTestExecutionListener.class}) -@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) public class SpringAwsProxyTest { private static final String CUSTOM_HEADER_KEY = "x-custom-header"; private static final String CUSTOM_HEADER_VALUE = "my-custom-value"; private static final String AUTHORIZER_PRINCIPAL_ID = "test-principal-" + UUID.randomUUID().toString(); + private static final String UNICODE_VALUE = "שלום לכולם"; - @Autowired - private ObjectMapper objectMapper; + private ObjectMapper objectMapper = new ObjectMapper(); + private MockLambdaContext lambdaContext = new MockLambdaContext(); + private static SpringLambdaContainerHandler handler; + private static SpringLambdaContainerHandler httpApiHandler; - @Autowired - private MockLambdaContext lambdaContext; + private AwsLambdaServletContainerHandler.StartupHandler h = (c -> { + FilterRegistration.Dynamic registration = c.addFilter("UnauthenticatedFilter", UnauthenticatedFilter.class); + // update the registration to map to a path + registration.addMappingForUrlPatterns(EnumSet.of(DispatcherType.REQUEST), true, "/echo/*"); + // servlet name mappings are disabled and will throw an exception + }); - @Autowired - private SpringLambdaContainerHandler handler; + private String type; - @Before + public static Collection data() { + return Arrays.asList(new Object[]{"API_GW", "ALB", "HTTP_API"}); + } + + public void initSpringAwsProxyTest(String reqType) { + type = reqType; + } + + private AwsProxyResponse executeRequest(AwsProxyRequestBuilder requestBuilder, Context lambdaContext) { + try { + switch (type) { + case "API_GW": + if (handler == null) { + handler = SpringLambdaContainerHandler.getAwsProxyHandler(EchoSpringAppConfig.class); + handler.onStartup(h); + } + return handler.proxy(requestBuilder.build(), lambdaContext); + case "ALB": + if (handler == null) { + handler = SpringLambdaContainerHandler.getAwsProxyHandler(EchoSpringAppConfig.class); + handler.onStartup(h); + } + return handler.proxy(requestBuilder.alb().build(), lambdaContext); + case "HTTP_API": + if (httpApiHandler == null) { + httpApiHandler = SpringLambdaContainerHandler.getHttpApiV2ProxyHandler(EchoSpringAppConfig.class); + httpApiHandler.onStartup(h); + } + return httpApiHandler.proxy(requestBuilder.toHttpApiV2Request(), lambdaContext); + default: + throw new RuntimeException("Unknown request type: " + type); + } + } catch (ContainerInitializationException e) { + e.printStackTrace(); + fail("Could not execute request"); + throw new RuntimeException(e); + } + } + + @BeforeEach public void clearServletContextCache() { AwsServletContext.clearServletContextCache(); } - @Test - public void headers_getHeaders_echo() { - AwsProxyRequest request = new AwsProxyRequestBuilder("/echo/headers", "GET") + @MethodSource("data") + @ParameterizedTest + void controllerAdvice_invalidPath_returnAdvice(String reqType) { + initSpringAwsProxyTest(reqType); + AwsProxyRequestBuilder request = new AwsProxyRequestBuilder("/echo2", "GET") + .json() + .header(CUSTOM_HEADER_KEY, CUSTOM_HEADER_VALUE); + + AwsProxyResponse output = executeRequest(request, lambdaContext); + assertNotNull(output); + assertEquals(404, output.getStatusCode()); + validateSingleValueModel(output, RestControllerAdvice.ERROR_MESSAGE); + + } + + @MethodSource("data") + @ParameterizedTest + void headers_getHeaders_echo(String reqType) { + initSpringAwsProxyTest(reqType); + AwsProxyRequestBuilder request = new AwsProxyRequestBuilder("/echo/headers", "GET") .json() - .header(CUSTOM_HEADER_KEY, CUSTOM_HEADER_VALUE) - .build(); + .header(CUSTOM_HEADER_KEY, CUSTOM_HEADER_VALUE); - AwsProxyResponse output = handler.proxy(request, lambdaContext); + AwsProxyResponse output = executeRequest(request, lambdaContext); assertEquals(200, output.getStatusCode()); - assertEquals("application/json", output.getHeaders().get("Content-Type").split(";")[0]); + assertEquals("application/json", output.getMultiValueHeaders().getFirst("Content-Type").split(";")[0]); validateMapResponseModel(output); } - @Test - public void headers_servletRequest_echo() { - AwsProxyRequest request = new AwsProxyRequestBuilder("/echo/servlet-headers", "GET") + @MethodSource("data") + @ParameterizedTest + void headers_servletRequest_echo(String reqType) { + initSpringAwsProxyTest(reqType); + AwsProxyRequestBuilder request = new AwsProxyRequestBuilder("/echo/servlet-headers", "GET") .json() - .header(CUSTOM_HEADER_KEY, CUSTOM_HEADER_VALUE) - .build(); + .header(CUSTOM_HEADER_KEY, CUSTOM_HEADER_VALUE); - AwsProxyResponse output = handler.proxy(request, lambdaContext); + AwsProxyResponse output = executeRequest(request, lambdaContext); assertEquals(200, output.getStatusCode()); - assertEquals("application/json", output.getHeaders().get("Content-Type").split(";")[0]); + assertEquals("application/json", output.getMultiValueHeaders().getFirst("Content-Type").split(";")[0]); validateMapResponseModel(output); } - @Test - public void queryString_uriInfo_echo() { - AwsProxyRequest request = new AwsProxyRequestBuilder("/echo/query-string", "GET") + @MethodSource("data") + @ParameterizedTest + void queryString_uriInfo_echo(String reqType) { + initSpringAwsProxyTest(reqType); + AwsProxyRequestBuilder request = new AwsProxyRequestBuilder("/echo/query-string", "GET") .json() - .queryString(CUSTOM_HEADER_KEY, CUSTOM_HEADER_VALUE) - .build(); + .queryString(CUSTOM_HEADER_KEY, CUSTOM_HEADER_VALUE); - AwsProxyResponse output = handler.proxy(request, lambdaContext); + AwsProxyResponse output = executeRequest(request, lambdaContext); assertEquals(200, output.getStatusCode()); - assertEquals("application/json", output.getHeaders().get("Content-Type").split(";")[0]); + assertEquals("application/json", output.getMultiValueHeaders().getFirst("Content-Type").split(";")[0]); validateMapResponseModel(output); } - @Test - public void authorizer_securityContext_customPrincipalSuccess() { - AwsProxyRequest request = new AwsProxyRequestBuilder("/echo/authorizer-principal", "GET") + @MethodSource("data") + @ParameterizedTest + void queryString_listParameter_expectCorrectLength(String reqType) { + initSpringAwsProxyTest(reqType); + AwsProxyRequestBuilder request = new AwsProxyRequestBuilder("/echo/list-query-string", "GET") + .json() + .queryString("list", "v1,v2,v3"); + + AwsProxyResponse output = executeRequest(request, lambdaContext); + assertEquals(200, output.getStatusCode()); + + validateSingleValueModel(output, "3"); + } + + @MethodSource("data") + @ParameterizedTest + void queryString_multiParam_expectCorrectValueCount(String reqType) + throws IOException { + initSpringAwsProxyTest(reqType); + AwsProxyRequestBuilder request = new AwsProxyRequestBuilder("/echo/multivalue-query-string", "GET") + .json() + .queryString("multiple", "first") + .queryString("multiple", "second"); + + AwsProxyResponse output = executeRequest(request, lambdaContext); + assertEquals(200, output.getStatusCode()); + MapResponseModel response = objectMapper.readValue(output.getBody(), MapResponseModel.class); + + assertEquals(2, response.getValues().size()); + assertTrue(response.getValues().containsKey("first")); + assertTrue(response.getValues().containsKey("second")); + } + + @MethodSource("data") + @ParameterizedTest + void dateHeader_notModified_expect304(String reqType) { + initSpringAwsProxyTest(reqType); + AwsProxyRequestBuilder request = new AwsProxyRequestBuilder("/echo/last-modified", "GET") + .json() + .header( + HttpHeaders.IF_MODIFIED_SINCE, + DateTimeFormatter.RFC_1123_DATE_TIME.format(ZonedDateTime.now().minus(1, ChronoUnit.SECONDS)) + ); + + AwsProxyResponse output = executeRequest(request, lambdaContext); + assertEquals(304, output.getStatusCode()); + assertEquals("", output.getBody()); + } + + @MethodSource("data") + @ParameterizedTest + void dateHeader_notModified_expect200(String reqType) { + initSpringAwsProxyTest(reqType); + AwsProxyRequestBuilder request = new AwsProxyRequestBuilder("/echo/last-modified", "GET") .json() - .authorizerPrincipal(AUTHORIZER_PRINCIPAL_ID) - .build(); + .header( + HttpHeaders.IF_MODIFIED_SINCE, + DateTimeFormatter.RFC_1123_DATE_TIME.format(ZonedDateTime.now().minus(5, ChronoUnit.DAYS)) + ); - AwsProxyResponse output = handler.proxy(request, lambdaContext); + AwsProxyResponse output = executeRequest(request, lambdaContext); assertEquals(200, output.getStatusCode()); - assertEquals("application/json", output.getHeaders().get("Content-Type").split(";")[0]); + assertEquals(EchoResource.STRING_BODY, output.getBody()); + } + + @MethodSource("data") + @ParameterizedTest + void authorizer_securityContext_customPrincipalSuccess(String reqType) { + initSpringAwsProxyTest(reqType); + assumeTrue("API_GW".equals(type)); + AwsProxyRequestBuilder request = new AwsProxyRequestBuilder("/echo/authorizer-principal", "GET") + .json() + .authorizerPrincipal(AUTHORIZER_PRINCIPAL_ID); + + AwsProxyResponse output = executeRequest(request, lambdaContext); + assertEquals(200, output.getStatusCode()); + assertEquals("application/json", output.getMultiValueHeaders().getFirst("Content-Type").split(";")[0]); validateSingleValueModel(output, AUTHORIZER_PRINCIPAL_ID); } - @Test - public void errors_unknownRoute_expect404() { - AwsProxyRequest request = new AwsProxyRequestBuilder("/echo/test33", "GET").build(); + @MethodSource("data") + @ParameterizedTest + void errors_unknownRoute_expect404(String reqType) { + initSpringAwsProxyTest(reqType); + AwsProxyRequestBuilder request = new AwsProxyRequestBuilder("/echo/test33", "GET"); - AwsProxyResponse output = handler.proxy(request, lambdaContext); + AwsProxyResponse output = executeRequest(request, lambdaContext); assertEquals(404, output.getStatusCode()); } - @Test - public void error_contentType_invalidContentType() { - AwsProxyRequest request = new AwsProxyRequestBuilder("/echo/json-body", "POST") + @MethodSource("data") + @ParameterizedTest + void error_contentType_invalidContentType(String reqType) { + initSpringAwsProxyTest(reqType); + AwsProxyRequestBuilder request = new AwsProxyRequestBuilder("/echo/json-body", "POST") .header("Content-Type", "application/octet-stream") - .body("asdasdasd") - .build(); + .body("asdasdasd"); - AwsProxyResponse output = handler.proxy(request, lambdaContext); + AwsProxyResponse output = executeRequest(request, lambdaContext); assertEquals(415, output.getStatusCode()); } - @Test - public void error_statusCode_methodNotAllowed() { - AwsProxyRequest request = new AwsProxyRequestBuilder("/echo/status-code", "POST") + @MethodSource("data") + @ParameterizedTest + void error_statusCode_methodNotAllowed(String reqType) { + initSpringAwsProxyTest(reqType); + AwsProxyRequestBuilder request = new AwsProxyRequestBuilder("/echo/status-code", "POST") .json() - .queryString("status", "201") - .build(); + .queryString("status", "201"); - AwsProxyResponse output = handler.proxy(request, lambdaContext); + AwsProxyResponse output = executeRequest(request, lambdaContext); assertEquals(405, output.getStatusCode()); } - @Test - public void responseBody_responseWriter_validBody() throws JsonProcessingException { + @MethodSource("data") + @ParameterizedTest + void error_unauthenticatedCall_filterStepsRequest(String reqType) { + initSpringAwsProxyTest(reqType); + AwsProxyRequestBuilder request = new AwsProxyRequestBuilder("/echo/status-code", "GET") + .header(UnauthenticatedFilter.HEADER_NAME, "1") + .json() + .queryString("status", "201"); + + AwsProxyResponse output = executeRequest(request, lambdaContext); + assertEquals(401, output.getStatusCode()); + } + + @MethodSource("data") + @ParameterizedTest + void responseBody_responseWriter_validBody(String reqType) throws JacksonException { + initSpringAwsProxyTest(reqType); SingleValueModel singleValueModel = new SingleValueModel(); singleValueModel.setValue(CUSTOM_HEADER_VALUE); - AwsProxyRequest request = new AwsProxyRequestBuilder("/echo/json-body", "POST") + AwsProxyRequestBuilder request = new AwsProxyRequestBuilder("/echo/json-body", "POST") .json() .header("Content-Type", "application/json") - .body(objectMapper.writeValueAsString(singleValueModel)) - .build(); + .body(objectMapper.writeValueAsString(singleValueModel)); - AwsProxyResponse output = handler.proxy(request, lambdaContext); + AwsProxyResponse output = executeRequest(request, lambdaContext); assertEquals(200, output.getStatusCode()); assertNotNull(output.getBody()); - System.out.println("Output:" + output.getBody()); validateSingleValueModel(output, CUSTOM_HEADER_VALUE); } - @Test - public void statusCode_responseStatusCode_customStatusCode() { - AwsProxyRequest request = new AwsProxyRequestBuilder("/echo/status-code", "GET") + @MethodSource("data") + @ParameterizedTest + void responseBody_responseWriter_validBody_UTF(String reqType) throws JacksonException { + initSpringAwsProxyTest(reqType); + SingleValueModel singleValueModel = new SingleValueModel(); + singleValueModel.setValue(UNICODE_VALUE); + AwsProxyRequestBuilder request = new AwsProxyRequestBuilder("/echo/json-body", "POST") + .header("Content-Type", "application/json; charset=UTF-8") + .body(objectMapper.writeValueAsString(singleValueModel)); + LambdaContainerHandler.getContainerConfig().setDefaultContentCharset("UTF-8"); + AwsProxyResponse output = executeRequest(request, lambdaContext); + assertEquals(200, output.getStatusCode()); + assertNotNull(output.getBody()); + validateSingleValueModel(output, UNICODE_VALUE); + LambdaContainerHandler.getContainerConfig().setDefaultContentCharset(ContainerConfig.DEFAULT_CONTENT_CHARSET); + } + + @MethodSource("data") + @ParameterizedTest + void statusCode_responseStatusCode_customStatusCode(String reqType) { + initSpringAwsProxyTest(reqType); + AwsProxyRequestBuilder request = new AwsProxyRequestBuilder("/echo/status-code", "GET") .json() - .queryString("status", "201") - .build(); + .queryString("status", "201"); - AwsProxyResponse output = handler.proxy(request, lambdaContext); + AwsProxyResponse output = executeRequest(request, lambdaContext); assertEquals(201, output.getStatusCode()); } - @Test - public void base64_binaryResponse_base64Encoding() { - AwsProxyRequest request = new AwsProxyRequestBuilder("/echo/binary", "GET").build(); + @MethodSource("data") + @ParameterizedTest + void base64_binaryResponse_base64Encoding(String reqType) { + initSpringAwsProxyTest(reqType); + AwsProxyRequestBuilder request = new AwsProxyRequestBuilder("/echo/binary", "GET"); - AwsProxyResponse response = handler.proxy(request, lambdaContext); + AwsProxyResponse response = executeRequest(request, lambdaContext); assertNotNull(response.getBody()); assertTrue(Base64.isBase64(response.getBody())); } - @Test - public void servletRequestEncoding_acceptEncoding_okStatusCode() { + @MethodSource("data") + @ParameterizedTest + void injectBody_populatedResponse_noException(String reqType) { + initSpringAwsProxyTest(reqType); + AwsProxyRequestBuilder request = new AwsProxyRequestBuilder("/echo/request-body", "POST") + .header(HttpHeaders.CONTENT_TYPE, "text/plain") + .body("This is a populated body"); + + AwsProxyResponse response = executeRequest(request, lambdaContext); + assertNotNull(response.getBody()); + assertEquals(200, response.getStatusCode()); + try { + SingleValueModel output = objectMapper.readValue(response.getBody(), SingleValueModel.class); + assertEquals("true", output.getValue()); + } catch (JacksonException e) { + e.printStackTrace(); + fail(); + } + + AwsProxyRequestBuilder emptyReq = new AwsProxyRequestBuilder("/echo/request-body", "POST"); + AwsProxyResponse emptyResp = executeRequest(emptyReq, lambdaContext); + try { + SingleValueModel output = objectMapper.readValue(emptyResp.getBody(), SingleValueModel.class); + assertNull(output.getValue()); + } catch (JacksonException e) { + e.printStackTrace(); + fail(); + } + } + + @MethodSource("data") + @ParameterizedTest + void servletRequestEncoding_acceptEncoding_okStatusCode(String reqType) { + initSpringAwsProxyTest(reqType); SingleValueModel singleValueModel = new SingleValueModel(); singleValueModel.setValue(CUSTOM_HEADER_VALUE); - AwsProxyRequest request = null; + AwsProxyRequestBuilder request = null; try { request = new AwsProxyRequestBuilder("/echo/json-body", "POST") .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON) .header(HttpHeaders.ACCEPT_ENCODING, "gzip, deflate") .queryString("status", "200") - .body(objectMapper.writeValueAsString(singleValueModel)) - .build(); - } catch (JsonProcessingException e) { + .body(objectMapper.writeValueAsString(singleValueModel)); + } catch (JacksonException e) { fail("Could not serialize object to JSON"); } - AwsProxyResponse output = handler.proxy(request, lambdaContext); + AwsProxyResponse output = executeRequest(request, lambdaContext); assertEquals(200, output.getStatusCode()); } - @Test - public void request_requestURI() { - AwsProxyRequest request = new AwsProxyRequestBuilder("/echo/request-URI", "GET") - .build(); + @MethodSource("data") + @ParameterizedTest + void request_requestURI(String reqType) { + initSpringAwsProxyTest(reqType); + AwsProxyRequestBuilder request = new AwsProxyRequestBuilder("/echo/request-URI", "GET"); - AwsProxyResponse output = handler.proxy(request, lambdaContext); + AwsProxyResponse output = executeRequest(request, lambdaContext); assertEquals(200, output.getStatusCode()); validateSingleValueModel(output, "/echo/request-URI"); } - @Test - public void request_requestURL() { - AwsProxyRequest request = new AwsProxyRequestBuilder("/echo/request-Url", "GET") + @MethodSource("data") + @ParameterizedTest + void request_requestURL(String reqType) { + initSpringAwsProxyTest(reqType); + AwsProxyRequestBuilder request = new AwsProxyRequestBuilder("/echo/request-url", "GET") + .scheme("https") + .serverName("api.myserver.com") + .stage("prod"); + handler.stripBasePath(""); + AwsProxyResponse output = executeRequest(request, lambdaContext); + assertEquals(200, output.getStatusCode()); + + validateSingleValueModel(output, "https://api.myserver.com/echo/request-url"); + } + + @MethodSource("data") + @ParameterizedTest + void request_encodedPath_returnsDecodedPath(String reqType) { + initSpringAwsProxyTest(reqType); + AwsProxyRequestBuilder request = new AwsProxyRequestBuilder("/echo/encoded-request-uri/Some%20Thing", "GET") + .scheme("https") + .serverName("api.myserver.com") + .stage("prod"); + + AwsProxyResponse output = executeRequest(request, lambdaContext); + assertEquals(200, output.getStatusCode()); + + validateSingleValueModel(output, "Some Thing"); + + } + + @MethodSource("data") + @ParameterizedTest + void contextPath_generateLink_returnsCorrectPath(String reqType) { + initSpringAwsProxyTest(reqType); + assumeFalse("ALB".equals(type)); + AwsProxyRequestBuilder request = new AwsProxyRequestBuilder("/echo/generate-uri", "GET") .scheme("https") .serverName("api.myserver.com") - .stage("prod") - .build(); + .stage("prod"); + LambdaContainerHandler.getContainerConfig().addCustomDomain("api.myserver.com"); + SpringLambdaContainerHandler.getContainerConfig().setUseStageAsServletContext(true); + + AwsProxyResponse output = executeRequest(request, lambdaContext); + assertEquals(200, output.getStatusCode()); + + String expectedUri = "https://api.myserver.com/prod/echo/encoded-request-uri/" + EchoResource.TEST_GENERATE_URI; + + validateSingleValueModel(output, expectedUri); + + SpringLambdaContainerHandler.getContainerConfig().setUseStageAsServletContext(false); + } - AwsProxyResponse output = handler.proxy(request, lambdaContext); + @MethodSource("data") + @ParameterizedTest + void multipart_getFileName_returnsCorrectFileName(String reqType) + throws IOException { + initSpringAwsProxyTest(reqType); + AwsProxyRequestBuilder request = new AwsProxyRequestBuilder("/echo/attachment", "POST") + .formFilePart("testFile", "myFile.txt", "hello".getBytes()); + + AwsProxyResponse output = executeRequest(request, lambdaContext); assertEquals(200, output.getStatusCode()); - validateSingleValueModel(output, "https://api.myserver.com/prod/echo/request-Url"); + assertEquals("testFile", output.getBody()); } private void validateMapResponseModel(AwsProxyResponse output) { @@ -226,9 +484,9 @@ private void validateMapResponseModel(AwsProxyResponse output) { MapResponseModel response = objectMapper.readValue(output.getBody(), MapResponseModel.class); assertNotNull(response.getValues().get(CUSTOM_HEADER_KEY)); assertEquals(CUSTOM_HEADER_VALUE, response.getValues().get(CUSTOM_HEADER_KEY)); - } catch (IOException e) { - fail("Exception while parsing response body: " + e.getMessage()); + } catch (JacksonException e) { e.printStackTrace(); + fail("Exception while parsing response body: " + e.getMessage()); } } @@ -237,9 +495,9 @@ private void validateSingleValueModel(AwsProxyResponse output, String value) { SingleValueModel response = objectMapper.readValue(output.getBody(), SingleValueModel.class); assertNotNull(response.getValue()); assertEquals(value, response.getValue()); - } catch (IOException e) { - fail("Exception while parsing response body: " + e.getMessage()); + } catch (JacksonException e) { e.printStackTrace(); + fail("Exception while parsing response body: " + e.getMessage()); } } } diff --git a/aws-serverless-java-container-spring/src/test/java/com/amazonaws/serverless/proxy/spring/SpringServletContextTest.java b/aws-serverless-java-container-spring/src/test/java/com/amazonaws/serverless/proxy/spring/SpringServletContextTest.java index 942e2062..11b6c7c5 100644 --- a/aws-serverless-java-container-spring/src/test/java/com/amazonaws/serverless/proxy/spring/SpringServletContextTest.java +++ b/aws-serverless-java-container-spring/src/test/java/com/amazonaws/serverless/proxy/spring/SpringServletContextTest.java @@ -2,23 +2,27 @@ import com.amazonaws.serverless.exceptions.ContainerInitializationException; import com.amazonaws.serverless.proxy.internal.LambdaContainerHandler; -import com.amazonaws.serverless.proxy.internal.model.AwsProxyRequest; -import com.amazonaws.serverless.proxy.internal.model.AwsProxyResponse; +import com.amazonaws.serverless.proxy.model.AwsProxyRequest; +import com.amazonaws.serverless.proxy.model.AwsProxyResponse; import com.amazonaws.serverless.proxy.internal.servlet.AwsServletContext; import com.amazonaws.serverless.proxy.internal.testutils.AwsProxyRequestBuilder; import com.amazonaws.serverless.proxy.internal.testutils.MockLambdaContext; +import com.amazonaws.serverless.proxy.spring.echoapp.ContextResource; import com.amazonaws.serverless.proxy.spring.echoapp.CustomHeaderFilter; import com.amazonaws.serverless.proxy.spring.echoapp.EchoSpringAppConfig; import com.amazonaws.serverless.proxy.spring.echoapp.model.ValidatedUserModel; -import org.junit.BeforeClass; -import org.junit.Test; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; + +import jakarta.servlet.DispatcherType; +import jakarta.servlet.FilterRegistration; -import javax.servlet.DispatcherType; -import javax.servlet.FilterRegistration; import java.util.EnumSet; -import static org.junit.Assert.*; +import static org.junit.jupiter.api.Assertions.*; // we don't use the spring annotations to pretend we are running in the actual container public class SpringServletContextTest { @@ -27,7 +31,7 @@ public class SpringServletContextTest { private static SpringLambdaContainerHandler handler; - @BeforeClass + @BeforeAll public static void setUp() { try { handler = SpringLambdaContainerHandler.getAwsProxyHandler(EchoSpringAppConfig.class); @@ -45,7 +49,7 @@ public static void setUp() { } @Test - public void context_autowireValidContext_echoContext() { + void context_autowireValidContext_echoContext() { AwsProxyRequest request = new AwsProxyRequestBuilder("/echo/servlet-context", "GET") .json() .stage(STAGE) @@ -53,12 +57,12 @@ public void context_autowireValidContext_echoContext() { AwsProxyResponse output = handler.proxy(request, lambdaContext); assertEquals(200, output.getStatusCode()); - assertEquals("text/plain", output.getHeaders().get("Content-Type").split(";")[0]); + assertEquals("text/plain", output.getMultiValueHeaders().getFirst("Content-Type").split(";")[0]); assertEquals(STAGE, output.getBody()); } @Test - public void context_contextAware_contextEcho() { + void context_contextAware_contextEcho() { AwsProxyRequest request = new AwsProxyRequestBuilder("/context/echo", "GET") .json() .stage(STAGE) @@ -66,26 +70,26 @@ public void context_contextAware_contextEcho() { AwsProxyResponse output = handler.proxy(request, lambdaContext); assertEquals(200, output.getStatusCode()); - assertEquals("text/plain", output.getHeaders().get("Content-Type").split(";")[0]); + assertEquals("text/plain", output.getMultiValueHeaders().getFirst("Content-Type").split(";")[0]); assertEquals(STAGE, output.getBody()); } @Test - public void filter_customHeaderFilter_echoHeaders() { + void filter_customHeaderFilter_echoHeaders() { AwsProxyRequest request = new AwsProxyRequestBuilder("/echo/headers", "GET") .json() .stage(STAGE) .build(); AwsProxyResponse output = handler.proxy(request, lambdaContext); - assertNotNull(output.getHeaders()); - assertTrue(output.getHeaders().size() > 0); - assertNotNull(output.getHeaders().get(CustomHeaderFilter.HEADER_NAME)); - assertEquals(CustomHeaderFilter.HEADER_VALUE, output.getHeaders().get(CustomHeaderFilter.HEADER_NAME)); + assertNotNull(output.getMultiValueHeaders()); + assertTrue(output.getMultiValueHeaders().size() > 0); + assertNotNull(output.getMultiValueHeaders().get(CustomHeaderFilter.HEADER_NAME)); + assertEquals(CustomHeaderFilter.HEADER_VALUE, output.getMultiValueHeaders().getFirst(CustomHeaderFilter.HEADER_NAME)); } @Test - public void filter_validationFilter_emptyName() { + void filter_validationFilter_emptyName() { ValidatedUserModel userModel = new ValidatedUserModel(); userModel.setFirstName("Test"); AwsProxyRequest request = new AwsProxyRequestBuilder("/context/user", "POST") @@ -96,5 +100,34 @@ public void filter_validationFilter_emptyName() { AwsProxyResponse output = handler.proxy(request, lambdaContext); assertEquals(HttpStatus.BAD_REQUEST.value(), output.getStatusCode()); } + + @Test + void exception_populatedException_annotationValuesMappedCorrectly() { + AwsProxyRequest request = new AwsProxyRequestBuilder("/context/exception", "GET") + .stage(STAGE) + .header(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE) + .build(); + + AwsProxyResponse output = handler.proxy(request, lambdaContext); + + assertEquals(409, output.getStatusCode()); + assertTrue(output.getBody().contains(ContextResource.EXCEPTION_REASON)); + } + + @Test + void cookie_injectInResponse_expectCustomSetCookie() { + AwsProxyRequest request = new AwsProxyRequestBuilder("/context/cookie", "GET") + .stage(STAGE) + .header(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE) + .build(); + + AwsProxyResponse output = handler.proxy(request, lambdaContext); + + + assertEquals(200, output.getStatusCode()); + assertTrue(output.getMultiValueHeaders().containsKey(HttpHeaders.SET_COOKIE)); + assertTrue(output.getMultiValueHeaders().getFirst(HttpHeaders.SET_COOKIE).contains(ContextResource.COOKIE_NAME + "=" + ContextResource.COOKIE_VALUE)); + assertTrue(output.getMultiValueHeaders().getFirst(HttpHeaders.SET_COOKIE).contains(ContextResource.COOKIE_DOMAIN)); + } } diff --git a/aws-serverless-java-container-spring/src/test/java/com/amazonaws/serverless/proxy/spring/StaticAppProxyTest.java b/aws-serverless-java-container-spring/src/test/java/com/amazonaws/serverless/proxy/spring/StaticAppProxyTest.java new file mode 100644 index 00000000..9e828119 --- /dev/null +++ b/aws-serverless-java-container-spring/src/test/java/com/amazonaws/serverless/proxy/spring/StaticAppProxyTest.java @@ -0,0 +1,36 @@ +package com.amazonaws.serverless.proxy.spring; + + +import com.amazonaws.serverless.proxy.internal.LambdaContainerHandler; +import com.amazonaws.serverless.proxy.model.AwsProxyRequest; +import com.amazonaws.serverless.proxy.model.AwsProxyResponse; +import com.amazonaws.serverless.proxy.internal.testutils.AwsProxyRequestBuilder; +import com.amazonaws.serverless.proxy.internal.testutils.MockLambdaContext; +import com.amazonaws.serverless.proxy.spring.staticapp.LambdaHandler; + +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; + +import jakarta.ws.rs.core.MediaType; + +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.assertEquals; + + +public class StaticAppProxyTest { + + private LambdaHandler lambdaHandler = new LambdaHandler(); + + @Test + void staticPage() { + AwsProxyRequest req = new AwsProxyRequestBuilder("/sample/page", "GET").build(); + // we temporarily allow the container to read from any path + LambdaContainerHandler.getContainerConfig().addValidFilePath("/"); + AwsProxyResponse resp = lambdaHandler.handleRequest(req, new MockLambdaContext()); + + assertEquals(200, resp.getStatusCode()); + assertTrue(resp.getBody().startsWith("")); + assertTrue(resp.getMultiValueHeaders().containsKey(HttpHeaders.CONTENT_TYPE)); + assertEquals(MediaType.TEXT_HTML, resp.getMultiValueHeaders().getFirst(HttpHeaders.CONTENT_TYPE)); + } +} diff --git a/aws-serverless-java-container-spring/src/test/java/com/amazonaws/serverless/proxy/spring/echoapp/ContextResource.java b/aws-serverless-java-container-spring/src/test/java/com/amazonaws/serverless/proxy/spring/echoapp/ContextResource.java index 02ec3cf0..8f7eaf2a 100644 --- a/aws-serverless-java-container-spring/src/test/java/com/amazonaws/serverless/proxy/spring/echoapp/ContextResource.java +++ b/aws-serverless-java-container-spring/src/test/java/com/amazonaws/serverless/proxy/spring/echoapp/ContextResource.java @@ -1,24 +1,41 @@ package com.amazonaws.serverless.proxy.spring.echoapp; +import com.amazonaws.serverless.proxy.spring.echoapp.model.SingleValueModel; import com.amazonaws.serverless.proxy.spring.echoapp.model.ValidatedUserModel; import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.validation.BindingResult; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.ResponseBody; +import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.context.ServletContextAware; import org.springframework.web.servlet.config.annotation.EnableWebMvc; -import javax.servlet.ServletContext; -import javax.validation.Valid; -import javax.ws.rs.core.MediaType; +import jakarta.servlet.ServletContext; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; + +import static org.springframework.core.annotation.AnnotatedElementUtils.findMergedAnnotation; + @RestController @EnableWebMvc @RequestMapping("/context") public class ContextResource implements ServletContextAware { + public static final String COOKIE_DOMAIN = "mydomain.com"; + public static final String COOKIE_NAME = "CustomCookie"; + public static final String COOKIE_VALUE = "CookieValue"; + public static final String EXCEPTION_REASON = "There was a conflict"; private ServletContext context; @RequestMapping(path = "/echo", method= RequestMethod.GET) @@ -26,18 +43,93 @@ public ResponseEntity getContext() { return new ResponseEntity(this.context.getServerInfo(), HttpStatus.OK); } - @RequestMapping(path = "/user", method=RequestMethod.POST, consumes = MediaType.APPLICATION_JSON) + @RequestMapping(path = "/user", method=RequestMethod.POST, consumes = MediaType.APPLICATION_JSON_VALUE) public ResponseEntity createUser(@Valid @RequestBody ValidatedUserModel newUser, BindingResult results) { if (results.hasErrors()) { - System.out.println("Has errors"); return new ResponseEntity(newUser, HttpStatus.BAD_REQUEST); } return new ResponseEntity(newUser, HttpStatus.OK); } + @RequestMapping(path = "/cookie", method=RequestMethod.GET) + public SingleValueModel setCookie(ServletRequest request, ServletResponse response) { + setCookie(request, response, COOKIE_NAME, COOKIE_VALUE, true, false, true, null, false); + return new SingleValueModel(); + } + + @RequestMapping(path = "/exception", method=RequestMethod.GET, produces = MediaType.APPLICATION_JSON_VALUE) + public SingleValueModel handleException() { + throw new SpringConflictException(); + } + + public static void setCookie(ServletRequest request, ServletResponse response, String name, String value, + boolean set, boolean global, boolean bSecureCookie, Integer maxAge, boolean httpOnly) { + Cookie ck = new Cookie(name, value); + + HttpServletRequest httpRequest = (HttpServletRequest) request; + + if (httpOnly) { + ck.setHttpOnly(true); + } + + if (set) { + if (maxAge != null) { + ck.setMaxAge(maxAge.intValue()); + } else { + ck.setMaxAge(-1); + } + } else { + ck.setMaxAge(0); + } + ck.setPath("/"); + + // for local and fngn envs., we should not set cookie as a secure cookie + if (bSecureCookie) { + ck.setSecure(true); + } + + ck.setDomain(COOKIE_DOMAIN); + + + ((HttpServletResponse) response).addCookie(ck); + } + @Override public void setServletContext(ServletContext servletContext) { context = servletContext; } + + @ControllerAdvice + class ExceptionHandlerAdvice { + @ExceptionHandler({ SpringConflictException.class }) + @ResponseBody + ResponseEntity handle(Exception exception) { + SingleValueModel body = new SingleValueModel(); + body.setValue(resolveAnnotatedExceptionReason(exception)); + HttpStatus responseStatus = resolveAnnotatedResponseStatus(exception); + return new ResponseEntity<>(body, responseStatus); + } + + HttpStatus resolveAnnotatedResponseStatus(Exception exception) { + ResponseStatus annotation = findMergedAnnotation(exception.getClass(), ResponseStatus.class); + if (annotation != null) { + return annotation.value(); + } + return HttpStatus.INTERNAL_SERVER_ERROR; + } + + String resolveAnnotatedExceptionReason(Exception exception) { + ResponseStatus annotation = findMergedAnnotation(exception.getClass(), ResponseStatus.class); + if (annotation != null && !"".equals(annotation.reason())) { + return annotation.reason(); + } + return exception.getLocalizedMessage(); + } + } + + @ResponseStatus(value=HttpStatus.CONFLICT, reason= EXCEPTION_REASON) + public class SpringConflictException extends RuntimeException { + + } } diff --git a/aws-serverless-java-container-spring/src/test/java/com/amazonaws/serverless/proxy/spring/echoapp/CustomHeaderFilter.java b/aws-serverless-java-container-spring/src/test/java/com/amazonaws/serverless/proxy/spring/echoapp/CustomHeaderFilter.java index 50256b1a..f6427db6 100644 --- a/aws-serverless-java-container-spring/src/test/java/com/amazonaws/serverless/proxy/spring/echoapp/CustomHeaderFilter.java +++ b/aws-serverless-java-container-spring/src/test/java/com/amazonaws/serverless/proxy/spring/echoapp/CustomHeaderFilter.java @@ -1,7 +1,14 @@ package com.amazonaws.serverless.proxy.spring.echoapp; -import javax.servlet.*; -import javax.servlet.http.HttpServletResponse; + +import jakarta.servlet.Filter; +import jakarta.servlet.FilterChain; +import jakarta.servlet.FilterConfig; +import jakarta.servlet.ServletException; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; +import jakarta.servlet.http.HttpServletResponse; + import java.io.IOException; @@ -11,12 +18,11 @@ public class CustomHeaderFilter implements Filter { @Override public void init(FilterConfig filterConfig) throws ServletException { - System.out.println("Called init on filter"); + } @Override public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException { - System.out.println("Called doFilter"); HttpServletResponse resp = (HttpServletResponse)servletResponse; resp.addHeader(HEADER_NAME, HEADER_VALUE); @@ -26,6 +32,6 @@ public void doFilter(ServletRequest servletRequest, ServletResponse servletRespo @Override public void destroy() { - System.out.println("Called destroy"); + } -} +} \ No newline at end of file diff --git a/aws-serverless-java-container-spring/src/test/java/com/amazonaws/serverless/proxy/spring/echoapp/EchoResource.java b/aws-serverless-java-container-spring/src/test/java/com/amazonaws/serverless/proxy/spring/echoapp/EchoResource.java index 486d67c2..a6dd033a 100644 --- a/aws-serverless-java-container-spring/src/test/java/com/amazonaws/serverless/proxy/spring/echoapp/EchoResource.java +++ b/aws-serverless-java-container-spring/src/test/java/com/amazonaws/serverless/proxy/spring/echoapp/EchoResource.java @@ -1,26 +1,52 @@ package com.amazonaws.serverless.proxy.spring.echoapp; -import com.amazonaws.serverless.proxy.internal.RequestReader; -import com.amazonaws.serverless.proxy.internal.model.ApiGatewayRequestContext; +import com.amazonaws.serverless.proxy.RequestReader; +import com.amazonaws.serverless.proxy.model.AwsProxyRequestContext; import com.amazonaws.serverless.proxy.spring.echoapp.model.MapResponseModel; import com.amazonaws.serverless.proxy.spring.echoapp.model.SingleValueModel; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.util.MultiValueMap; import org.springframework.web.bind.annotation.*; -import org.springframework.web.context.ServletContextAware; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.multipart.MultipartResolver; +import org.springframework.web.multipart.support.StandardServletMultipartResolver; import org.springframework.web.servlet.config.annotation.EnableWebMvc; -import javax.servlet.ServletContext; -import javax.servlet.http.HttpServletRequest; +import jakarta.servlet.ServletContext; +import jakarta.servlet.http.HttpServletRequest; + +import java.io.IOException; +import java.net.URI; +import java.time.Instant; +import java.time.temporal.ChronoUnit; import java.util.Enumeration; +import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.Random; +import static org.springframework.web.servlet.mvc.method.annotation.MvcUriComponentsBuilder.fromMethodCall; +import static org.springframework.web.servlet.mvc.method.annotation.MvcUriComponentsBuilder.on; + + @RestController @EnableWebMvc @RequestMapping("/echo") public class EchoResource { + public static final String TEST_GENERATE_URI = "test"; + public static final String STRING_BODY = "Hello"; + public static final String EX_MESSAGE = "404 exception message"; + + @Bean + public MultipartResolver multipartResolver() { + MultipartResolver multipartResolver = new StandardServletMultipartResolver(); + //multipartResolver.setMaxUploadSize(1000000); + return multipartResolver; + } + @Autowired ServletContext servletContext; @@ -47,7 +73,7 @@ public MapResponseModel echoServletHeaders(HttpServletRequest context) { } @RequestMapping(path = "/query-string", method = RequestMethod.GET) - public MapResponseModel echoQueryString(HttpServletRequest request) { + public MapResponseModel echoQueryString(HttpServletRequest request, @RequestParam(value="nonexistent", required=false) String nonexistentParamValue) { MapResponseModel queryStrings = new MapResponseModel(); for (String key : request.getParameterMap().keySet()) { queryStrings.addValue(key, request.getParameterMap().get(key)[0]); @@ -56,12 +82,28 @@ public MapResponseModel echoQueryString(HttpServletRequest request) { return queryStrings; } + @RequestMapping(path = "/multivalue-query-string", method = RequestMethod.GET) + public MapResponseModel countMultivalueQueryParams(@RequestParam MultiValueMap multipleParams) { + MapResponseModel out = new MapResponseModel(); + for (String v : multipleParams.get("multiple")) { + out.addValue(v, "ok"); + } + return out; + } + + @RequestMapping(path = "/list-query-string", method = RequestMethod.GET) + public SingleValueModel echoListQueryString(@RequestParam(value="list") List valueList) { + SingleValueModel value = new SingleValueModel(); + value.setValue(valueList.size() + ""); + return value; + } + @RequestMapping(path = "/authorizer-principal", method = RequestMethod.GET) public SingleValueModel echoAuthorizerPrincipal(HttpServletRequest context) { SingleValueModel valueModel = new SingleValueModel(); - ApiGatewayRequestContext apiGatewayRequestContext = - (ApiGatewayRequestContext) context.getAttribute(RequestReader.API_GATEWAY_CONTEXT_PROPERTY); - valueModel.setValue(apiGatewayRequestContext.getAuthorizer().getPrincipalId()); + AwsProxyRequestContext awsProxyRequestContext = + (AwsProxyRequestContext) context.getAttribute(RequestReader.API_GATEWAY_CONTEXT_PROPERTY); + valueModel.setValue(awsProxyRequestContext.getAuthorizer().getPrincipalId()); return valueModel; } @@ -103,11 +145,56 @@ public SingleValueModel echoRequestURI(HttpServletRequest request) { return valueModel; } - @RequestMapping(path = "/request-Url", method = RequestMethod.GET) + @RequestMapping(path = "/request-url", method = RequestMethod.GET) public SingleValueModel echoRequestURL(HttpServletRequest request) { SingleValueModel valueModel = new SingleValueModel(); valueModel.setValue(request.getRequestURL().toString()); return valueModel; } + + @RequestMapping(path = "/request-body", method = RequestMethod.POST) + public SingleValueModel helloForPopulatedBody(@RequestBody(required = false) Optional input) { + SingleValueModel valueModel = new SingleValueModel(); + if (input.isPresent() && !"null".equals(input.get())) { + valueModel.setValue("true"); + } + + return valueModel; + } + + @RequestMapping(path = "/encoded-request-uri/{encoded-var}", method = RequestMethod.GET) + public SingleValueModel echoEncodedRequestUri(@PathVariable("encoded-var") String encodedVar) { + SingleValueModel valueModel = new SingleValueModel(); + valueModel.setValue(encodedVar); + + return valueModel; + } + + @RequestMapping(path = "/generate-uri", method = RequestMethod.GET) + public SingleValueModel echoGeneratedResourceLink() { + SingleValueModel valueModel = new SingleValueModel(); + + URI personUri = fromMethodCall(on(EchoResource.class).echoEncodedRequestUri(TEST_GENERATE_URI)).build().toUri(); + + valueModel.setValue(personUri.toString()); + + return valueModel; + } + + @RequestMapping(path = "/last-modified", method = RequestMethod.GET) + public ResponseEntity echoLastModified() { + return ResponseEntity + .ok() + .lastModified(Instant.now().minus(1, ChronoUnit.DAYS).toEpochMilli()) + .body(STRING_BODY); + } + + @RequestMapping(path = "/attachment", method=RequestMethod.POST) + public ResponseEntity receiveFile(@RequestParam("testFile") MultipartFile file) throws IOException { + String fileName = file.getName(); + byte[] fileContents = file.getBytes(); + + return ResponseEntity.ok(fileName); + } } diff --git a/aws-serverless-java-container-spring/src/test/java/com/amazonaws/serverless/proxy/spring/echoapp/EchoSpringAppConfig.java b/aws-serverless-java-container-spring/src/test/java/com/amazonaws/serverless/proxy/spring/echoapp/EchoSpringAppConfig.java index 230ec4bd..9b3e0cec 100644 --- a/aws-serverless-java-container-spring/src/test/java/com/amazonaws/serverless/proxy/spring/echoapp/EchoSpringAppConfig.java +++ b/aws-serverless-java-container-spring/src/test/java/com/amazonaws/serverless/proxy/spring/echoapp/EchoSpringAppConfig.java @@ -1,32 +1,18 @@ package com.amazonaws.serverless.proxy.spring.echoapp; -import com.amazonaws.serverless.exceptions.ContainerInitializationException; import com.amazonaws.serverless.proxy.internal.testutils.MockLambdaContext; -import com.amazonaws.serverless.proxy.spring.SpringLambdaContainerHandler; -import com.fasterxml.jackson.databind.ObjectMapper; -import org.springframework.beans.factory.annotation.Autowired; +import tools.jackson.databind.ObjectMapper; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.ComponentScan; import org.springframework.context.annotation.Configuration; import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean; import org.springframework.context.annotation.PropertySource; -import org.springframework.web.context.ConfigurableWebApplicationContext; + @Configuration @ComponentScan("com.amazonaws.serverless.proxy.spring.echoapp") @PropertySource("classpath:application.properties") public class EchoSpringAppConfig { - - @Autowired - private ConfigurableWebApplicationContext applicationContext; - - @Bean - public SpringLambdaContainerHandler springLambdaContainerHandler() throws ContainerInitializationException { - SpringLambdaContainerHandler handler = SpringLambdaContainerHandler.getAwsProxyHandler(applicationContext); - handler.setRefreshContext(false); - return handler; - } - @Bean public ObjectMapper objectMapper() { return new ObjectMapper(); @@ -38,7 +24,7 @@ public MockLambdaContext lambdaContext() { } @Bean - public javax.validation.Validator localValidatorFactoryBean() { + public jakarta.validation.Validator localValidatorFactoryBean() { return new LocalValidatorFactoryBean(); } } diff --git a/aws-serverless-java-container-spring/src/test/java/com/amazonaws/serverless/proxy/spring/echoapp/RestControllerAdvice.java b/aws-serverless-java-container-spring/src/test/java/com/amazonaws/serverless/proxy/spring/echoapp/RestControllerAdvice.java new file mode 100644 index 00000000..c5e472a5 --- /dev/null +++ b/aws-serverless-java-container-spring/src/test/java/com/amazonaws/serverless/proxy/spring/echoapp/RestControllerAdvice.java @@ -0,0 +1,27 @@ +package com.amazonaws.serverless.proxy.spring.echoapp; + + +import com.amazonaws.serverless.proxy.spring.echoapp.model.SingleValueModel; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseBody; +import org.springframework.web.servlet.NoHandlerFoundException; + + +@ControllerAdvice +@RequestMapping(produces = "application/json") +@ResponseBody +public class RestControllerAdvice { + public static final String ERROR_MESSAGE = "UnhadledPath"; + + @ExceptionHandler(NoHandlerFoundException.class) + public ResponseEntity unhandledPath(final NoHandlerFoundException e) { + SingleValueModel model = new SingleValueModel(); + model.setValue(ERROR_MESSAGE); + return new ResponseEntity(model, HttpStatus.NOT_FOUND); + } +} \ No newline at end of file diff --git a/aws-serverless-java-container-spring/src/test/java/com/amazonaws/serverless/proxy/spring/echoapp/UnauthenticatedFilter.java b/aws-serverless-java-container-spring/src/test/java/com/amazonaws/serverless/proxy/spring/echoapp/UnauthenticatedFilter.java new file mode 100644 index 00000000..ac88119e --- /dev/null +++ b/aws-serverless-java-container-spring/src/test/java/com/amazonaws/serverless/proxy/spring/echoapp/UnauthenticatedFilter.java @@ -0,0 +1,42 @@ +package com.amazonaws.serverless.proxy.spring.echoapp; + + +import jakarta.servlet.Filter; +import jakarta.servlet.FilterChain; +import jakarta.servlet.FilterConfig; +import jakarta.servlet.ServletException; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import java.io.IOException; + + +public class UnauthenticatedFilter implements Filter { + public static final String HEADER_NAME = "X-Unauthenticated-Response"; + public static final int RESPONSE_STATUS = 401; + + @Override + public void init(FilterConfig filterConfig) + throws ServletException { + + } + + + @Override + public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) + throws IOException, ServletException { + if (((HttpServletRequest)servletRequest).getHeader(HEADER_NAME) != null) { + ((HttpServletResponse) servletResponse).setStatus(401); + return; + } + filterChain.doFilter(servletRequest, servletResponse); + } + + + @Override + public void destroy() { + + } +} diff --git a/aws-serverless-java-container-spring/src/test/java/com/amazonaws/serverless/proxy/spring/echoapp/model/ValidatedUserModel.java b/aws-serverless-java-container-spring/src/test/java/com/amazonaws/serverless/proxy/spring/echoapp/model/ValidatedUserModel.java index 56c157e8..c587429d 100644 --- a/aws-serverless-java-container-spring/src/test/java/com/amazonaws/serverless/proxy/spring/echoapp/model/ValidatedUserModel.java +++ b/aws-serverless-java-container-spring/src/test/java/com/amazonaws/serverless/proxy/spring/echoapp/model/ValidatedUserModel.java @@ -1,9 +1,8 @@ package com.amazonaws.serverless.proxy.spring.echoapp.model; -import org.hibernate.validator.constraints.Email; -import org.hibernate.validator.constraints.NotEmpty; - -import javax.validation.constraints.Size; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Size; public class ValidatedUserModel { @NotEmpty diff --git a/aws-serverless-java-container-spring/src/test/java/com/amazonaws/serverless/proxy/spring/echoapp/profile/OverrideProfileConfiguration.java b/aws-serverless-java-container-spring/src/test/java/com/amazonaws/serverless/proxy/spring/echoapp/profile/OverrideProfileConfiguration.java index 4e03f80e..2738b6a1 100644 --- a/aws-serverless-java-container-spring/src/test/java/com/amazonaws/serverless/proxy/spring/echoapp/profile/OverrideProfileConfiguration.java +++ b/aws-serverless-java-container-spring/src/test/java/com/amazonaws/serverless/proxy/spring/echoapp/profile/OverrideProfileConfiguration.java @@ -4,6 +4,7 @@ import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Profile; + @Configuration @Profile("override") public class OverrideProfileConfiguration { diff --git a/aws-serverless-java-container-spring/src/test/java/com/amazonaws/serverless/proxy/spring/extensibility/CustomServlet.java b/aws-serverless-java-container-spring/src/test/java/com/amazonaws/serverless/proxy/spring/extensibility/CustomServlet.java new file mode 100644 index 00000000..5c344b4d --- /dev/null +++ b/aws-serverless-java-container-spring/src/test/java/com/amazonaws/serverless/proxy/spring/extensibility/CustomServlet.java @@ -0,0 +1,21 @@ +package com.amazonaws.serverless.proxy.spring.extensibility; + +import org.springframework.context.ApplicationContext; + +import jakarta.servlet.http.HttpServlet; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; + +public class CustomServlet extends HttpServlet { + private ApplicationContext appCtx; + + @Override + protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException { + resp.getWriter().print("Unittest " + (appCtx!=null ? appCtx.getDisplayName() : "")); + } + + public void setAppCtx(ApplicationContext appCtx) { + this.appCtx = appCtx; + } +} diff --git a/aws-serverless-java-container-spring/src/test/java/com/amazonaws/serverless/proxy/spring/extensibility/CustomServletTest.java b/aws-serverless-java-container-spring/src/test/java/com/amazonaws/serverless/proxy/spring/extensibility/CustomServletTest.java new file mode 100644 index 00000000..5959a811 --- /dev/null +++ b/aws-serverless-java-container-spring/src/test/java/com/amazonaws/serverless/proxy/spring/extensibility/CustomServletTest.java @@ -0,0 +1,27 @@ +package com.amazonaws.serverless.proxy.spring.extensibility; + +import com.amazonaws.serverless.proxy.internal.testutils.AwsProxyRequestBuilder; +import com.amazonaws.serverless.proxy.internal.testutils.MockLambdaContext; +import org.junit.jupiter.api.Test; + +import jakarta.ws.rs.HttpMethod; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class CustomServletTest { + + @Test + void customServlet() throws IOException { + StreamLambdaHandler lambdaHandler = new StreamLambdaHandler(); + InputStream requestStream = new AwsProxyRequestBuilder("/test", HttpMethod.GET) + .buildStream(); + ByteArrayOutputStream responseStream = new ByteArrayOutputStream(); + lambdaHandler.handleRequest(requestStream, responseStream, new MockLambdaContext()); + assertTrue(responseStream.toString().contains("Unittest"), + "response should contain value set in CustomServlet"); + } + +} diff --git a/aws-serverless-java-container-spring/src/test/java/com/amazonaws/serverless/proxy/spring/extensibility/CustomSpringLambdaContainerHandler.java b/aws-serverless-java-container-spring/src/test/java/com/amazonaws/serverless/proxy/spring/extensibility/CustomSpringLambdaContainerHandler.java new file mode 100644 index 00000000..4e5fe278 --- /dev/null +++ b/aws-serverless-java-container-spring/src/test/java/com/amazonaws/serverless/proxy/spring/extensibility/CustomSpringLambdaContainerHandler.java @@ -0,0 +1,92 @@ +package com.amazonaws.serverless.proxy.spring.extensibility; + +import com.amazonaws.serverless.exceptions.ContainerInitializationException; +import com.amazonaws.serverless.proxy.*; +import com.amazonaws.serverless.proxy.internal.servlet.AwsHttpServletResponse; +import com.amazonaws.serverless.proxy.model.AwsProxyRequest; +import com.amazonaws.serverless.proxy.model.AwsProxyResponse; +import com.amazonaws.serverless.proxy.model.HttpApiV2ProxyRequest; +import com.amazonaws.serverless.proxy.spring.SpringLambdaContainerHandler; +import org.springframework.web.context.ConfigurableWebApplicationContext; + +import jakarta.servlet.ServletRegistration; +import jakarta.servlet.http.HttpServletRequest; + +public class CustomSpringLambdaContainerHandler extends SpringLambdaContainerHandler { + + /** + * Creates a default SpringLambdaContainerHandler initialized with the `AwsProxyRequest` and `AwsProxyResponse` objects + * @param config A set of classes annotated with the Spring @Configuration annotation + * @return An initialized instance of the `SpringLambdaContainerHandler` + * @throws ContainerInitializationException When the Spring framework fails to start. + */ + public static SpringLambdaContainerHandler getAwsProxyHandler(Class... config) throws ContainerInitializationException { + return new CustomSpringProxyHandlerBuilder() + .defaultProxy() + .initializationWrapper(new InitializationWrapper()) + .configurationClasses(config) + .buildAndInitialize(); + } + + /** + * Creates a default SpringLambdaContainerHandler initialized with the `AwsProxyRequest` and `AwsProxyResponse` objects and sets the given profiles as active + * @param applicationContext A custom ConfigurableWebApplicationContext to be used + * @param profiles The spring profiles to activate + * @return An initialized instance of the `SpringLambdaContainerHandler` + * @throws ContainerInitializationException When the Spring framework fails to start. + */ + public static SpringLambdaContainerHandler getAwsProxyHandler(ConfigurableWebApplicationContext applicationContext, String... profiles) + throws ContainerInitializationException { + return new CustomSpringProxyHandlerBuilder() + .defaultProxy() + .initializationWrapper(new InitializationWrapper()) + .springApplicationContext(applicationContext) + .profiles(profiles) + .buildAndInitialize(); + } + + /** + * Creates a default SpringLambdaContainerHandler initialized with the `HttpApiV2ProxyRequest` and `AwsProxyResponse` objects + * @param config A set of classes annotated with the Spring @Configuration annotation + * @return An initialized instance of the `SpringLambdaContainerHandler` + * @throws ContainerInitializationException When the Spring framework fails to start. + */ + public static SpringLambdaContainerHandler getHttpApiV2ProxyHandler(Class... config) throws ContainerInitializationException { + return new CustomSpringProxyHandlerBuilder() + .defaultHttpApiV2Proxy() + .initializationWrapper(new InitializationWrapper()) + .configurationClasses(config) + .buildAndInitialize(); + } + + /** + * Creates a new container handler with the given reader and writer objects + * + * @param requestTypeClass The class for the incoming Lambda event + * @param requestReader An implementation of `RequestReader` + * @param responseWriter An implementation of `ResponseWriter` + * @param securityContextWriter An implementation of `SecurityContextWriter` + * @param exceptionHandler An implementation of `ExceptionHandler` + */ + public CustomSpringLambdaContainerHandler(Class requestTypeClass, + Class responseTypeClass, + RequestReader requestReader, + ResponseWriter responseWriter, + SecurityContextWriter securityContextWriter, + ExceptionHandler exceptionHandler, + ConfigurableWebApplicationContext applicationContext, + InitializationWrapper init) { + super(requestTypeClass, responseTypeClass, requestReader, responseWriter, securityContextWriter, + exceptionHandler, applicationContext, init); + } + + @Override + protected void registerServlets() { + CustomServlet customServlet = new CustomServlet(); + customServlet.setAppCtx(appContext); + ServletRegistration.Dynamic reg = getServletContext().addServlet("customServlet", customServlet); + reg.addMapping("/"); + reg.setLoadOnStartup(1); + } + +} diff --git a/aws-serverless-java-container-spring/src/test/java/com/amazonaws/serverless/proxy/spring/extensibility/CustomSpringProxyHandlerBuilder.java b/aws-serverless-java-container-spring/src/test/java/com/amazonaws/serverless/proxy/spring/extensibility/CustomSpringProxyHandlerBuilder.java new file mode 100644 index 00000000..de74cf54 --- /dev/null +++ b/aws-serverless-java-container-spring/src/test/java/com/amazonaws/serverless/proxy/spring/extensibility/CustomSpringProxyHandlerBuilder.java @@ -0,0 +1,15 @@ +package com.amazonaws.serverless.proxy.spring.extensibility; + +import com.amazonaws.serverless.proxy.model.AwsProxyResponse; +import com.amazonaws.serverless.proxy.spring.SpringLambdaContainerHandler; +import com.amazonaws.serverless.proxy.spring.SpringProxyHandlerBuilder; +import org.springframework.web.context.ConfigurableWebApplicationContext; + +public class CustomSpringProxyHandlerBuilder extends SpringProxyHandlerBuilder { + + @Override + protected SpringLambdaContainerHandler createHandler(ConfigurableWebApplicationContext ctx) { + return new CustomSpringLambdaContainerHandler<>(requestTypeClass, responseTypeClass, requestReader, responseWriter, + securityContextWriter, exceptionHandler, ctx, initializationWrapper); + } +} diff --git a/aws-serverless-java-container-spring/src/test/java/com/amazonaws/serverless/proxy/spring/extensibility/StreamLambdaHandler.java b/aws-serverless-java-container-spring/src/test/java/com/amazonaws/serverless/proxy/spring/extensibility/StreamLambdaHandler.java new file mode 100644 index 00000000..afc74869 --- /dev/null +++ b/aws-serverless-java-container-spring/src/test/java/com/amazonaws/serverless/proxy/spring/extensibility/StreamLambdaHandler.java @@ -0,0 +1,32 @@ +package com.amazonaws.serverless.proxy.spring.extensibility; + + +import com.amazonaws.serverless.exceptions.ContainerInitializationException; +import com.amazonaws.serverless.proxy.model.AwsProxyRequest; +import com.amazonaws.serverless.proxy.model.AwsProxyResponse; +import com.amazonaws.serverless.proxy.spring.SpringLambdaContainerHandler; +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestStreamHandler; +import org.springframework.web.context.support.GenericWebApplicationContext; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + + +public class StreamLambdaHandler implements RequestStreamHandler { + private static final SpringLambdaContainerHandler handler; + static { + try { + handler = CustomSpringLambdaContainerHandler.getAwsProxyHandler(new GenericWebApplicationContext()); + } catch (ContainerInitializationException e) { + throw new RuntimeException("Could not initialize Spring framework", e); + } + } + + @Override + public void handleRequest(InputStream inputStream, OutputStream outputStream, Context context) + throws IOException { + handler.proxyStream(inputStream, outputStream, context); + } +} \ No newline at end of file diff --git a/aws-serverless-java-container-spring/src/test/java/com/amazonaws/serverless/proxy/spring/profile/SpringProfileTest.java b/aws-serverless-java-container-spring/src/test/java/com/amazonaws/serverless/proxy/spring/profile/SpringProfileTest.java index 882e75ee..d08a3e45 100644 --- a/aws-serverless-java-container-spring/src/test/java/com/amazonaws/serverless/proxy/spring/profile/SpringProfileTest.java +++ b/aws-serverless-java-container-spring/src/test/java/com/amazonaws/serverless/proxy/spring/profile/SpringProfileTest.java @@ -1,27 +1,27 @@ package com.amazonaws.serverless.proxy.spring.profile; -import com.amazonaws.serverless.proxy.internal.model.AwsProxyRequest; -import com.amazonaws.serverless.proxy.internal.model.AwsProxyResponse; +import com.amazonaws.serverless.proxy.model.AwsProxyRequest; +import com.amazonaws.serverless.proxy.model.AwsProxyResponse; import com.amazonaws.serverless.proxy.internal.servlet.AwsServletContext; import com.amazonaws.serverless.proxy.internal.testutils.AwsProxyRequestBuilder; import com.amazonaws.serverless.proxy.internal.testutils.MockLambdaContext; import com.amazonaws.serverless.proxy.spring.SpringLambdaContainerHandler; import com.amazonaws.serverless.proxy.spring.echoapp.EchoSpringAppConfig; import com.amazonaws.serverless.proxy.spring.echoapp.model.MapResponseModel; -import com.fasterxml.jackson.databind.ObjectMapper; -import org.junit.Before; -import org.junit.Test; -import org.junit.runner.RunWith; +import tools.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.test.context.ContextConfiguration; import org.springframework.test.context.TestExecutionListeners; -import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; +import org.springframework.test.context.junit.jupiter.SpringExtension; import org.springframework.test.context.support.DependencyInjectionTestExecutionListener; import org.springframework.test.context.web.WebAppConfiguration; -import static org.junit.Assert.assertEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; -@RunWith(SpringJUnit4ClassRunner.class) +@ExtendWith(SpringExtension.class) @ContextConfiguration(classes = EchoSpringAppConfig.class) @WebAppConfiguration @TestExecutionListeners(inheritListeners = false, listeners = {DependencyInjectionTestExecutionListener.class}) @@ -32,13 +32,13 @@ public class SpringProfileTest { @Autowired private MockLambdaContext lambdaContext; - @Before + @BeforeEach public void clearServletContextCache() { AwsServletContext.clearServletContextCache(); } @Test - public void profile_defaultProfile() throws Exception { + void profile_defaultProfile() throws Exception { AwsProxyRequest request = new AwsProxyRequestBuilder("/profile/spring-properties", "GET") .build(); @@ -54,10 +54,9 @@ public void profile_defaultProfile() throws Exception { } @Test - public void profile_overrideProfile() throws Exception { + void profile_overrideProfile() throws Exception { AwsProxyRequest request = new AwsProxyRequestBuilder("/profile/spring-properties", "GET") .build(); - SpringLambdaContainerHandler handler = SpringLambdaContainerHandler.getAwsProxyHandler(EchoSpringAppConfig.class); handler.activateSpringProfiles("override"); AwsProxyResponse output = handler.proxy(request, lambdaContext); diff --git a/aws-serverless-java-container-spring/src/test/java/com/amazonaws/serverless/proxy/spring/springapp/AppConfig.java b/aws-serverless-java-container-spring/src/test/java/com/amazonaws/serverless/proxy/spring/springapp/AppConfig.java new file mode 100644 index 00000000..d3562c22 --- /dev/null +++ b/aws-serverless-java-container-spring/src/test/java/com/amazonaws/serverless/proxy/spring/springapp/AppConfig.java @@ -0,0 +1,8 @@ +package com.amazonaws.serverless.proxy.spring.springapp; + +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; + +@Configuration +@Import({MessageController.class}) +public class AppConfig { } diff --git a/aws-serverless-java-container-spring/src/test/java/com/amazonaws/serverless/proxy/spring/springapp/LambdaHandler.java b/aws-serverless-java-container-spring/src/test/java/com/amazonaws/serverless/proxy/spring/springapp/LambdaHandler.java new file mode 100644 index 00000000..f0cad13c --- /dev/null +++ b/aws-serverless-java-container-spring/src/test/java/com/amazonaws/serverless/proxy/spring/springapp/LambdaHandler.java @@ -0,0 +1,26 @@ +package com.amazonaws.serverless.proxy.spring.springapp; + +import com.amazonaws.serverless.exceptions.ContainerInitializationException; +import com.amazonaws.serverless.proxy.model.AwsProxyRequest; +import com.amazonaws.serverless.proxy.model.AwsProxyResponse; +import com.amazonaws.serverless.proxy.spring.SpringLambdaContainerHandler; +import com.amazonaws.serverless.proxy.spring.SpringProxyHandlerBuilder; +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; + +public class LambdaHandler implements RequestHandler { + private SpringLambdaContainerHandler handler; + + public LambdaHandler() throws ContainerInitializationException { + handler = new SpringProxyHandlerBuilder() + .defaultProxy() + .asyncInit() + .configurationClasses(AppConfig.class) + .buildAndInitialize(); + } + + @Override + public AwsProxyResponse handleRequest(AwsProxyRequest awsProxyRequest, Context context) { + return handler.proxy(awsProxyRequest, context); + } +} diff --git a/aws-serverless-java-container-spring/src/test/java/com/amazonaws/serverless/proxy/spring/springapp/MessageController.java b/aws-serverless-java-container-spring/src/test/java/com/amazonaws/serverless/proxy/spring/springapp/MessageController.java new file mode 100644 index 00000000..1f5dc83d --- /dev/null +++ b/aws-serverless-java-container-spring/src/test/java/com/amazonaws/serverless/proxy/spring/springapp/MessageController.java @@ -0,0 +1,25 @@ +package com.amazonaws.serverless.proxy.spring.springapp; + +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.context.request.async.DeferredResult; +import org.springframework.web.servlet.config.annotation.EnableWebMvc; + +@RestController +@EnableWebMvc +public class MessageController { + public static final String HELLO_MESSAGE = "Hello"; + + @RequestMapping(path="/hello", method= RequestMethod.GET) + public String hello() { + return HELLO_MESSAGE; + } + + @RequestMapping(path="/async", method= RequestMethod.GET) + public DeferredResult asyncHello() { + DeferredResult result = new DeferredResult<>(); + result.setResult(HELLO_MESSAGE); + return result; + } +} diff --git a/aws-serverless-java-container-spring/src/test/java/com/amazonaws/serverless/proxy/spring/springslowapp/LambdaHandler.java b/aws-serverless-java-container-spring/src/test/java/com/amazonaws/serverless/proxy/spring/springslowapp/LambdaHandler.java new file mode 100644 index 00000000..0607c6f1 --- /dev/null +++ b/aws-serverless-java-container-spring/src/test/java/com/amazonaws/serverless/proxy/spring/springslowapp/LambdaHandler.java @@ -0,0 +1,34 @@ +package com.amazonaws.serverless.proxy.spring.springslowapp; + +import com.amazonaws.serverless.exceptions.ContainerInitializationException; +import com.amazonaws.serverless.proxy.model.AwsProxyRequest; +import com.amazonaws.serverless.proxy.model.AwsProxyResponse; +import com.amazonaws.serverless.proxy.spring.SpringLambdaContainerHandler; +import com.amazonaws.serverless.proxy.spring.SpringProxyHandlerBuilder; +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; + +import java.time.Instant; + +public class LambdaHandler implements RequestHandler { + private SpringLambdaContainerHandler handler; + private long constructorTime; + + public LambdaHandler() throws ContainerInitializationException { + long startTime = Instant.now().toEpochMilli(); + handler = new SpringProxyHandlerBuilder() + .defaultProxy() + .configurationClasses(SlowAppConfig.class) + .buildAndInitialize(); + constructorTime = Instant.now().toEpochMilli() - startTime; + } + + public long getConstructorTime() { + return constructorTime; + } + + @Override + public AwsProxyResponse handleRequest(AwsProxyRequest awsProxyRequest, Context context) { + return handler.proxy(awsProxyRequest, context); + } +} diff --git a/aws-serverless-java-container-spring/src/test/java/com/amazonaws/serverless/proxy/spring/springslowapp/MessageController.java b/aws-serverless-java-container-spring/src/test/java/com/amazonaws/serverless/proxy/spring/springslowapp/MessageController.java new file mode 100644 index 00000000..1c8abd20 --- /dev/null +++ b/aws-serverless-java-container-spring/src/test/java/com/amazonaws/serverless/proxy/spring/springslowapp/MessageController.java @@ -0,0 +1,17 @@ +package com.amazonaws.serverless.proxy.spring.springslowapp; + +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.config.annotation.EnableWebMvc; + +@RestController +@EnableWebMvc +public class MessageController { + public static final String HELLO_MESSAGE = "Hello"; + + @RequestMapping(path="/hello", method= RequestMethod.GET) + public String hello() { + return HELLO_MESSAGE; + } +} diff --git a/aws-serverless-java-container-spring/src/test/java/com/amazonaws/serverless/proxy/spring/springslowapp/SlowAppConfig.java b/aws-serverless-java-container-spring/src/test/java/com/amazonaws/serverless/proxy/spring/springslowapp/SlowAppConfig.java new file mode 100644 index 00000000..d698e30e --- /dev/null +++ b/aws-serverless-java-container-spring/src/test/java/com/amazonaws/serverless/proxy/spring/springslowapp/SlowAppConfig.java @@ -0,0 +1,22 @@ +package com.amazonaws.serverless.proxy.spring.springslowapp; + +import org.springframework.beans.factory.InitializingBean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.stereotype.Component; + + +@Configuration +@Import({MessageController.class}) +public class SlowAppConfig { + + @Component + public static class SlowDownInit implements InitializingBean { + public static final int INIT_SLEEP_TIME_MS = 13_000; + + @Override + public void afterPropertiesSet() throws Exception { + Thread.sleep(INIT_SLEEP_TIME_MS); + } + } +} diff --git a/aws-serverless-java-container-spring/src/test/java/com/amazonaws/serverless/proxy/spring/staticapp/LambdaHandler.java b/aws-serverless-java-container-spring/src/test/java/com/amazonaws/serverless/proxy/spring/staticapp/LambdaHandler.java new file mode 100755 index 00000000..ef74e340 --- /dev/null +++ b/aws-serverless-java-container-spring/src/test/java/com/amazonaws/serverless/proxy/spring/staticapp/LambdaHandler.java @@ -0,0 +1,37 @@ + package com.amazonaws.serverless.proxy.spring.staticapp; + + + import com.amazonaws.serverless.exceptions.ContainerInitializationException; + import com.amazonaws.serverless.proxy.model.AwsProxyRequest; + import com.amazonaws.serverless.proxy.model.AwsProxyResponse; + import com.amazonaws.serverless.proxy.spring.SpringLambdaContainerHandler; + import com.amazonaws.services.lambda.runtime.Context; + import com.amazonaws.services.lambda.runtime.RequestHandler; + + import org.springframework.web.context.support.XmlWebApplicationContext; + + + public class LambdaHandler + implements RequestHandler + { + SpringLambdaContainerHandler handler; + boolean isinitialized = false; + + public AwsProxyResponse handleRequest(AwsProxyRequest awsProxyRequest, Context context) + { + if (!isinitialized) { + isinitialized = true; + try { + XmlWebApplicationContext wc = new XmlWebApplicationContext(); + wc.setConfigLocation("classpath:/staticAppContext.xml"); + handler = SpringLambdaContainerHandler.getAwsProxyHandler(wc); + } catch (ContainerInitializationException e) { + e.printStackTrace(); + return null; + } + } + AwsProxyResponse res = handler.proxy(awsProxyRequest, context); + return res; + } + } + diff --git a/aws-serverless-java-container-spring/src/test/java/com/amazonaws/serverless/proxy/spring/staticapp/MyController.java b/aws-serverless-java-container-spring/src/test/java/com/amazonaws/serverless/proxy/spring/staticapp/MyController.java new file mode 100755 index 00000000..eae65a09 --- /dev/null +++ b/aws-serverless-java-container-spring/src/test/java/com/amazonaws/serverless/proxy/spring/staticapp/MyController.java @@ -0,0 +1,15 @@ +package com.amazonaws.serverless.proxy.spring.staticapp; + +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.servlet.config.annotation.EnableWebMvc; + + +@EnableWebMvc +@Controller +public class MyController { + @RequestMapping({ "/sample/page" }) + public String showPage() { + return "sample"; + } +} diff --git a/aws-serverless-java-container-spring/src/test/resources/boot-application.properties b/aws-serverless-java-container-spring/src/test/resources/boot-application.properties new file mode 100644 index 00000000..c57cb12a --- /dev/null +++ b/aws-serverless-java-container-spring/src/test/resources/boot-application.properties @@ -0,0 +1,3 @@ +spring.http.encoding.charset=UTF-8 +spring.http.encoding.enabled=true +spring.http.encoding.force=true \ No newline at end of file diff --git a/aws-serverless-java-container-spring/src/test/resources/static/static.html b/aws-serverless-java-container-spring/src/test/resources/static/static.html new file mode 100644 index 00000000..5231067b --- /dev/null +++ b/aws-serverless-java-container-spring/src/test/resources/static/static.html @@ -0,0 +1,5 @@ + + +

Static

+ + \ No newline at end of file diff --git a/aws-serverless-java-container-spring/src/test/resources/staticAppContext.xml b/aws-serverless-java-container-spring/src/test/resources/staticAppContext.xml new file mode 100755 index 00000000..712f48c0 --- /dev/null +++ b/aws-serverless-java-container-spring/src/test/resources/staticAppContext.xml @@ -0,0 +1,39 @@ + + + + + + + classpath:application.properties + + + + + + + + + + + /templates/ + + + .html + + + + + + + + + \ No newline at end of file diff --git a/aws-serverless-java-container-spring/src/test/resources/templates/sample.html b/aws-serverless-java-container-spring/src/test/resources/templates/sample.html new file mode 100755 index 00000000..cec03da0 --- /dev/null +++ b/aws-serverless-java-container-spring/src/test/resources/templates/sample.html @@ -0,0 +1,5 @@ + + +hello world + + \ No newline at end of file diff --git a/aws-serverless-java-container-springboot4/pom.xml b/aws-serverless-java-container-springboot4/pom.xml new file mode 100644 index 00000000..cddeb7d7 --- /dev/null +++ b/aws-serverless-java-container-springboot4/pom.xml @@ -0,0 +1,408 @@ + + + + aws-serverless-java-container + com.amazonaws.serverless + 3.0.3-SNAPSHOT + .. + + 4.0.0 + + com.amazonaws.serverless + aws-serverless-java-container-springboot4 + AWS Serverless Java container support - SpringBoot 4 implementation + Allows Java applications written for SpringBoot 4 to run in AWS Lambda + https://aws.amazon.com/lambda + + + 7.0.1 + 4.0.0 + 7.0.0 + + + + + + org.springframework + spring-framework-bom + ${spring.version} + pom + import + + + + + + + + org.springframework.cloud + spring-cloud-function-serverless-web + 5.0.0 + + + com.amazonaws.serverless + aws-serverless-java-container-core + ${project.version} + + + com.amazonaws.serverless + aws-serverless-java-container-core + ${project.version} + tests + test-jar + test + + + com.github.spotbugs + spotbugs-annotations + 4.9.3 + provided + + + org.springframework + spring-webflux + ${spring.version} + true + + + org.springframework.boot + spring-boot + ${springboot.version} + true + + + org.springframework + spring-context + + + org.springframework + spring-core + + + + + org.springframework.boot + spring-boot-autoconfigure + ${springboot.version} + true + + + org.springframework.boot + spring-boot-web-server + ${springboot.version} + + + org.springframework.boot + spring-boot-starter-web + ${springboot.version} + true + + + org.springframework.boot + spring-boot-starter-tomcat + + + + + org.springframework.boot + spring-boot-webmvc + ${springboot.version} + + + org.springframework + spring-core + ${spring.version} + true + + + org.springframework + spring-context + ${spring.version} + true + + + org.springframework + spring-webmvc + ${spring.version} + true + + + org.springframework + spring-aop + + + org.springframework + spring-expression + + + + + + org.springframework.security + spring-security-config + ${springsecurity.version} + + + org.springframework + spring-context + + + org.springframework + spring-beans + + + org.springframework + spring-core + + + org.springframework + spring-expression + + + org.springframework + spring-aop + + + test + + + org.springframework.security + spring-security-web + ${springsecurity.version} + + + org.springframework + spring-core + + + org.springframework + spring-web + + + org.springframework + spring-beans + + + org.springframework + spring-context + + + org.springframework + spring-expression + + + org.springframework + spring-aop + + + test + + + org.hibernate.validator + hibernate-validator + 9.1.0.Final + test + + + + org.junit.jupiter + junit-jupiter + test + + + + jakarta.validation + jakarta.validation-api + 3.1.1 + test + + + + jakarta.websocket + jakarta.websocket-api + 2.2.0 + test + + + + jakarta.websocket + jakarta.websocket-client-api + 2.2.0 + test + + + + org.springframework.boot + spring-boot-starter-data-jpa + ${springboot.version} + test + + + org.springframework.boot + spring-boot-starter-aop + + + org.springframework.boot + spring-boot-starter-webmvc + + + org.springframework.boot + spring-boot-starter-logging + + + org.springframework.boot + spring-boot-starter-tomcat + + + org.apache.tomcat.embed + tomcat-embed-core + + + org.apache.tomcat.embed + tomcat-embed-websocket + + + + + com.h2database + h2 + 2.3.232 + test + + + org.springframework.boot + spring-boot-starter-webflux + ${springboot.version} + true + + + org.glassfish.expressly + expressly + 6.0.0 + test + + + + + + + + + org.jacoco + jacoco-maven-plugin + + ${basedir}/target/coverage-reports/jacoco-unit.exec + ${basedir}/target/coverage-reports/jacoco-unit.exec + + + com/amazonaws/serverless/proxy/spring/AwsSpringWebCustomRuntimeEventLoop* + com/amazonaws/serverless/proxy/spring/AwsSpringAotTypesProcessor* + + + + + default-prepare-agent + + prepare-agent + + + + jacoco-site + package + + report + + + + jacoco-check + test + + check + + + true + + + BUNDLE + + + INSTRUCTION + COVEREDRATIO + ${jacoco.minCoverage} + + + + + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + false + + + + com.github.spotbugs + spotbugs-maven-plugin + + ${project.basedir}/spotbugs-excludeFilter.xml + + + + + analyze-compile + compile + + check + + + + + + org.owasp + dependency-check-maven + ${dependencyCheck.version} + + true + + ${project.basedir}/../owasp-suppression.xml + + 7 + false + + + + org.apache.maven.plugins + maven-compiler-plugin + + 17 + 17 + + + + + + + spring-snapshots + Spring Snapshots + https://repo.spring.io/snapshot + + true + + + + spring-milestones + Spring Milestones + https://repo.spring.io/milestone + + false + + + + diff --git a/aws-serverless-java-container-springboot4/spotbugs-excludeFilter.xml b/aws-serverless-java-container-springboot4/spotbugs-excludeFilter.xml new file mode 100644 index 00000000..b26b1ad2 --- /dev/null +++ b/aws-serverless-java-container-springboot4/spotbugs-excludeFilter.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/aws-serverless-java-container-springboot4/src/main/java/com/amazonaws/serverless/proxy/spring/AwsSpringAotTypesProcessor.java b/aws-serverless-java-container-springboot4/src/main/java/com/amazonaws/serverless/proxy/spring/AwsSpringAotTypesProcessor.java new file mode 100644 index 00000000..0a461aa9 --- /dev/null +++ b/aws-serverless-java-container-springboot4/src/main/java/com/amazonaws/serverless/proxy/spring/AwsSpringAotTypesProcessor.java @@ -0,0 +1,97 @@ +/* + * Copyright 2024-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.amazonaws.serverless.proxy.spring; + +import com.amazonaws.serverless.proxy.model.*; +import org.springframework.aot.generate.GenerationContext; +import org.springframework.aot.hint.MemberCategory; +import org.springframework.aot.hint.RuntimeHints; +import org.springframework.beans.factory.aot.BeanFactoryInitializationAotContribution; +import org.springframework.beans.factory.aot.BeanFactoryInitializationAotProcessor; +import org.springframework.beans.factory.aot.BeanFactoryInitializationCode; +import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; + +import com.amazonaws.serverless.proxy.internal.servlet.AwsHttpServletResponse; +import tools.jackson.core.JsonToken; + +/** + * AOT Initialization processor required to register reflective hints for GraalVM. + * This is necessary to ensure proper JSON serialization/deserialization. + * It is registered with META-INF/spring/aot.factories + * + * @author Oleg Zhurakousky + */ +public class AwsSpringAotTypesProcessor implements BeanFactoryInitializationAotProcessor { + + @Override + public BeanFactoryInitializationAotContribution processAheadOfTime(ConfigurableListableBeanFactory beanFactory) { + return new ReflectiveProcessorBeanFactoryInitializationAotContribution(); + } + + private static final class ReflectiveProcessorBeanFactoryInitializationAotContribution implements BeanFactoryInitializationAotContribution { + @Override + public void applyTo(GenerationContext generationContext, BeanFactoryInitializationCode beanFactoryInitializationCode) { + RuntimeHints runtimeHints = generationContext.getRuntimeHints(); + // known static types + + runtimeHints.reflection().registerType(AwsProxyRequest.class, + MemberCategory.INVOKE_PUBLIC_METHODS, MemberCategory.INVOKE_PUBLIC_CONSTRUCTORS, MemberCategory.DECLARED_FIELDS, MemberCategory.DECLARED_CLASSES); + runtimeHints.reflection().registerType(AwsProxyResponse.class, + MemberCategory.INVOKE_PUBLIC_METHODS, MemberCategory.INVOKE_PUBLIC_CONSTRUCTORS, MemberCategory.DECLARED_FIELDS, MemberCategory.DECLARED_CLASSES); + runtimeHints.reflection().registerType(SingleValueHeaders.class, + MemberCategory.INVOKE_PUBLIC_METHODS, MemberCategory.INVOKE_PUBLIC_CONSTRUCTORS, MemberCategory.DECLARED_FIELDS, MemberCategory.DECLARED_CLASSES); + runtimeHints.reflection().registerType(JsonToken.class, + MemberCategory.INVOKE_PUBLIC_METHODS, MemberCategory.INVOKE_PUBLIC_CONSTRUCTORS, MemberCategory.DECLARED_FIELDS, MemberCategory.DECLARED_CLASSES); + runtimeHints.reflection().registerType(MultiValuedTreeMap.class, + MemberCategory.INVOKE_PUBLIC_METHODS, MemberCategory.INVOKE_PUBLIC_CONSTRUCTORS, MemberCategory.DECLARED_FIELDS, MemberCategory.DECLARED_CLASSES); + runtimeHints.reflection().registerType(Headers.class, + MemberCategory.INVOKE_PUBLIC_METHODS, MemberCategory.INVOKE_PUBLIC_CONSTRUCTORS, MemberCategory.DECLARED_FIELDS, MemberCategory.DECLARED_CLASSES); + runtimeHints.reflection().registerType(AwsProxyRequestContext.class, + MemberCategory.INVOKE_PUBLIC_METHODS, MemberCategory.INVOKE_PUBLIC_CONSTRUCTORS, MemberCategory.DECLARED_FIELDS, MemberCategory.DECLARED_CLASSES); + runtimeHints.reflection().registerType(ApiGatewayRequestIdentity.class, + MemberCategory.INVOKE_PUBLIC_METHODS, MemberCategory.INVOKE_PUBLIC_CONSTRUCTORS, MemberCategory.DECLARED_FIELDS, MemberCategory.DECLARED_CLASSES); + runtimeHints.reflection().registerType(AwsHttpServletResponse.class, + MemberCategory.INVOKE_PUBLIC_METHODS, MemberCategory.INVOKE_PUBLIC_CONSTRUCTORS, + MemberCategory.DECLARED_FIELDS, MemberCategory.DECLARED_CLASSES, MemberCategory.INTROSPECT_DECLARED_METHODS); + runtimeHints.reflection().registerType(HttpApiV2ProxyRequest.class, + MemberCategory.INVOKE_PUBLIC_METHODS, MemberCategory.INVOKE_PUBLIC_CONSTRUCTORS, + MemberCategory.DECLARED_FIELDS, MemberCategory.DECLARED_CLASSES, MemberCategory.INTROSPECT_DECLARED_METHODS); + runtimeHints.reflection().registerType(HttpApiV2HttpContext.class, + MemberCategory.INVOKE_PUBLIC_METHODS, MemberCategory.INVOKE_PUBLIC_CONSTRUCTORS, + MemberCategory.DECLARED_FIELDS, MemberCategory.DECLARED_CLASSES, MemberCategory.INTROSPECT_DECLARED_METHODS); + runtimeHints.reflection().registerType(HttpApiV2ProxyRequestContext.class, + MemberCategory.INVOKE_PUBLIC_METHODS, MemberCategory.INVOKE_PUBLIC_CONSTRUCTORS, + MemberCategory.DECLARED_FIELDS, MemberCategory.DECLARED_CLASSES, MemberCategory.INTROSPECT_DECLARED_METHODS); + runtimeHints.reflection().registerType(HttpApiV2AuthorizerMap.class, + MemberCategory.INVOKE_PUBLIC_METHODS, MemberCategory.INVOKE_PUBLIC_CONSTRUCTORS, + MemberCategory.DECLARED_FIELDS, MemberCategory.DECLARED_CLASSES, MemberCategory.INTROSPECT_DECLARED_METHODS); + runtimeHints.reflection().registerType(HttpApiV2AuthorizerMap.HttpApiV2AuthorizerDeserializer.class, + MemberCategory.INVOKE_PUBLIC_METHODS, MemberCategory.INVOKE_PUBLIC_CONSTRUCTORS, + MemberCategory.DECLARED_FIELDS, MemberCategory.DECLARED_CLASSES, MemberCategory.INTROSPECT_DECLARED_METHODS); + runtimeHints.reflection().registerType(HttpApiV2AuthorizerMap.HttpApiV2AuthorizerSerializer.class, + MemberCategory.INVOKE_PUBLIC_METHODS, MemberCategory.INVOKE_PUBLIC_CONSTRUCTORS, + MemberCategory.DECLARED_FIELDS, MemberCategory.DECLARED_CLASSES, MemberCategory.INTROSPECT_DECLARED_METHODS); + runtimeHints.reflection().registerType(HttpApiV2IamAuthorizer.class, + MemberCategory.INVOKE_PUBLIC_METHODS, MemberCategory.INVOKE_PUBLIC_CONSTRUCTORS, + MemberCategory.DECLARED_FIELDS, MemberCategory.DECLARED_CLASSES, MemberCategory.INTROSPECT_DECLARED_METHODS); + runtimeHints.reflection().registerType(HttpApiV2JwtAuthorizer.class, + MemberCategory.INVOKE_PUBLIC_METHODS, MemberCategory.INVOKE_PUBLIC_CONSTRUCTORS, + MemberCategory.DECLARED_FIELDS, MemberCategory.DECLARED_CLASSES, MemberCategory.INTROSPECT_DECLARED_METHODS); + } + + } +} diff --git a/aws-serverless-java-container-springboot4/src/main/java/com/amazonaws/serverless/proxy/spring/AwsSpringHttpProcessingUtils.java b/aws-serverless-java-container-springboot4/src/main/java/com/amazonaws/serverless/proxy/spring/AwsSpringHttpProcessingUtils.java new file mode 100644 index 00000000..b2c05465 --- /dev/null +++ b/aws-serverless-java-container-springboot4/src/main/java/com/amazonaws/serverless/proxy/spring/AwsSpringHttpProcessingUtils.java @@ -0,0 +1,248 @@ +package com.amazonaws.serverless.proxy.spring; + +import java.io.InputStream; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import com.amazonaws.serverless.proxy.internal.HttpUtils; +import com.amazonaws.serverless.proxy.internal.servlet.AwsHttpServletRequest; +import com.amazonaws.serverless.proxy.internal.servlet.AwsProxyHttpServletRequest; +import com.amazonaws.serverless.proxy.model.RequestSource; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.springframework.cloud.function.serverless.web.ServerlessHttpServletRequest; +import org.springframework.cloud.function.serverless.web.ServerlessMVC; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.util.CollectionUtils; +import org.springframework.util.FileCopyUtils; +import org.springframework.util.MultiValueMapAdapter; +import org.springframework.util.StringUtils; + +import com.amazonaws.serverless.proxy.AwsHttpApiV2SecurityContextWriter; +import com.amazonaws.serverless.proxy.AwsProxySecurityContextWriter; +import com.amazonaws.serverless.proxy.RequestReader; +import com.amazonaws.serverless.proxy.SecurityContextWriter; +import com.amazonaws.serverless.proxy.internal.servlet.AwsHttpServletResponse; +import com.amazonaws.serverless.proxy.internal.servlet.AwsProxyHttpServletResponseWriter; +import com.amazonaws.serverless.proxy.model.AwsProxyRequest; +import com.amazonaws.serverless.proxy.model.AwsProxyResponse; +import com.amazonaws.serverless.proxy.model.HttpApiV2ProxyRequest; +import com.amazonaws.services.lambda.runtime.Context; +import tools.jackson.databind.ObjectMapper; + +import jakarta.servlet.ServletContext; +import jakarta.servlet.http.HttpServletRequest; + +import static com.amazonaws.serverless.proxy.internal.servlet.AwsHttpServletRequest.decodeValueIfEncoded; +import static com.amazonaws.serverless.proxy.internal.servlet.AwsHttpServletRequest.getQueryParamValuesAsList; + +class AwsSpringHttpProcessingUtils { + + private static Log logger = LogFactory.getLog(AwsSpringHttpProcessingUtils.class); + private static final int LAMBDA_MAX_REQUEST_DURATION_MINUTES = 15; + + private AwsSpringHttpProcessingUtils() { + + } + + public static AwsProxyResponse processRequest(HttpServletRequest request, ServerlessMVC mvc, + AwsProxyHttpServletResponseWriter responseWriter) { + CountDownLatch latch = new CountDownLatch(1); + AwsHttpServletResponse response = new AwsHttpServletResponse(request, latch); + try { + mvc.service(request, response); + boolean requestTimedOut = !latch.await(LAMBDA_MAX_REQUEST_DURATION_MINUTES, TimeUnit.MINUTES); // timeout is potentially lower as user configures it + if (requestTimedOut) { + logger.warn("request timed out after " + LAMBDA_MAX_REQUEST_DURATION_MINUTES + " minutes"); + } + AwsProxyResponse awsResponse = responseWriter.writeResponse(response, null); + return awsResponse; + } + catch (Exception e) { + e.printStackTrace(); + throw new IllegalStateException(e); + } + } + + public static String extractVersion() { + try { + String path = AwsSpringHttpProcessingUtils.class.getProtectionDomain().getCodeSource().getLocation().toString(); + int endIndex = path.lastIndexOf('.'); + if (endIndex < 0) { + return "UNKNOWN-VERSION"; + } + int startIndex = path.lastIndexOf("/") + 1; + return path.substring(startIndex, endIndex).replace("spring-cloud-function-serverless-web-", ""); + } + catch (Exception e) { + if (logger.isDebugEnabled()) { + logger.debug("Failed to detect version", e); + } + return "UNKNOWN-VERSION"; + } + + } + + public static HttpServletRequest generateHttpServletRequest(InputStream jsonRequest, Context lambdaContext, + ServletContext servletContext, ObjectMapper mapper) { + try { + String text = new String(FileCopyUtils.copyToByteArray(jsonRequest), StandardCharsets.UTF_8); + if (logger.isDebugEnabled()) { + logger.debug("Creating HttpServletRequest from: " + text); + } + return generateHttpServletRequest(text, lambdaContext, servletContext, mapper); + } catch (Exception e) { + throw new IllegalStateException(e); + } + } + + @SuppressWarnings({ "rawtypes", "unchecked" }) + public static HttpServletRequest generateHttpServletRequest(String jsonRequest, Context lambdaContext, + ServletContext servletContext, ObjectMapper mapper) { + Map _request = readValue(jsonRequest, Map.class, mapper); + SecurityContextWriter securityWriter = "2.0".equals(_request.get("version")) + ? new AwsHttpApiV2SecurityContextWriter() + : new AwsProxySecurityContextWriter(); + HttpServletRequest httpServletRequest = "2.0".equals(_request.get("version")) + ? AwsSpringHttpProcessingUtils.generateRequest2(jsonRequest, lambdaContext, securityWriter, mapper, servletContext) + : AwsSpringHttpProcessingUtils.generateRequest1(jsonRequest, lambdaContext, securityWriter, mapper, servletContext); + return httpServletRequest; + } + + @SuppressWarnings({ "unchecked", "rawtypes" }) + private static HttpServletRequest generateRequest1(String request, Context lambdaContext, + SecurityContextWriter securityWriter, ObjectMapper mapper, ServletContext servletContext) { + AwsProxyRequest v1Request = readValue(request, AwsProxyRequest.class, mapper); + + ServerlessHttpServletRequest httpRequest = new ServerlessHttpServletRequest(servletContext, v1Request.getHttpMethod(), v1Request.getPath()); + + populateQueryStringParametersV1(v1Request, httpRequest); + populateMultiValueQueryStringParametersV1(v1Request, httpRequest); + + String contentType = null; + if (v1Request.getMultiValueHeaders() != null) { + MultiValueMapAdapter headers = new MultiValueMapAdapter(v1Request.getMultiValueHeaders()); + httpRequest.setHeaders(headers); + contentType = v1Request.getMultiValueHeaders().getFirst(HttpHeaders.CONTENT_TYPE); + } + populateContentAndContentType( + v1Request.getBody(), + contentType, + v1Request.isBase64Encoded(), + httpRequest + ); + if (v1Request.getRequestContext() != null) { + httpRequest.setAttribute(RequestReader.API_GATEWAY_CONTEXT_PROPERTY, v1Request.getRequestContext()); + httpRequest.setAttribute(RequestReader.ALB_CONTEXT_PROPERTY, v1Request.getRequestContext().getElb()); + } + httpRequest.setAttribute(RequestReader.API_GATEWAY_STAGE_VARS_PROPERTY, v1Request.getStageVariables()); + httpRequest.setAttribute(RequestReader.API_GATEWAY_EVENT_PROPERTY, v1Request); + httpRequest.setAttribute(RequestReader.LAMBDA_CONTEXT_PROPERTY, lambdaContext); + httpRequest.setAttribute(RequestReader.JAX_SECURITY_CONTEXT_PROPERTY, + securityWriter.writeSecurityContext(v1Request, lambdaContext)); + return httpRequest; + } + + + + + @SuppressWarnings({ "rawtypes", "unchecked" }) + private static HttpServletRequest generateRequest2(String request, Context lambdaContext, + SecurityContextWriter securityWriter, ObjectMapper mapper, ServletContext servletContext) { + HttpApiV2ProxyRequest v2Request = readValue(request, HttpApiV2ProxyRequest.class, mapper); + + + ServerlessHttpServletRequest httpRequest = new ServerlessHttpServletRequest(servletContext, + v2Request.getRequestContext().getHttp().getMethod(), v2Request.getRequestContext().getHttp().getPath()); + populateQueryStringParametersV2(v2Request.getQueryStringParameters(), httpRequest); + + v2Request.getHeaders().forEach(httpRequest::setHeader); + + populateContentAndContentType( + v2Request.getBody(), + v2Request.getHeaders().get(HttpHeaders.CONTENT_TYPE), + v2Request.isBase64Encoded(), + httpRequest + ); + + httpRequest.setAttribute(RequestReader.HTTP_API_CONTEXT_PROPERTY, v2Request.getRequestContext()); + httpRequest.setAttribute(RequestReader.HTTP_API_STAGE_VARS_PROPERTY, v2Request.getStageVariables()); + httpRequest.setAttribute(RequestReader.HTTP_API_EVENT_PROPERTY, v2Request); + httpRequest.setAttribute(RequestReader.LAMBDA_CONTEXT_PROPERTY, lambdaContext); + httpRequest.setAttribute(RequestReader.JAX_SECURITY_CONTEXT_PROPERTY, + securityWriter.writeSecurityContext(v2Request, lambdaContext)); + return httpRequest; + } + + private static void populateQueryStringParametersV2(Map requestParameters, ServerlessHttpServletRequest httpRequest) { + if (!CollectionUtils.isEmpty(requestParameters)) { + for (Entry entry : requestParameters.entrySet()) { + // fix according to parseRawQueryString + httpRequest.setParameter(entry.getKey(), entry.getValue()); + } + } + } + + private static void populateQueryStringParametersV1(AwsProxyRequest v1Request, ServerlessHttpServletRequest httpRequest) { + Map requestParameters = v1Request.getQueryStringParameters(); + if (!CollectionUtils.isEmpty(requestParameters)) { + // decode all keys and values in map + for (Entry entry : requestParameters.entrySet()) { + String k = v1Request.getRequestSource() == RequestSource.ALB ? decodeValueIfEncoded(entry.getKey()) : entry.getKey(); + String v = v1Request.getRequestSource() == RequestSource.ALB ? decodeValueIfEncoded(entry.getValue()) : entry.getValue(); + httpRequest.setParameter(k, v); + } + } + } + + private static void populateMultiValueQueryStringParametersV1(AwsProxyRequest v1Request, ServerlessHttpServletRequest httpRequest) { + if (v1Request.getMultiValueQueryStringParameters() != null) { + MultiValueMapAdapter queryStringParameters = new MultiValueMapAdapter<>(v1Request.getMultiValueQueryStringParameters()); + queryStringParameters.forEach((k, v) -> { + String key = v1Request.getRequestSource() == RequestSource.ALB + ? decodeValueIfEncoded(k) + : k; + List value = v1Request.getRequestSource() == RequestSource.ALB + ? getQueryParamValuesAsList(v1Request.getMultiValueQueryStringParameters(), k, false).stream() + .map(AwsHttpServletRequest::decodeValueIfEncoded) + .toList() + : v; + httpRequest.setParameter(key, value.toArray(new String[0])); + }); + } + } + + private static T readValue(String json, Class clazz, ObjectMapper mapper) { + try { + return mapper.readValue(json, clazz); + } + catch (Exception e) { + throw new IllegalStateException(e); + } + } + + private static void populateContentAndContentType( + String body, + String contentType, + boolean base64Encoded, + ServerlessHttpServletRequest httpRequest) { + if (StringUtils.hasText(body)) { + httpRequest.setContentType(contentType == null ? MediaType.APPLICATION_JSON_VALUE : contentType); + if (base64Encoded) { + httpRequest.setContent(Base64.getMimeDecoder().decode(body)); + } else { + Charset charseEncoding = HttpUtils.parseCharacterEncoding(contentType,StandardCharsets.UTF_8); + httpRequest.setContent(body.getBytes(charseEncoding)); + } + } + } + + + +} diff --git a/aws-serverless-java-container-springboot4/src/main/java/com/amazonaws/serverless/proxy/spring/AwsSpringWebCustomRuntimeEventLoop.java b/aws-serverless-java-container-springboot4/src/main/java/com/amazonaws/serverless/proxy/spring/AwsSpringWebCustomRuntimeEventLoop.java new file mode 100644 index 00000000..d4e8cc0b --- /dev/null +++ b/aws-serverless-java-container-springboot4/src/main/java/com/amazonaws/serverless/proxy/spring/AwsSpringWebCustomRuntimeEventLoop.java @@ -0,0 +1,186 @@ +/* + * Copyright 2024-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.amazonaws.serverless.proxy.spring; + +import java.io.PrintWriter; +import java.io.StringWriter; +import java.net.URI; +import java.text.MessageFormat; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.springframework.boot.web.server.servlet.context.ServletWebServerApplicationContext; +import org.springframework.cloud.function.serverless.web.ServerlessMVC; +import org.springframework.context.SmartLifecycle; +import org.springframework.core.env.Environment; +import org.springframework.http.RequestEntity; +import org.springframework.http.ResponseEntity; +import org.springframework.web.client.RestTemplate; + +import com.amazonaws.serverless.proxy.internal.servlet.AwsProxyHttpServletResponseWriter; +import com.amazonaws.serverless.proxy.model.AwsProxyResponse; +import jakarta.servlet.http.HttpServletRequest; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.SerializationFeature; +import tools.jackson.databind.json.JsonMapper; + +/** + * Event loop and necessary configurations to support AWS Lambda Custom Runtime + * - https://docs.aws.amazon.com/lambda/latest/dg/runtimes-custom.html. + * + * @author Oleg Zhurakousky + * @author Mark Sailes + * + */ +public final class AwsSpringWebCustomRuntimeEventLoop implements SmartLifecycle { + + private static Log logger = LogFactory.getLog(AwsSpringWebCustomRuntimeEventLoop.class); + + static final String LAMBDA_VERSION_DATE = "2018-06-01"; + private static final String LAMBDA_ERROR_URL_TEMPLATE = "http://{0}/{1}/runtime/invocation/{2}/error"; + private static final String LAMBDA_RUNTIME_URL_TEMPLATE = "http://{0}/{1}/runtime/invocation/next"; + private static final String LAMBDA_INVOCATION_URL_TEMPLATE = "http://{0}/{1}/runtime/invocation/{2}/response"; + private static final String USER_AGENT_VALUE = String.format("spring-cloud-function/%s-%s", + System.getProperty("java.runtime.version"), AwsSpringHttpProcessingUtils.extractVersion()); + + private final ServletWebServerApplicationContext applicationContext; + + private volatile boolean running; + + private final ExecutorService executor = Executors.newSingleThreadExecutor(); + + public AwsSpringWebCustomRuntimeEventLoop(ServletWebServerApplicationContext applicationContext) { + this.applicationContext = applicationContext; + } + + public void run() { + this.running = true; + this.executor.execute(() -> { + eventLoop(this.applicationContext); + }); + } + + @Override + public void start() { + this.run(); + } + + @Override + public void stop() { + this.executor.shutdownNow(); + this.running = false; + } + + @Override + public boolean isRunning() { + return this.running; + } + + private void eventLoop(ServletWebServerApplicationContext context) { + ServerlessMVC mvc = ServerlessMVC.INSTANCE(context); + + Environment environment = context.getEnvironment(); + logger.info("Starting AWSWebRuntimeEventLoop"); + + String runtimeApi = environment.getProperty("AWS_LAMBDA_RUNTIME_API"); + String eventUri = MessageFormat.format(LAMBDA_RUNTIME_URL_TEMPLATE, runtimeApi, LAMBDA_VERSION_DATE); + if (logger.isDebugEnabled()) { + logger.debug("Event URI: " + eventUri); + } + + RequestEntity requestEntity = RequestEntity.get(URI.create(eventUri)) + .header("User-Agent", USER_AGENT_VALUE).build(); + RestTemplate rest = new RestTemplate(); + ObjectMapper mapper = JsonMapper.builder() + .disable(SerializationFeature.FAIL_ON_EMPTY_BEANS) + .build(); + AwsProxyHttpServletResponseWriter responseWriter = new AwsProxyHttpServletResponseWriter(); + + logger.info("Entering event loop"); + while (this.isRunning()) { + logger.debug("Attempting to get new event"); + ResponseEntity incomingEvent = rest.exchange(requestEntity, String.class); + + if (incomingEvent != null && incomingEvent.hasBody()) { + if (logger.isDebugEnabled()) { + logger.debug("New Event received from AWS Gateway: " + incomingEvent.getBody()); + } + String requestId = incomingEvent.getHeaders().getFirst("Lambda-Runtime-Aws-Request-Id"); + + try { + logger.debug("Submitting request to the user's web application"); + + HttpServletRequest httpServletRequest = AwsSpringHttpProcessingUtils.generateHttpServletRequest( + incomingEvent.getBody(), null, mvc.getServletContext(), mapper); + AwsProxyResponse awsResponse = AwsSpringHttpProcessingUtils.processRequest( + httpServletRequest, mvc, responseWriter); + if (logger.isDebugEnabled()) { + logger.debug("Received response - body: " + awsResponse.getBody() + + "; status: " + awsResponse.getStatusCode() + "; headers: " + awsResponse.getHeaders()); + } + + String invocationUrl = MessageFormat.format(LAMBDA_INVOCATION_URL_TEMPLATE, runtimeApi, + LAMBDA_VERSION_DATE, requestId); + + ResponseEntity result = rest.exchange(RequestEntity.post(URI.create(invocationUrl)) + .header("User-Agent", USER_AGENT_VALUE).body(awsResponse), byte[].class); + if (logger.isDebugEnabled()) { + logger.debug("Response sent: body: " + result.getBody() + + "; status: " + result.getStatusCode() + "; headers: " + result.getHeaders()); + } + if (logger.isInfoEnabled()) { + logger.info("Result POST status: " + result); + } + } + catch (Exception e) { + logger.error(e); + this.propagateAwsError(requestId, e, mapper, runtimeApi, rest); + } + } + } + } + + private void propagateAwsError(String requestId, Exception e, ObjectMapper mapper, String runtimeApi, RestTemplate rest) { + String errorMessage = e.getMessage(); + String errorType = e.getClass().getSimpleName(); + StringWriter sw = new StringWriter(); + PrintWriter pw = new PrintWriter(sw); + e.printStackTrace(pw); + String stackTrace = sw.toString(); + Map em = new HashMap<>(); + em.put("errorMessage", errorMessage); + em.put("errorType", errorType); + em.put("stackTrace", stackTrace); + try { + byte[] outputBody = mapper.writeValueAsBytes(em); + String errorUrl = MessageFormat.format(LAMBDA_ERROR_URL_TEMPLATE, runtimeApi, LAMBDA_VERSION_DATE, requestId); + ResponseEntity result = rest.exchange(RequestEntity.post(URI.create(errorUrl)) + .header("User-Agent", USER_AGENT_VALUE) + .body(outputBody), Object.class); + if (logger.isInfoEnabled()) { + logger.info("Result ERROR status: " + result.getStatusCode()); + } + } + catch (Exception e2) { + throw new IllegalArgumentException("Failed to report error", e2); + } + } +} diff --git a/aws-serverless-java-container-springboot4/src/main/java/com/amazonaws/serverless/proxy/spring/AwsSpringWebRuntimeInitializer.java b/aws-serverless-java-container-springboot4/src/main/java/com/amazonaws/serverless/proxy/spring/AwsSpringWebRuntimeInitializer.java new file mode 100644 index 00000000..9324ebdf --- /dev/null +++ b/aws-serverless-java-container-springboot4/src/main/java/com/amazonaws/serverless/proxy/spring/AwsSpringWebRuntimeInitializer.java @@ -0,0 +1,66 @@ +/* + * Copyright 2024-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.amazonaws.serverless.proxy.spring; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.springframework.boot.web.server.servlet.context.ServletWebServerApplicationContext; +import org.springframework.context.ApplicationContextInitializer; +import org.springframework.context.SmartLifecycle; +import org.springframework.context.support.GenericApplicationContext; +import org.springframework.core.env.Environment; +import org.springframework.util.StringUtils; + +/** + * Initializer to optionally start Custom Runtime to process web workloads. + * Registered with META-INF/spring.factories + * + * @author Dave Syer + * @author Oleg Zhurakousky + */ +public class AwsSpringWebRuntimeInitializer implements ApplicationContextInitializer { + + private static Log logger = LogFactory.getLog(AwsSpringWebRuntimeInitializer.class); + + @Override + public void initialize(GenericApplicationContext context) { + Environment environment = context.getEnvironment(); + + if (context instanceof ServletWebServerApplicationContext && isCustomRuntime(environment)) { + if (context.getBeanFactory().getBeanNamesForType(AwsSpringWebCustomRuntimeEventLoop.class, false, false).length == 0) { + context.registerBean(StringUtils.uncapitalize(AwsSpringWebCustomRuntimeEventLoop.class.getSimpleName()), + SmartLifecycle.class, () -> new AwsSpringWebCustomRuntimeEventLoop((ServletWebServerApplicationContext) context)); + } + } + } + + private boolean isCustomRuntime(Environment environment) { + String handler = environment.getProperty("_HANDLER"); + if (StringUtils.hasText(handler)) { + handler = handler.split(":")[0]; + logger.info("AWS Handler: " + handler); + try { + Thread.currentThread().getContextClassLoader().loadClass(handler); + } + catch (Exception e) { + logger.debug("Will execute Lambda in Custom Runtime"); + return true; + } + } + return false; + } +} diff --git a/aws-serverless-java-container-springboot4/src/main/java/com/amazonaws/serverless/proxy/spring/SpringBootAwsProxyExceptionHandler.java b/aws-serverless-java-container-springboot4/src/main/java/com/amazonaws/serverless/proxy/spring/SpringBootAwsProxyExceptionHandler.java new file mode 100644 index 00000000..127ef668 --- /dev/null +++ b/aws-serverless-java-container-springboot4/src/main/java/com/amazonaws/serverless/proxy/spring/SpringBootAwsProxyExceptionHandler.java @@ -0,0 +1,27 @@ +package com.amazonaws.serverless.proxy.spring; + +import com.amazonaws.serverless.proxy.AwsProxyExceptionHandler; +import com.amazonaws.serverless.proxy.ExceptionHandler; +import com.amazonaws.serverless.proxy.model.AwsProxyResponse; +import org.springframework.web.ErrorResponse; + +/** + * This ExceptionHandler implementation enhances the standard AwsProxyExceptionHandler + * by mapping additional details from org.springframework.web.ErrorResponse + * + * As of now this class is identical with SpringAwsProxyExceptionHandler. We may consider + * moving it to a common module to share it in the future. + */ +public class SpringBootAwsProxyExceptionHandler extends AwsProxyExceptionHandler + implements ExceptionHandler { + @Override + public AwsProxyResponse handle(Throwable ex) { + if (ex instanceof ErrorResponse) { + return new AwsProxyResponse(((ErrorResponse) ex).getStatusCode().value(), + HEADERS, getErrorJson(ex.getMessage())); + } else { + return super.handle(ex); + } + } + +} diff --git a/aws-serverless-java-container-springboot4/src/main/java/com/amazonaws/serverless/proxy/spring/SpringBootLambdaContainerHandler.java b/aws-serverless-java-container-springboot4/src/main/java/com/amazonaws/serverless/proxy/spring/SpringBootLambdaContainerHandler.java new file mode 100644 index 00000000..34dae152 --- /dev/null +++ b/aws-serverless-java-container-springboot4/src/main/java/com/amazonaws/serverless/proxy/spring/SpringBootLambdaContainerHandler.java @@ -0,0 +1,230 @@ +/* + * Copyright 2016 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance + * with the License. A copy of the License is located at + * + * http://aws.amazon.com/apache2.0/ + * + * or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES + * OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions + * and limitations under the License. + */ +package com.amazonaws.serverless.proxy.spring; + +import java.util.concurrent.CountDownLatch; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.WebApplicationType; +import org.springframework.boot.builder.SpringApplicationBuilder; +import org.springframework.boot.web.server.servlet.context.AnnotationConfigServletWebServerApplicationContext; +import org.springframework.context.ConfigurableApplicationContext; + +import com.amazonaws.serverless.exceptions.ContainerInitializationException; +import com.amazonaws.serverless.proxy.ExceptionHandler; +import com.amazonaws.serverless.proxy.InitializationWrapper; +import com.amazonaws.serverless.proxy.RequestReader; +import com.amazonaws.serverless.proxy.ResponseWriter; +import com.amazonaws.serverless.proxy.SecurityContextWriter; +import com.amazonaws.serverless.proxy.internal.servlet.AwsHttpServletRequest; +import com.amazonaws.serverless.proxy.internal.servlet.AwsHttpServletResponse; +import com.amazonaws.serverless.proxy.internal.servlet.AwsLambdaServletContainerHandler; +import com.amazonaws.serverless.proxy.internal.servlet.AwsServletContext; +import com.amazonaws.serverless.proxy.internal.servlet.AwsServletRegistration; +import com.amazonaws.serverless.proxy.internal.testutils.Timer; +import com.amazonaws.serverless.proxy.model.AwsProxyRequest; +import com.amazonaws.serverless.proxy.model.AwsProxyResponse; +import com.amazonaws.serverless.proxy.model.HttpApiV2ProxyRequest; +import com.amazonaws.serverless.proxy.spring.embedded.ServerlessReactiveServletEmbeddedServerFactory; +import com.amazonaws.serverless.proxy.spring.embedded.ServerlessServletEmbeddedServerFactory; +import com.amazonaws.services.lambda.runtime.Context; + +import jakarta.servlet.Servlet; +import jakarta.servlet.http.HttpServletRequest; + +/** + * SpringBoot implementation of the `LambdaContainerHandler` abstract class. This class uses the `LambdaSpringApplicationInitializer` + * object behind the scenes to proxy requests. The default implementation leverages the `AwsProxyHttpServletRequest` and + * `AwsHttpServletResponse` implemented in the `aws-serverless-java-container-core` package. + * + * Important: Make sure to add LambdaFlushResponseListener in your SpringBootServletInitializer subclass configure(). + * + * @param The incoming event type + * @param The expected return type + */ +public class SpringBootLambdaContainerHandler extends AwsLambdaServletContainerHandler { + private static final String DISPATCHER_SERVLET_REGISTRATION_NAME = "dispatcherServlet"; + + private final Class springBootInitializer; + private static final Logger log = LoggerFactory.getLogger(SpringBootLambdaContainerHandler.class); + private String[] springProfiles = null; + private WebApplicationType springWebApplicationType; + private ConfigurableApplicationContext applicationContext; + + private static SpringBootLambdaContainerHandler instance; + + // State vars + private boolean initialized; + + /** + * We need to rely on the static instance of this for SpringBoot because we need it to access the ServletContext. + * Normally, SpringBoot would initialize its own embedded container through the SpringApplication.run() + * method. However, in our case we need to rely on the pre-initialized handler and need to fetch information from it + * for our mock {@link ServerlessReactiveServletEmbeddedServerFactory}. + * + * @return The initialized instance + */ + public static SpringBootLambdaContainerHandler getInstance() { + return instance; + } + + /** + * Creates a default SpringLambdaContainerHandler initialized with the `AwsProxyRequest` and `AwsProxyResponse` objects and the given Spring profiles + * @param springBootInitializer {@code SpringBootServletInitializer} class + * @param profiles A list of Spring profiles to activate + * @return An initialized instance of the `SpringLambdaContainerHandler` + * @throws ContainerInitializationException If an error occurs while initializing the Spring framework + */ + public static SpringBootLambdaContainerHandler getAwsProxyHandler(Class springBootInitializer, String... profiles) + throws ContainerInitializationException { + return new SpringBootProxyHandlerBuilder() + .defaultProxy() + .initializationWrapper(new InitializationWrapper()) + .springBootApplication(springBootInitializer) + .profiles(profiles) + .buildAndInitialize(); + } + + /** + * Creates a default SpringLambdaContainerHandler initialized with the `AwsProxyRequest` and `HttpApiV2ProxyRequest` objects and the given Spring profiles + * @param springBootInitializer {@code SpringBootServletInitializer} class + * @param profiles A list of Spring profiles to activate + * @return An initialized instance of the `SpringLambdaContainerHandler` + * @throws ContainerInitializationException If an error occurs while initializing the Spring framework + */ + public static SpringBootLambdaContainerHandler getHttpApiV2ProxyHandler(Class springBootInitializer, String... profiles) + throws ContainerInitializationException { + return new SpringBootProxyHandlerBuilder() + .defaultHttpApiV2Proxy() + .initializationWrapper(new InitializationWrapper()) + .springBootApplication(springBootInitializer) + .profiles(profiles) + .buildAndInitialize(); + } + + /** + * Creates a new container handler with the given reader and writer objects + * + * @param requestTypeClass The class for the incoming Lambda event + * @param responseTypeClass The class for the Lambda function output + * @param requestReader An implementation of `RequestReader` + * @param responseWriter An implementation of `ResponseWriter` + * @param securityContextWriter An implementation of `SecurityContextWriter` + * @param exceptionHandler An implementation of `ExceptionHandler` + * @param springBootInitializer {@code SpringBootServletInitializer} class + * @param init The initialization Wrapper that will be used to start Spring Boot + * @param applicationType The Spring Web Application Type + */ + public SpringBootLambdaContainerHandler(Class requestTypeClass, + Class responseTypeClass, + RequestReader requestReader, + ResponseWriter responseWriter, + SecurityContextWriter securityContextWriter, + ExceptionHandler exceptionHandler, + Class springBootInitializer, + InitializationWrapper init, + WebApplicationType applicationType) { + super(requestTypeClass, responseTypeClass, requestReader, responseWriter, securityContextWriter, exceptionHandler); + Timer.start("SPRINGBOOT2_CONTAINER_HANDLER_CONSTRUCTOR"); + initialized = false; + this.springBootInitializer = springBootInitializer; + springWebApplicationType = applicationType; + setInitializationWrapper(init); + SpringBootLambdaContainerHandler.setInstance(this); + + Timer.stop("SPRINGBOOT2_CONTAINER_HANDLER_CONSTRUCTOR"); + } + + // this is not pretty. However, because SpringBoot wants to control all of the initialization + // we need to access this handler as a singleton from the EmbeddedContainer to set the servlet + // context and from the ServletConfigurationSupport implementation + private static void setInstance(SpringBootLambdaContainerHandler h) { + SpringBootLambdaContainerHandler.instance = h; + } + + public void activateSpringProfiles(String... profiles) { + springProfiles = profiles; + // force a re-initialization + initialized = false; + } + + @Override + protected AwsHttpServletResponse getContainerResponse(HttpServletRequest request, CountDownLatch latch) { + return new AwsHttpServletResponse(request, latch); + } + + @Override + protected void handleRequest(HttpServletRequest containerRequest, AwsHttpServletResponse containerResponse, Context lambdaContext) throws Exception { + // this method of the AwsLambdaServletContainerHandler sets the servlet context + Timer.start("SPRINGBOOT2_HANDLE_REQUEST"); + + // wire up the application context on the first invocation + if (!initialized) { + initialize(); + } + + // process filters & invoke servlet + Servlet reqServlet = ((AwsServletContext)getServletContext()).getServletForPath(containerRequest.getPathInfo()); + if (AwsHttpServletRequest.class.isAssignableFrom(containerRequest.getClass())) { + ((AwsHttpServletRequest)containerRequest).setServletContext(getServletContext()); + ((AwsHttpServletRequest)containerRequest).setResponse(containerResponse); + } + doFilter(containerRequest, containerResponse, reqServlet); + Timer.stop("SPRINGBOOT2_HANDLE_REQUEST"); + } + + + @Override + public void initialize() + throws ContainerInitializationException { + Timer.start("SPRINGBOOT2_COLD_START"); + + SpringApplicationBuilder builder = new SpringApplicationBuilder(getEmbeddedContainerClasses()) + .web(springWebApplicationType); // .REACTIVE, .SERVLET + if (springProfiles != null) { + builder.profiles(springProfiles); + } + applicationContext = builder.run(); + if (springWebApplicationType == WebApplicationType.SERVLET) { + ((AnnotationConfigServletWebServerApplicationContext)applicationContext).setServletContext(getServletContext()); + AwsServletRegistration reg = (AwsServletRegistration)getServletContext().getServletRegistration(DISPATCHER_SERVLET_REGISTRATION_NAME); + if (reg != null) { + reg.setLoadOnStartup(1); + } + } + super.initialize(); + initialized = true; + Timer.stop("SPRINGBOOT2_COLD_START"); + } + + private Class[] getEmbeddedContainerClasses() { + Class[] classes = new Class[2]; + if (springWebApplicationType == WebApplicationType.REACTIVE) { + try { + // if HandlerAdapter is available we assume they are using WebFlux. Otherwise plain servlet. + this.getClass().getClassLoader().loadClass("org.springframework.web.reactive.HandlerAdapter"); + log.debug("Found WebFlux HandlerAdapter on classpath, using reactive server factory"); + classes[0] = ServerlessReactiveServletEmbeddedServerFactory.class; + } catch (ClassNotFoundException e) { + springWebApplicationType = WebApplicationType.SERVLET; + classes[0] = ServerlessServletEmbeddedServerFactory.class; + } + } else { + classes[0] = ServerlessServletEmbeddedServerFactory.class; + } + + classes[1] = springBootInitializer; + return classes; + } +} diff --git a/aws-serverless-java-container-springboot4/src/main/java/com/amazonaws/serverless/proxy/spring/SpringBootProxyHandlerBuilder.java b/aws-serverless-java-container-springboot4/src/main/java/com/amazonaws/serverless/proxy/spring/SpringBootProxyHandlerBuilder.java new file mode 100644 index 00000000..e7ad017f --- /dev/null +++ b/aws-serverless-java-container-springboot4/src/main/java/com/amazonaws/serverless/proxy/spring/SpringBootProxyHandlerBuilder.java @@ -0,0 +1,88 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance + * with the License. A copy of the License is located at + * + * http://aws.amazon.com/apache2.0/ + * + * or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES + * OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions + * and limitations under the License. + */ +package com.amazonaws.serverless.proxy.spring; + +import com.amazonaws.serverless.exceptions.ContainerInitializationException; +import com.amazonaws.serverless.proxy.ExceptionHandler; +import com.amazonaws.serverless.proxy.internal.servlet.ServletLambdaContainerHandlerBuilder; +import com.amazonaws.serverless.proxy.model.AwsProxyResponse; +import org.springframework.boot.WebApplicationType; + +import jakarta.servlet.http.HttpServletRequest; + +public final class SpringBootProxyHandlerBuilder extends ServletLambdaContainerHandlerBuilder< + RequestType, + AwsProxyResponse, + HttpServletRequest, + SpringBootLambdaContainerHandler, + SpringBootProxyHandlerBuilder> { + private Class springBootInitializer; + private String[] profiles; + private WebApplicationType applicationType = WebApplicationType.REACTIVE; + + @Override + protected SpringBootProxyHandlerBuilder self() { + return this; + } + + + public SpringBootProxyHandlerBuilder springBootApplication(Class app) { + springBootInitializer = app; + return self(); + } + + public SpringBootProxyHandlerBuilder profiles(String... profiles) { + this.profiles = profiles; + return self(); + } + + public SpringBootProxyHandlerBuilder servletApplication() { + this.applicationType = WebApplicationType.SERVLET; + return self(); + } + + @Override + public SpringBootLambdaContainerHandler build() throws ContainerInitializationException { + validate(); + if (springBootInitializer == null) { + throw new ContainerInitializationException("Missing spring boot application class in builder", null); + } + SpringBootLambdaContainerHandler handler = new SpringBootLambdaContainerHandler( + requestTypeClass, + responseTypeClass, + requestReader, + responseWriter, + securityContextWriter, + exceptionHandler, + springBootInitializer, + initializationWrapper, + applicationType + ); + if (profiles != null) { + handler.activateSpringProfiles(profiles); + } + return handler; + } + + @Override + public SpringBootLambdaContainerHandler buildAndInitialize() throws ContainerInitializationException { + SpringBootLambdaContainerHandler handler = build(); + initializationWrapper.start(handler); + return handler; + } + + @Override + protected ExceptionHandler defaultExceptionHandler() { + return new SpringBootAwsProxyExceptionHandler(); + } +} diff --git a/aws-serverless-java-container-springboot4/src/main/java/com/amazonaws/serverless/proxy/spring/SpringDelegatingLambdaContainerHandler.java b/aws-serverless-java-container-springboot4/src/main/java/com/amazonaws/serverless/proxy/spring/SpringDelegatingLambdaContainerHandler.java new file mode 100644 index 00000000..178abdb7 --- /dev/null +++ b/aws-serverless-java-container-springboot4/src/main/java/com/amazonaws/serverless/proxy/spring/SpringDelegatingLambdaContainerHandler.java @@ -0,0 +1,103 @@ +package com.amazonaws.serverless.proxy.spring; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +import com.amazonaws.serverless.exceptions.ContainerInitializationException; +import com.amazonaws.serverless.proxy.AsyncInitializationWrapper; +import com.amazonaws.serverless.proxy.InitializationTypeHelper; +import com.amazonaws.serverless.proxy.internal.InitializableLambdaContainerHandler; +import com.amazonaws.serverless.proxy.model.AwsProxyResponse; +import org.springframework.cloud.function.serverless.web.FunctionClassUtils; +import org.springframework.cloud.function.serverless.web.ServerlessMVC; + +import com.amazonaws.serverless.proxy.internal.servlet.AwsProxyHttpServletResponseWriter; +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestStreamHandler; +import tools.jackson.databind.ObjectMapper; + +import jakarta.servlet.http.HttpServletRequest; + +/* + * Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance + * with the License. A copy of the License is located at + * + * http://aws.amazon.com/apache2.0/ + * + * or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES + * OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions + * and limitations under the License. + */ + +/** + * An implementation of {@link RequestStreamHandler} which delegates to + * Spring Cloud Function serverless web module managed by Spring team. + * + * It requires no sub-classing from the user other then being identified as "Handler". + * The configuration class(es) should be provided via MAIN_CLASS environment variable. + * + */ +public class SpringDelegatingLambdaContainerHandler implements RequestStreamHandler { + private final SpringDelegatingInitHandler initHandler; + private final ObjectMapper mapper; + private final AwsProxyHttpServletResponseWriter responseWriter; + private final AsyncInitializationWrapper asyncInitWrapper; + + public SpringDelegatingLambdaContainerHandler() throws ContainerInitializationException { + this(new Class[] {FunctionClassUtils.getStartClass()}); + } + + public SpringDelegatingLambdaContainerHandler(final Class... startupClasses) throws ContainerInitializationException { + this.initHandler = new SpringDelegatingInitHandler(startupClasses); + if (InitializationTypeHelper.isAsyncInitializationDisabled()) { + initHandler.initialize(); + this.asyncInitWrapper = null; + } else { + this.asyncInitWrapper = new AsyncInitializationWrapper(); + asyncInitWrapper.start(initHandler); + } + this.mapper = new ObjectMapper(); + this.responseWriter = new AwsProxyHttpServletResponseWriter(); + } + + @Override + public void handleRequest(InputStream input, OutputStream output, Context lambdaContext) throws IOException { + // Wait for async initialization to complete if needed to avoid race condition + if (asyncInitWrapper != null) { + try { + asyncInitWrapper.getInitializationLatch().await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("Initialization interrupted", e); + } + } + + ServerlessMVC mvc = initHandler.getMvc(); + HttpServletRequest httpServletRequest = AwsSpringHttpProcessingUtils + .generateHttpServletRequest(input, lambdaContext, mvc.getServletContext(), this.mapper); + AwsProxyResponse awsProxyResponse = AwsSpringHttpProcessingUtils.processRequest(httpServletRequest, mvc, responseWriter); + this.mapper.writeValue(output, awsProxyResponse); + } + + private static final class SpringDelegatingInitHandler implements InitializableLambdaContainerHandler { + private ServerlessMVC mvc; + private final Class[] startupClasses; + + public SpringDelegatingInitHandler(final Class... startupClasses) { + this.startupClasses = startupClasses; + } + + @Override + public void initialize() throws ContainerInitializationException { + this.mvc = ServerlessMVC.INSTANCE(this.startupClasses); + this.mvc.waitForContext(); + } + + public ServerlessMVC getMvc() { + return this.mvc; + } + } +} diff --git a/aws-serverless-java-container-springboot4/src/main/java/com/amazonaws/serverless/proxy/spring/embedded/ServerlessReactiveServletEmbeddedServerFactory.java b/aws-serverless-java-container-springboot4/src/main/java/com/amazonaws/serverless/proxy/spring/embedded/ServerlessReactiveServletEmbeddedServerFactory.java new file mode 100644 index 00000000..917523a3 --- /dev/null +++ b/aws-serverless-java-container-springboot4/src/main/java/com/amazonaws/serverless/proxy/spring/embedded/ServerlessReactiveServletEmbeddedServerFactory.java @@ -0,0 +1,105 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance + * with the License. A copy of the License is located at + * + * http://aws.amazon.com/apache2.0/ + * + * or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES + * OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions + * and limitations under the License. + */ +package com.amazonaws.serverless.proxy.spring.embedded; + +import com.amazonaws.serverless.proxy.spring.SpringBootLambdaContainerHandler; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import org.springframework.boot.autoconfigure.AutoConfigureOrder; +import org.springframework.boot.web.server.reactive.AbstractReactiveWebServerFactory; +import org.springframework.boot.web.server.WebServer; +import org.springframework.boot.web.server.WebServerException; +import org.springframework.core.Ordered; +import org.springframework.http.server.reactive.HttpHandler; +import org.springframework.http.server.reactive.ServletHttpHandlerAdapter; + +import jakarta.servlet.*; +import java.io.IOException; +import java.util.Enumeration; + +@AutoConfigureOrder(Ordered.HIGHEST_PRECEDENCE) +public class ServerlessReactiveServletEmbeddedServerFactory extends AbstractReactiveWebServerFactory implements WebServer, Servlet { + private ServletHttpHandlerAdapter handler; + private ServletConfig config; + static final String SERVLET_NAME = "com.amazonaws.serverless.proxy.spring.embedded.ServerlessReactiveEmbeddedServerFactory"; + static final String SERVLET_INFO = "ServerlessReactiveEmbeddedServerFactory"; + + @Override + @SuppressFBWarnings("MTIA_SUSPECT_SERVLET_INSTANCE_FIELD") + public WebServer getWebServer(HttpHandler httpHandler) { + handler = new ServletHttpHandlerAdapter(httpHandler); + return this; + } + + @Override + public void start() throws WebServerException { + // register this object as the main handler servlet with a mapping of / + SpringBootLambdaContainerHandler + .getInstance() + .getServletContext() + .addServlet(SERVLET_NAME, this) + .addMapping("/"); + handler.init(new ServletAdapterConfig()); + } + + @Override + public void stop() throws WebServerException { + // nothing to do here. + } + + @Override + public void init(ServletConfig servletConfig) throws ServletException { + config = servletConfig; + } + + @Override + public ServletConfig getServletConfig() { + return config; + } + + @Override + public void service(ServletRequest servletRequest, ServletResponse servletResponse) throws ServletException, IOException { + handler.service(servletRequest, servletResponse); + } + + @Override + public String getServletInfo() { + return SERVLET_INFO; + } + + @Override + public void destroy() { + + } + + private static class ServletAdapterConfig implements ServletConfig { + @Override + public String getServletName() { + return SERVLET_NAME; + } + + @Override + public ServletContext getServletContext() { + return SpringBootLambdaContainerHandler.getInstance().getServletContext(); + } + + @Override + public String getInitParameter(String s) { + return null; + } + + @Override + public Enumeration getInitParameterNames() { + return null; + } + } +} diff --git a/aws-serverless-java-container-springboot4/src/main/java/com/amazonaws/serverless/proxy/spring/embedded/ServerlessServletEmbeddedServerFactory.java b/aws-serverless-java-container-springboot4/src/main/java/com/amazonaws/serverless/proxy/spring/embedded/ServerlessServletEmbeddedServerFactory.java new file mode 100644 index 00000000..7278ba44 --- /dev/null +++ b/aws-serverless-java-container-springboot4/src/main/java/com/amazonaws/serverless/proxy/spring/embedded/ServerlessServletEmbeddedServerFactory.java @@ -0,0 +1,65 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance + * with the License. A copy of the License is located at + * + * http://aws.amazon.com/apache2.0/ + * + * or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES + * OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions + * and limitations under the License. + */ +package com.amazonaws.serverless.proxy.spring.embedded; + +import com.amazonaws.serverless.proxy.internal.servlet.AwsLambdaServletContainerHandler; +import com.amazonaws.serverless.proxy.spring.SpringBootLambdaContainerHandler; +import org.springframework.boot.autoconfigure.AutoConfigureOrder; +import org.springframework.boot.web.server.WebServer; +import org.springframework.boot.web.server.WebServerException; +import org.springframework.boot.web.servlet.ServletContextInitializer; +import org.springframework.boot.web.server.servlet.ServletWebServerFactory; +import org.springframework.core.Ordered; + +import jakarta.servlet.ServletException; + +@AutoConfigureOrder(Ordered.HIGHEST_PRECEDENCE) +public class ServerlessServletEmbeddedServerFactory implements ServletWebServerFactory, WebServer { + @SuppressWarnings("rawtypes") + private AwsLambdaServletContainerHandler handler; + + public ServerlessServletEmbeddedServerFactory() { + super(); + handler = SpringBootLambdaContainerHandler.getInstance(); + } + + @Override + public WebServer getWebServer(ServletContextInitializer... initializers) { + for (ServletContextInitializer i : initializers) { + try { + if (handler.getServletContext() == null) { + throw new WebServerException("Attempting to initialize ServletEmbeddedWebServer without ServletContext in Handler", null); + } + i.onStartup(handler.getServletContext()); + } catch (ServletException e) { + throw new WebServerException("Could not initialize Servlets", e); + } + } + return this; + } + + @Override + public void start() throws WebServerException { + + } + + @Override + public void stop() throws WebServerException { + + } + + @Override + public int getPort() { + return 0; + } +} diff --git a/aws-serverless-java-container-springboot4/src/main/resources/META-INF/spring.factories b/aws-serverless-java-container-springboot4/src/main/resources/META-INF/spring.factories new file mode 100644 index 00000000..cd5c2e70 --- /dev/null +++ b/aws-serverless-java-container-springboot4/src/main/resources/META-INF/spring.factories @@ -0,0 +1,2 @@ +org.springframework.context.ApplicationContextInitializer=\ +com.amazonaws.serverless.proxy.spring.AwsSpringWebRuntimeInitializer diff --git a/aws-serverless-java-container-springboot4/src/main/resources/META-INF/spring/aot.factories b/aws-serverless-java-container-springboot4/src/main/resources/META-INF/spring/aot.factories new file mode 100644 index 00000000..44acc0d8 --- /dev/null +++ b/aws-serverless-java-container-springboot4/src/main/resources/META-INF/spring/aot.factories @@ -0,0 +1 @@ +org.springframework.beans.factory.aot.BeanFactoryInitializationAotProcessor=com.amazonaws.serverless.proxy.spring.AwsSpringAotTypesProcessor \ No newline at end of file diff --git a/aws-serverless-java-container-springboot4/src/test/java/com/amazonaws/serverless/proxy/spring/AWSWebRuntimeTests.java b/aws-serverless-java-container-springboot4/src/test/java/com/amazonaws/serverless/proxy/spring/AWSWebRuntimeTests.java new file mode 100644 index 00000000..9903e8f8 --- /dev/null +++ b/aws-serverless-java-container-springboot4/src/test/java/com/amazonaws/serverless/proxy/spring/AWSWebRuntimeTests.java @@ -0,0 +1,39 @@ +package com.amazonaws.serverless.proxy.spring; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.context.ConfigurableApplicationContext; + +public class AWSWebRuntimeTests { + + @AfterEach + public void after() { + System.clearProperty("_HANDLER"); + } + + @Test + public void testWebRuntimeInitialization() throws Exception { + try (ConfigurableApplicationContext context = SpringApplication.run(EmptyApplication.class);) { + assertFalse(context.getBeansOfType(AwsSpringWebCustomRuntimeEventLoop.class).size() > 0); + } + System.setProperty("_HANDLER", "foo"); + AwsSpringWebCustomRuntimeEventLoop loop = null; + try (ConfigurableApplicationContext context = SpringApplication.run(EmptyApplication.class);) { + Thread.sleep(100); + assertTrue(context.getBeansOfType(AwsSpringWebCustomRuntimeEventLoop.class).size() > 0); + loop = context.getBean(AwsSpringWebCustomRuntimeEventLoop.class); + assertTrue(loop.isRunning()); + } + assertFalse(loop.isRunning()); + } + + @EnableAutoConfiguration + private static class EmptyApplication { + + } +} diff --git a/aws-serverless-java-container-springboot4/src/test/java/com/amazonaws/serverless/proxy/spring/AwsSpringHttpProcessingUtilsTests.java b/aws-serverless-java-container-springboot4/src/test/java/com/amazonaws/serverless/proxy/spring/AwsSpringHttpProcessingUtilsTests.java new file mode 100644 index 00000000..d02a4360 --- /dev/null +++ b/aws-serverless-java-container-springboot4/src/test/java/com/amazonaws/serverless/proxy/spring/AwsSpringHttpProcessingUtilsTests.java @@ -0,0 +1,352 @@ +package com.amazonaws.serverless.proxy.spring; + +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Collection; +import java.util.Map; + +import com.amazonaws.serverless.proxy.RequestReader; +import com.amazonaws.serverless.proxy.model.AlbContext; +import com.amazonaws.serverless.proxy.model.HttpApiV2ProxyRequest; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.web.server.servlet.context.ServletWebServerApplicationContext; +import org.springframework.cloud.function.serverless.web.ServerlessMVC; +import org.springframework.cloud.function.serverless.web.ServerlessServletContext; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.config.annotation.EnableWebMvc; + +import com.amazonaws.serverless.proxy.internal.servlet.AwsProxyHttpServletResponseWriter; +import com.amazonaws.serverless.proxy.model.AwsProxyResponse; +import tools.jackson.databind.ObjectMapper; + +import jakarta.servlet.http.HttpServletRequest; + +import static org.junit.jupiter.api.Assertions.*; + +public class AwsSpringHttpProcessingUtilsTests { + + private static String API_GATEWAY_EVENT = "{\n" + + " \"version\": \"1.0\",\n" + + " \"resource\": \"$default\",\n" + + " \"path\": \"/async\",\n" + + " \"httpMethod\": \"POST\",\n" + + " \"headers\": {\n" + + " \"Content-Length\": \"45\",\n" + + " \"Content-Type\": \"application/json\",\n" + + " \"Host\": \"i76bfh111.execute-api.eu-west-3.amazonaws.com\",\n" + + " \"User-Agent\": \"curl/7.79.1\",\n" + + " \"X-Amzn-Trace-Id\": \"Root=1-64087690-2151375b219d3ba3389ea84e\",\n" + + " \"X-Forwarded-For\": \"109.210.252.44\",\n" + + " \"X-Forwarded-Port\": \"443\",\n" + + " \"X-Forwarded-Proto\": \"https\",\n" + + " \"accept\": \"*/*\"\n" + + " },\n" + + " \"multiValueHeaders\": {\n" + + " \"Content-Length\": [\n" + + " \"45\"\n" + + " ],\n" + + " \"Content-Type\": [\n" + + " \"application/json\"\n" + + " ],\n" + + " \"Host\": [\n" + + " \"i76bfhczs0.execute-api.eu-west-3.amazonaws.com\"\n" + + " ],\n" + + " \"User-Agent\": [\n" + + " \"curl/7.79.1\"\n" + + " ],\n" + + " \"X-Amzn-Trace-Id\": [\n" + + " \"Root=1-64087690-2151375b219d3ba3389ea84e\"\n" + + " ],\n" + + " \"X-Forwarded-For\": [\n" + + " \"109.210.252.44\"\n" + + " ],\n" + + " \"X-Forwarded-Port\": [\n" + + " \"443\"\n" + + " ],\n" + + " \"X-Forwarded-Proto\": [\n" + + " \"https\"\n" + + " ],\n" + + " \"accept\": [\n" + + " \"*/*\"\n" + + " ]\n" + + " },\n" + + " \"queryStringParameters\": {\n" + + " \"abc\": \"xyz\",\n" + + " \"parameter1\": \"value2\"\n" + + " },\n" + + " \"multiValueQueryStringParameters\": {\n" + + " \"abc\": [\n" + + " \"xyz\"\n" + + " ],\n" + + " \"parameter1\": [\n" + + " \"value1\",\n" + + " \"value2\"\n" + + " ]\n" + + " },\n" + + " \"requestContext\": {\n" + + " \"accountId\": \"123456789098\",\n" + + " \"apiId\": \"i76bfhczs0\",\n" + + " \"domainName\": \"i76bfhc111.execute-api.eu-west-3.amazonaws.com\",\n" + + " \"domainPrefix\": \"i76bfhczs0\",\n" + + " \"extendedRequestId\": \"Bdd2ngt5iGYEMIg=\",\n" + + " \"httpMethod\": \"POST\",\n" + + " \"path\": \"/pets\",\n" + + " \"protocol\": \"HTTP/1.1\",\n" + + " \"requestId\": \"Bdd2ngt5iGYEMIg=\",\n" + + " \"requestTime\": \"08/Mar/2023:11:50:40 +0000\",\n" + + " \"requestTimeEpoch\": 1678276240455,\n" + + " \"resourceId\": \"$default\",\n" + + " \"resourcePath\": \"$default\",\n" + + " \"stage\": \"$default\"\n" + + " },\n" + + " \"pathParameters\": null,\n" + + " \"stageVariables\": null,\n" + + " \"body\": \"{\\\"name\\\":\\\"bob\\\"}\",\n" + + " \"isBase64Encoded\": false\n" + + "}"; + + private static String API_GATEWAY_EVENT_V2 = "{\n" + + " \"version\": \"2.0\",\n" + + " \"routeKey\": \"$default\",\n" + + " \"rawPath\": \"/async\",\n" + + " \"rawQueryString\": \"parameter1=value1¶meter1=value2¶meter2=value\",\n" + + " \"cookies\": [\n" + + " \"cookie1\",\n" + + " \"cookie2\"\n" + + " ],\n" + + " \"headers\": {\n" + + " \"header1\": \"value1\",\n" + + " \"header2\": \"value1,value2\",\n" + + " \"User-Agent\": \"curl/7.79.1\",\n" + + " \"X-Forwarded-Port\": \"443\"\n" + + " },\n" + + " \"queryStringParameters\": {\n" + + " \"parameter1\": \"value1,value2\",\n" + + " \"parameter2\": \"value\"\n" + + " },\n" + + " \"requestContext\": {\n" + + " \"accountId\": \"123456789012\",\n" + + " \"apiId\": \"api-id\",\n" + + " \"authentication\": {\n" + + " \"clientCert\": {\n" + + " \"clientCertPem\": \"CERT_CONTENT\",\n" + + " \"subjectDN\": \"www.example.com\",\n" + + " \"issuerDN\": \"Example issuer\",\n" + + " \"serialNumber\": \"a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1\",\n" + + " \"validity\": {\n" + + " \"notBefore\": \"May 28 12:30:02 2019 GMT\",\n" + + " \"notAfter\": \"Aug 5 09:36:04 2021 GMT\"\n" + + " }\n" + + " }\n" + + " },\n" + + " \"authorizer\": {\n" + + " \"jwt\": {\n" + + " \"claims\": {\n" + + " \"claim1\": \"value1\",\n" + + " \"claim2\": \"value2\"\n" + + " },\n" + + " \"scopes\": [\n" + + " \"scope1\",\n" + + " \"scope2\"\n" + + " ]\n" + + " }\n" + + " },\n" + + " \"domainName\": \"id.execute-api.us-east-1.amazonaws.com\",\n" + + " \"domainPrefix\": \"id\",\n" + + " \"http\": {\n" + + " \"method\": \"POST\",\n" + + " \"path\": \"/async\",\n" + + " \"protocol\": \"HTTP/1.1\",\n" + + " \"sourceIp\": \"IP\",\n" + + " \"userAgent\": \"agent\"\n" + + " },\n" + + " \"requestId\": \"id\",\n" + + " \"routeKey\": \"$default\",\n" + + " \"stage\": \"$default\",\n" + + " \"time\": \"12/Mar/2020:19:03:58 +0000\",\n" + + " \"timeEpoch\": 1583348638390\n" + + " },\n" + + " \"body\": \"Hello from Lambda\",\n" + + " \"pathParameters\": {\n" + + " \"parameter1\": \"value1\"\n" + + " },\n" + + " \"isBase64Encoded\": false,\n" + + " \"stageVariables\": {\n" + + " \"stageVariable1\": \"value1\",\n" + + " \"stageVariable2\": \"value2\"\n" + + " }\n" + + "}"; + + private static final String ALB_EVENT = "{\n" + + " \"requestContext\": {\n" + + " \"elb\": {\n" + + " \"targetGroupArn\": \"arn:aws:elasticloadbalancing:region:123456789012:targetgroup/my-target-group/6d0ecf831eec9f09\"\n" + + " }\n" + + " },\n" + + " \"httpMethod\": \"POST\",\n" + + " \"path\": \"/async\",\n" + + " \"multiValueQueryStringParameters\": { \"parameter1\": [\"value1\", \"value2\"], \"parameter2\": [\"1970-01-01T00%3A00%3A00.004Z\"]},\n" + + " \"multiValueHeaders\": {\n" + + " \"accept\": [\"text/html,application/xhtml+xml\"],\n" + + " \"accept-language\": [\"en-US,en;q=0.8\"],\n" + + " \"content-type\": [\"text/plain\"],\n" + + " \"cookie\": [\"cookies\"],\n" + + " \"host\": [\"lambda-846800462-us-east-2.elb.amazonaws.com\"],\n" + + " \"User-Agent\": [\"curl/7.79.1\"],\n" + + " \"x-amzn-trace-id\": [\"Root=1-5bdb40ca-556d8b0c50dc66f0511bf520\"],\n" + + " \"x-forwarded-for\": [\"72.21.198.66\"],\n" + + " \"x-forwarded-port\": [\"443\"],\n" + + " \"x-forwarded-proto\": [\"https\"]\n" + + " },\n" + + " \"isBase64Encoded\": false,\n" + + " \"body\": \"request_body\"\n" + + "}"; + + private final ObjectMapper mapper = new ObjectMapper(); + + public static Collection data() { + return Arrays.asList(new String[]{API_GATEWAY_EVENT, API_GATEWAY_EVENT_V2, ALB_EVENT}); + } + + public static Collection v1Data() { + return Arrays.asList(new String[]{API_GATEWAY_EVENT, ALB_EVENT}); + } + + public static Collection v2Data() { + return Arrays.asList(new String[]{API_GATEWAY_EVENT_V2}); + } + + @MethodSource("data") + @ParameterizedTest + public void validateHttpServletRequestGenerationWithInputStream(String jsonEvent) { + ByteArrayInputStream inputStream = new ByteArrayInputStream(jsonEvent.getBytes(StandardCharsets.UTF_8)); + ServerlessServletContext servletContext = new ServerlessServletContext(); + HttpServletRequest request = AwsSpringHttpProcessingUtils.generateHttpServletRequest(inputStream, null, servletContext, mapper); + assertRequest(request); + } + + private static void assertRequest(HttpServletRequest request) { + assertEquals("curl/7.79.1", request.getHeader("User-Agent")); + assertEquals("443", request.getHeader("X-Forwarded-Port")); + assertEquals("POST", request.getMethod()); + assertEquals("/async", request.getRequestURI()); + assertNotNull(request.getServletContext()); + // parameter handling for 2.0 requests is currently not spec compliant and to be fixed in future version + // see also GitHub issue https://github.com/aws/serverless-java-container/issues/1278 + if (!(request.getAttribute(RequestReader.HTTP_API_EVENT_PROPERTY) instanceof HttpApiV2ProxyRequest)) { + assertEquals("value1", request.getParameter("parameter1")); + assertArrayEquals(new String[]{"value1", "value2"}, request.getParameterValues("parameter1")); + } + if (request.getAttribute(RequestReader.ALB_CONTEXT_PROPERTY) instanceof AlbContext) { + // query params should be decoded + assertEquals("1970-01-01T00:00:00.004Z", request.getParameter("parameter2")); + } + } + + @MethodSource("data") + @ParameterizedTest + public void validateHttpServletRequestGenerationWithJson(String jsonEvent) { + ServerlessServletContext servletContext = new ServerlessServletContext(); + HttpServletRequest request = AwsSpringHttpProcessingUtils.generateHttpServletRequest(jsonEvent, null, servletContext, mapper); + // spot check some headers + assertRequest(request); + } + + @MethodSource("data") + @ParameterizedTest + public void validateRequestResponse(String jsonEvent) throws Exception { + try (ConfigurableApplicationContext context = SpringApplication.run(EmptyApplication.class);) { + ServerlessMVC mvc = ServerlessMVC.INSTANCE((ServletWebServerApplicationContext) context); + AwsProxyHttpServletResponseWriter responseWriter = new AwsProxyHttpServletResponseWriter(); + AwsProxyResponse awsResponse = AwsSpringHttpProcessingUtils.processRequest( + AwsSpringHttpProcessingUtils.generateHttpServletRequest(jsonEvent, null, + mvc.getServletContext(), mapper), mvc, responseWriter); + assertEquals("hello", awsResponse.getBody()); + assertEquals(200, awsResponse.getStatusCode()); + } + + } + + + @MethodSource("v1Data") + @ParameterizedTest + public void validateLambdaContextAttribute(String jsonEvent) { + ServerlessServletContext servletContext = new ServerlessServletContext(); + HttpServletRequest request = AwsSpringHttpProcessingUtils.generateHttpServletRequest(jsonEvent, null, servletContext, mapper); + assertNotNull(request.getAttribute(RequestReader.API_GATEWAY_EVENT_PROPERTY)); + } + + @MethodSource("data") + @ParameterizedTest + public void validateSecurityContextAttribute(String jsonEvent) { + ServerlessServletContext servletContext = new ServerlessServletContext(); + HttpServletRequest request = AwsSpringHttpProcessingUtils.generateHttpServletRequest(jsonEvent, null, servletContext, mapper); + assertNotNull(request.getAttribute(RequestReader.JAX_SECURITY_CONTEXT_PROPERTY)); + } + + @MethodSource("v1Data") + @ParameterizedTest + public void validateNullMultiValueHeaders(String jsonEvent) throws Exception { + Map event = mapper.readValue(jsonEvent, Map.class); + event.put("multiValueHeaders", null); + String modifiedEvent = mapper.writeValueAsString(event); + ServerlessServletContext servletContext = new ServerlessServletContext(); + HttpServletRequest request = AwsSpringHttpProcessingUtils.generateHttpServletRequest(modifiedEvent, null, servletContext, mapper); + assertNotNull(request); + } + + @MethodSource("data") + @ParameterizedTest + public void validateBase64EncodedBody(String jsonEvent) throws Exception { + String base64Body = java.util.Base64.getEncoder().encodeToString("test body".getBytes(StandardCharsets.UTF_8)); + Map event = mapper.readValue(jsonEvent, Map.class); + event.put("body", base64Body); + event.put("isBase64Encoded", true); + String modifiedEvent = mapper.writeValueAsString(event); + ServerlessServletContext servletContext = new ServerlessServletContext(); + HttpServletRequest request = AwsSpringHttpProcessingUtils.generateHttpServletRequest(modifiedEvent, null, servletContext, mapper); + assertNotNull(request); + } + + @MethodSource("v2Data") + @ParameterizedTest + public void validateHttpApiContextAttribute(String jsonEvent) { + ServerlessServletContext servletContext = new ServerlessServletContext(); + HttpServletRequest request = AwsSpringHttpProcessingUtils.generateHttpServletRequest(jsonEvent, null, servletContext, mapper); + assertNotNull(request.getAttribute(RequestReader.HTTP_API_CONTEXT_PROPERTY)); + assertNotNull(request.getAttribute(RequestReader.HTTP_API_STAGE_VARS_PROPERTY)); + assertNotNull(request.getAttribute(RequestReader.HTTP_API_EVENT_PROPERTY)); + } + + @EnableAutoConfiguration + @Configuration + @EnableWebSecurity + public static class EmptyApplication { + @RestController + @EnableWebMvc + public static class MyController { + @PostMapping(path = "/async") + public String async(@RequestBody String body) { + return "hello"; + } + } + + @Bean + SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http.csrf((csrf) -> csrf.disable()); + return http.build(); + } + } +} diff --git a/aws-serverless-java-container-springboot4/src/test/java/com/amazonaws/serverless/proxy/spring/JpaAppTest.java b/aws-serverless-java-container-springboot4/src/test/java/com/amazonaws/serverless/proxy/spring/JpaAppTest.java new file mode 100644 index 00000000..a111e510 --- /dev/null +++ b/aws-serverless-java-container-springboot4/src/test/java/com/amazonaws/serverless/proxy/spring/JpaAppTest.java @@ -0,0 +1,52 @@ +package com.amazonaws.serverless.proxy.spring; + +import com.amazonaws.serverless.proxy.internal.testutils.AwsProxyRequestBuilder; +import com.amazonaws.serverless.proxy.internal.testutils.MockLambdaContext; +import com.amazonaws.serverless.proxy.model.AwsProxyResponse; +import com.amazonaws.serverless.proxy.spring.jpaapp.LambdaHandler; +import com.amazonaws.serverless.proxy.spring.jpaapp.MessageController; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +import java.util.Arrays; +import java.util.Collection; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class JpaAppTest { + + LambdaHandler handler; + MockLambdaContext lambdaContext = new MockLambdaContext(); + + private String type; + + public static Collection data() { + return Arrays.asList(new Object[]{"API_GW", "ALB", "HTTP_API"}); + } + + public void initJpaAppTest(String reqType) { + type = reqType; + handler = new LambdaHandler(type); + } + + @MethodSource("data") + @ParameterizedTest + void asyncRequest(String reqType) { + initJpaAppTest(reqType); + AwsProxyRequestBuilder req = new AwsProxyRequestBuilder("/async", "POST") + .json() + .body("{\"name\":\"kong\"}"); + AwsProxyResponse resp = handler.handleRequest(req, lambdaContext); + assertEquals("{\"name\":\"KONG\"}", resp.getBody()); + } + + @MethodSource("data") + @ParameterizedTest + void helloRequest_respondsWithSingleMessage(String reqType) { + initJpaAppTest(reqType); + AwsProxyRequestBuilder req = new AwsProxyRequestBuilder("/hello", "GET"); + AwsProxyResponse resp = handler.handleRequest(req, lambdaContext); + assertEquals(MessageController.HELLO_MESSAGE, resp.getBody()); + } + +} diff --git a/aws-serverless-java-container-springboot4/src/test/java/com/amazonaws/serverless/proxy/spring/SecurityAppTest.java b/aws-serverless-java-container-springboot4/src/test/java/com/amazonaws/serverless/proxy/spring/SecurityAppTest.java new file mode 100644 index 00000000..d0b57950 --- /dev/null +++ b/aws-serverless-java-container-springboot4/src/test/java/com/amazonaws/serverless/proxy/spring/SecurityAppTest.java @@ -0,0 +1,39 @@ +package com.amazonaws.serverless.proxy.spring; + +import com.amazonaws.serverless.proxy.internal.testutils.AwsProxyRequestBuilder; +import com.amazonaws.serverless.proxy.internal.testutils.MockLambdaContext; +import com.amazonaws.serverless.proxy.model.AwsProxyRequest; +import com.amazonaws.serverless.proxy.model.AwsProxyResponse; +import com.amazonaws.serverless.proxy.spring.securityapp.LambdaHandler; +import com.amazonaws.serverless.proxy.spring.securityapp.SecurityConfig; +import org.junit.jupiter.api.Test; + +import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.core.MediaType; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class SecurityAppTest { + + LambdaHandler handler = new LambdaHandler(); + MockLambdaContext lambdaContext = new MockLambdaContext(); + + public SecurityAppTest() { + System.setProperty("logging.level.root", "DEBUG"); + } + + @Test + void helloRequest_withAuth_respondsWithSingleMessage() { + AwsProxyRequest req = new AwsProxyRequestBuilder("/hello", "GET").build(); + AwsProxyResponse resp = handler.handleRequest(req, lambdaContext); + assertEquals(401, resp.getStatusCode()); + assertTrue(resp.getMultiValueHeaders().containsKey(HttpHeaders.WWW_AUTHENTICATE)); + req = new AwsProxyRequestBuilder("/hello", "GET") + .basicAuth(SecurityConfig.USERNAME, SecurityConfig.PASSWORD) + .header(HttpHeaders.ACCEPT, MediaType.TEXT_PLAIN) + .build(); + resp = handler.handleRequest(req, lambdaContext); + assertEquals(200, resp.getStatusCode()); + } +} diff --git a/aws-serverless-java-container-springboot4/src/test/java/com/amazonaws/serverless/proxy/spring/ServletAppTest.java b/aws-serverless-java-container-springboot4/src/test/java/com/amazonaws/serverless/proxy/spring/ServletAppTest.java new file mode 100644 index 00000000..3e7cd0cf --- /dev/null +++ b/aws-serverless-java-container-springboot4/src/test/java/com/amazonaws/serverless/proxy/spring/ServletAppTest.java @@ -0,0 +1,236 @@ +package com.amazonaws.serverless.proxy.spring; + +import com.amazonaws.serverless.proxy.internal.LambdaContainerHandler; +import com.amazonaws.serverless.proxy.internal.testutils.AwsProxyRequestBuilder; +import com.amazonaws.serverless.proxy.internal.testutils.MockLambdaContext; +import com.amazonaws.serverless.proxy.model.AwsProxyResponse; +import com.amazonaws.serverless.proxy.model.ContainerConfig; +import com.amazonaws.serverless.proxy.spring.servletapp.*; +import tools.jackson.core.JacksonException; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.core.MediaType; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.Arrays; +import java.util.Collection; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.*; + +public class ServletAppTest { + + LambdaHandler handler; + MockLambdaContext lambdaContext = new MockLambdaContext(); + + private String type; + + public static Collection data() { + return Arrays.asList(new Object[]{"API_GW", "ALB", "HTTP_API"}); + } + + public void initServletAppTest(String reqType) { + type = reqType; + handler = new LambdaHandler(type); + } + + @MethodSource("data") + @ParameterizedTest + void asyncRequest(String reqType) { + initServletAppTest(reqType); + AwsProxyRequestBuilder req = new AwsProxyRequestBuilder("/async", "POST") + .json() + .body("{\"name\":\"bob\"}"); + AwsProxyResponse resp = handler.handleRequest(req, lambdaContext); + assertEquals("{\"name\":\"BOB\"}", resp.getBody()); + } + + @MethodSource("data") + @ParameterizedTest + void helloRequest_respondsWithSingleMessage(String reqType) { + initServletAppTest(reqType); + AwsProxyRequestBuilder req = new AwsProxyRequestBuilder("/hello", "GET"); + AwsProxyResponse resp = handler.handleRequest(req, lambdaContext); + assertEquals(MessageController.HELLO_MESSAGE, resp.getBody()); + } + + @MethodSource("data") + @ParameterizedTest + void validateRequest_invalidData_respondsWith400(String reqType) { + initServletAppTest(reqType); + UserData ud = new UserData(); + AwsProxyRequestBuilder req = new AwsProxyRequestBuilder("/validate", "POST") + .header(HttpHeaders.ACCEPT, MediaType.TEXT_PLAIN) + .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON) + .body(ud); + AwsProxyResponse resp = handler.handleRequest(req, lambdaContext); + try { + System.out.println(LambdaContainerHandler.getObjectMapper().writeValueAsString(resp)); + } catch (JacksonException e) { + e.printStackTrace(); + } + assertEquals("3", resp.getBody()); + assertEquals(400, resp.getStatusCode()); + + UserData ud2 = new UserData(); + ud2.setFirstName("Test"); + ud2.setLastName("Test"); + ud2.setEmail("Test"); + req = new AwsProxyRequestBuilder("/validate", "POST") + .header(HttpHeaders.ACCEPT, MediaType.TEXT_PLAIN) + .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON) + .body(ud2); + resp = handler.handleRequest(req, lambdaContext); + assertEquals("1", resp.getBody()); + assertEquals(400, resp.getStatusCode()); + } + + @MethodSource("data") + @ParameterizedTest + void messageObject_parsesObject_returnsCorrectMessage(String reqType) { + initServletAppTest(reqType); + AwsProxyRequestBuilder req = new AwsProxyRequestBuilder("/message", "POST") + .json() + .body(new MessageData("test message")); + AwsProxyResponse resp = handler.handleRequest(req, lambdaContext); + assertNotNull(resp); + assertEquals(200, resp.getStatusCode()); + assertEquals("test message", resp.getBody()); + } + + @MethodSource("data") + @ParameterizedTest + void messageObject_propertiesInContentType_returnsCorrectMessage(String reqType) { + initServletAppTest(reqType); + AwsProxyRequestBuilder req = new AwsProxyRequestBuilder("/message", "POST") + .header(HttpHeaders.CONTENT_TYPE, "application/json;v=1") + .header(HttpHeaders.ACCEPT, "application/json;v=1") + .body(new MessageData("test message")); + AwsProxyResponse resp = handler.handleRequest(req, lambdaContext); + assertNotNull(resp); + assertEquals(200, resp.getStatusCode()); + assertEquals("test message", resp.getBody()); + } + + @MethodSource("data") + @ParameterizedTest + void echoMessage_fileNameLikeParameter_returnsMessage(String reqType) { + initServletAppTest(reqType); + AwsProxyRequestBuilder req = new AwsProxyRequestBuilder("/echo/test.test.test", "GET"); + AwsProxyResponse resp = handler.handleRequest(req, lambdaContext); + assertNotNull(resp); + assertEquals(200, resp.getStatusCode()); + assertEquals("test.test.test", resp.getBody()); + } + + @MethodSource("data") + @ParameterizedTest + void getUtf8String_returnsValidUtf8String(String reqType) { + initServletAppTest(reqType); + // We expect strings to come back as UTF-8 correctly because Spring itself will call the setCharacterEncoding + // method on the response to set it to UTF- + LambdaContainerHandler.getContainerConfig().setDefaultContentCharset(ContainerConfig.DEFAULT_CONTENT_CHARSET); + AwsProxyRequestBuilder req = new AwsProxyRequestBuilder("/content-type/utf8", "GET") + .header(HttpHeaders.ACCEPT, MediaType.TEXT_PLAIN); + AwsProxyResponse resp = handler.handleRequest(req, lambdaContext); + assertNotNull(resp); + assertEquals(200, resp.getStatusCode()); + assertEquals("text/plain; charset=UTF-8", resp.getMultiValueHeaders().get(HttpHeaders.CONTENT_TYPE).stream().collect(Collectors.joining(","))); + assertEquals(MessageController.UTF8_RESPONSE, resp.getBody()); + } + + @MethodSource("data") + @ParameterizedTest + void getUtf8Json_returnsValidUtf8String(String reqType) { + initServletAppTest(reqType); + LambdaContainerHandler.getContainerConfig().setDefaultContentCharset(ContainerConfig.DEFAULT_CONTENT_CHARSET); + AwsProxyRequestBuilder req = new AwsProxyRequestBuilder("/content-type/jsonutf8", "GET"); + AwsProxyResponse resp = handler.handleRequest(req, lambdaContext); + assertNotNull(resp); + assertEquals(200, resp.getStatusCode()); + assertEquals("{\"s\":\"" + MessageController.UTF8_RESPONSE + "\"}", resp.getBody()); + } + + @MethodSource("data") + @ParameterizedTest + void stream_getUtf8String_returnsValidUtf8String(String reqType) throws IOException { + initServletAppTest(reqType); + LambdaContainerHandler.getContainerConfig().setDefaultContentCharset(ContainerConfig.DEFAULT_CONTENT_CHARSET); + LambdaStreamHandler streamHandler = new LambdaStreamHandler(type); + AwsProxyRequestBuilder reqBuilder = new AwsProxyRequestBuilder("/content-type/utf8", "GET") + .header(HttpHeaders.ACCEPT, MediaType.TEXT_PLAIN); + InputStream req = null; + switch (type) { + case "ALB": + req = reqBuilder.alb().buildStream(); + break; + case "API_GW": + req = reqBuilder.buildStream(); + break; + case "HTTP_API": + req = reqBuilder.toHttpApiV2RequestStream(); + } + ByteArrayOutputStream out = new ByteArrayOutputStream(); + streamHandler.handleRequest(req, out, lambdaContext); + AwsProxyResponse resp = LambdaContainerHandler.getObjectMapper().readValue(out.toByteArray(), AwsProxyResponse.class); + assertNotNull(resp); + assertEquals(200, resp.getStatusCode()); + assertEquals(MessageController.UTF8_RESPONSE, resp.getBody()); + } + + @MethodSource("data") + @ParameterizedTest + void stream_getUtf8Json_returnsValidUtf8String(String reqType) throws IOException { + initServletAppTest(reqType); + LambdaContainerHandler.getContainerConfig().setDefaultContentCharset(ContainerConfig.DEFAULT_CONTENT_CHARSET); + LambdaStreamHandler streamHandler = new LambdaStreamHandler(type); + AwsProxyRequestBuilder reqBuilder = new AwsProxyRequestBuilder("/content-type/jsonutf8", "GET"); + InputStream req = null; + switch (type) { + case "ALB": + req = reqBuilder.alb().buildStream(); + break; + case "API_GW": + req = reqBuilder.buildStream(); + break; + case "HTTP_API": + req = reqBuilder.toHttpApiV2RequestStream(); + } + ByteArrayOutputStream out = new ByteArrayOutputStream(); + streamHandler.handleRequest(req, out, lambdaContext); + AwsProxyResponse resp = LambdaContainerHandler.getObjectMapper().readValue(out.toByteArray(), AwsProxyResponse.class); + assertNotNull(resp); + assertEquals(200, resp.getStatusCode()); + assertEquals("{\"s\":\"" + MessageController.UTF8_RESPONSE + "\"}", resp.getBody()); + } + + @MethodSource("data") + @ParameterizedTest + void springExceptionMapping_throw404Ex_expectMappedTo404(String reqType) { + initServletAppTest(reqType); + AwsProxyRequestBuilder req = new AwsProxyRequestBuilder("/ex/customstatus", "GET"); + AwsProxyResponse resp = handler.handleRequest(req, lambdaContext); + assertNotNull(resp); + assertEquals(404, resp.getStatusCode()); + } + + @MethodSource("data") + @ParameterizedTest + void echoMessage_populatesSingleValueHeadersForHttpApiV2(String reqType) { + initServletAppTest(reqType); + AwsProxyRequestBuilder req = new AwsProxyRequestBuilder("/message", "POST") + .header(HttpHeaders.CONTENT_TYPE, "application/json;v=1") + .header(HttpHeaders.ACCEPT, "application/json;v=1") + .body(new MessageData("test message")); + AwsProxyResponse resp = handler.handleRequest(req, lambdaContext); + if ("HTTP_API".equals(type)) { + assertNotNull(resp.getHeaders()); + } else { + assertNull(resp.getHeaders()); + } + } +} diff --git a/aws-serverless-java-container-springboot4/src/test/java/com/amazonaws/serverless/proxy/spring/SlowAppTest.java b/aws-serverless-java-container-springboot4/src/test/java/com/amazonaws/serverless/proxy/spring/SlowAppTest.java new file mode 100644 index 00000000..f5e83e85 --- /dev/null +++ b/aws-serverless-java-container-springboot4/src/test/java/com/amazonaws/serverless/proxy/spring/SlowAppTest.java @@ -0,0 +1,32 @@ +package com.amazonaws.serverless.proxy.spring; + +import com.amazonaws.serverless.proxy.internal.testutils.AwsProxyRequestBuilder; +import com.amazonaws.serverless.proxy.internal.testutils.MockLambdaContext; +import com.amazonaws.serverless.proxy.model.AwsProxyRequest; +import com.amazonaws.serverless.proxy.model.AwsProxyResponse; +import com.amazonaws.serverless.proxy.spring.slowapp.LambdaHandler; +import com.amazonaws.serverless.proxy.spring.slowapp.MessageController; +import com.amazonaws.serverless.proxy.spring.slowapp.SlowTestApplication; +import org.junit.jupiter.api.Test; + +import java.time.Instant; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class SlowAppTest { + + @Test + void slowAppInit_continuesInBackgroundThread_returnsCorrect() { + LambdaHandler slowApp = new LambdaHandler(); + System.out.println("Start time: " + slowApp.getConstructorTime()); + assertTrue(slowApp.getConstructorTime() < 10_000); + AwsProxyRequest req = new AwsProxyRequestBuilder("/hello", "GET").build(); + long startRequestTime = Instant.now().toEpochMilli(); + AwsProxyResponse resp = slowApp.handleRequest(req, new MockLambdaContext()); + long endRequestTime = Instant.now().toEpochMilli(); + assertTrue(endRequestTime - startRequestTime > SlowTestApplication.SlowDownInit.INIT_SLEEP_TIME_MS - 10_000); + assertEquals(200, resp.getStatusCode()); + assertEquals(MessageController.HELLO_MESSAGE, resp.getBody()); + } +} diff --git a/aws-serverless-java-container-springboot4/src/test/java/com/amazonaws/serverless/proxy/spring/SpringDelegatingLambdaContainerHandlerTests.java b/aws-serverless-java-container-springboot4/src/test/java/com/amazonaws/serverless/proxy/spring/SpringDelegatingLambdaContainerHandlerTests.java new file mode 100644 index 00000000..86fd8a96 --- /dev/null +++ b/aws-serverless-java-container-springboot4/src/test/java/com/amazonaws/serverless/proxy/spring/SpringDelegatingLambdaContainerHandlerTests.java @@ -0,0 +1,349 @@ +package com.amazonaws.serverless.proxy.spring; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.*; + +import com.amazonaws.serverless.exceptions.ContainerInitializationException; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.util.CollectionUtils; + +import com.amazonaws.serverless.proxy.spring.servletapp.MessageData; +import com.amazonaws.serverless.proxy.spring.servletapp.ServletApplication; +import com.amazonaws.serverless.proxy.spring.servletapp.UserData; +import tools.jackson.databind.ObjectMapper; + +import jakarta.ws.rs.core.HttpHeaders; + +@SuppressWarnings("rawtypes") +public class SpringDelegatingLambdaContainerHandlerTests { + + private static final String API_GATEWAY_EVENT = """ + { + "version": "1.0", + "resource": "$default", + "path": "/async", + "httpMethod": "POST", + "headers": { + "Content-Length": "45", + "Content-Type": "application/json", + "Host": "i76bfh111.execute-api.eu-west-3.amazonaws.com", + "User-Agent": "curl/7.79.1", + "X-Amzn-Trace-Id": "Root=1-64087690-2151375b219d3ba3389ea84e", + "X-Forwarded-For": "109.210.252.44", + "X-Forwarded-Port": "443", + "X-Forwarded-Proto": "https", + "accept": "*/*" + }, + "multiValueHeaders": { + "Content-Length": [ + "45" + ], + "Content-Type": [ + "application/json" + ], + "Host": [ + "i76bfhczs0.execute-api.eu-west-3.amazonaws.com" + ], + "User-Agent": [ + "curl/7.79.1" + ], + "X-Amzn-Trace-Id": [ + "Root=1-64087690-2151375b219d3ba3389ea84e" + ], + "X-Forwarded-For": [ + "109.210.252.44" + ], + "X-Forwarded-Port": [ + "443" + ], + "X-Forwarded-Proto": [ + "https" + ], + "accept": [ + "*/*" + ] + }, + "queryStringParameters": { + "abc": "xyz", + "name": "Ricky", + "foo": "baz" + }, + "multiValueQueryStringParameters": { + "abc": [ + "xyz" + ], + "name": [ + "Ricky" + ], + "foo": [ + "bar", + "baz" + ] + }, + "requestContext": { + "accountId": "123456789098", + "apiId": "i76bfhczs0", + "domainName": "i76bfhc111.execute-api.eu-west-3.amazonaws.com", + "domainPrefix": "i76bfhczs0", + "extendedRequestId": "Bdd2ngt5iGYEMIg=", + "httpMethod": "POST", + "identity": { + "accessKey": null, + "accountId": null, + "caller": null, + "cognitoAmr": null, + "cognitoAuthenticationProvider": null, + "cognitoAuthenticationType": null, + "cognitoIdentityId": null, + "cognitoIdentityPoolId": null, + "principalOrgId": null, + "sourceIp": "109.210.252.44", + "user": null, + "userAgent": "curl/7.79.1", + "userArn": null + }, + "path": "/pets", + "protocol": "HTTP/1.1", + "requestId": "Bdd2ngt5iGYEMIg=", + "requestTime": "08/Mar/2023:11:50:40 +0000", + "requestTimeEpoch": 1678276240455, + "resourceId": "$default", + "resourcePath": "$default", + "stage": "$default" + }, + "pathParameters": null, + "stageVariables": null, + "body": "{\\"name\\":\\"bob\\"}", + "isBase64Encoded": false + }"""; + + private static final String API_GATEWAY_EVENT_V2 = """ + { + "version": "2.0", + "routeKey": "$default", + "rawPath": "/my/path", + "rawQueryString": "parameter1=value1¶meter1=value2&name=Ricky¶meter2=value", + "cookies": [ + "cookie1", + "cookie2" + ], + "headers": { + "header1": "value1", + "header2": "value1,value2" + }, + "queryStringParameters": { + "parameter1": "value1,value2", + "name": "Ricky", + "parameter2": "value" + }, + "requestContext": { + "accountId": "123456789012", + "apiId": "api-id", + "authentication": { + "clientCert": { + "clientCertPem": "CERT_CONTENT", + "subjectDN": "www.example.com", + "issuerDN": "Example issuer", + "serialNumber": "a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1", + "validity": { + "notBefore": "May 28 12:30:02 2019 GMT", + "notAfter": "Aug 5 09:36:04 2021 GMT" + } + } + }, + "authorizer": { + "jwt": { + "claims": { + "claim1": "value1", + "claim2": "value2" + }, + "scopes": [ + "scope1", + "scope2" + ] + } + }, + "domainName": "id.execute-api.us-east-1.amazonaws.com", + "domainPrefix": "id", + "http": { + "method": "POST", + "path": "/my/path", + "protocol": "HTTP/1.1", + "sourceIp": "IP", + "userAgent": "agent" + }, + "requestId": "id", + "routeKey": "$default", + "stage": "$default", + "time": "12/Mar/2020:19:03:58 +0000", + "timeEpoch": 1583348638390 + }, + "body": "Hello from Lambda", + "pathParameters": { + "parameter1": "value1" + }, + "isBase64Encoded": false, + "stageVariables": { + "stageVariable1": "value1", + "stageVariable2": "value2" + } + }"""; + + private SpringDelegatingLambdaContainerHandler handler; + + private ObjectMapper mapper = new ObjectMapper(); + + public void initServletAppTest() throws ContainerInitializationException { + this.handler = new SpringDelegatingLambdaContainerHandler(ServletApplication.class); + } + + public static Collection data() { + return Arrays.asList(API_GATEWAY_EVENT, API_GATEWAY_EVENT_V2); + } + + @MethodSource("data") + @ParameterizedTest + public void validateComplesrequest(String jsonEvent) throws Exception { + initServletAppTest(); + InputStream targetStream = new ByteArrayInputStream(this.generateHttpRequest(jsonEvent, "POST", + "/foo/male/list/24", "{\"name\":\"bob\"}", false,null)); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + handler.handleRequest(targetStream, output, null); + Map result = mapper.readValue(output.toString(StandardCharsets.UTF_8), Map.class); + assertEquals(200, result.get("statusCode")); + String[] responseBody = ((String) result.get("body")).split("/"); + assertEquals("male", responseBody[0]); + assertEquals("24", responseBody[1]); + assertEquals("Ricky", responseBody[2]); + } + + @MethodSource("data") + @ParameterizedTest + public void testValidate400(String jsonEvent) throws Exception { + initServletAppTest(); + UserData ud = new UserData(); + InputStream targetStream = new ByteArrayInputStream(this.generateHttpRequest(jsonEvent, "POST", "/validate", mapper.writeValueAsString(ud),false, null)); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + handler.handleRequest(targetStream, output, null); + Map result = mapper.readValue(output.toString(StandardCharsets.UTF_8), Map.class); + assertEquals(400, result.get("statusCode")); + assertEquals("3", result.get("body")); + } + + @MethodSource("data") + @ParameterizedTest + public void testValidate200(String jsonEvent) throws Exception { + initServletAppTest(); + UserData ud = new UserData(); + ud.setFirstName("bob"); + ud.setLastName("smith"); + ud.setEmail("foo@bar.com"); + InputStream targetStream = new ByteArrayInputStream(this.generateHttpRequest(jsonEvent, "POST", "/validate", mapper.writeValueAsString(ud),false, null)); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + handler.handleRequest(targetStream, output, null); + Map result = mapper.readValue(output.toString(StandardCharsets.UTF_8), Map.class); + assertEquals(200, result.get("statusCode")); + assertEquals("VALID", result.get("body")); + } + + @MethodSource("data") + @ParameterizedTest + public void testValidate200Base64(String jsonEvent) throws Exception { + initServletAppTest(); + UserData ud = new UserData(); + ud.setFirstName("bob"); + ud.setLastName("smith"); + ud.setEmail("foo@bar.com"); + InputStream targetStream = new ByteArrayInputStream(this.generateHttpRequest(jsonEvent, "POST", "/validate", + Base64.getMimeEncoder().encodeToString(mapper.writeValueAsString(ud).getBytes()),true, null)); + + ByteArrayOutputStream output = new ByteArrayOutputStream(); + handler.handleRequest(targetStream, output, null); + Map result = mapper.readValue(output.toString(StandardCharsets.UTF_8), Map.class); + assertEquals(200, result.get("statusCode")); + assertEquals("VALID", result.get("body")); + } + + @MethodSource("data") + @ParameterizedTest + public void messageObject_parsesObject_returnsCorrectMessage(String jsonEvent) throws Exception { + initServletAppTest(); + InputStream targetStream = new ByteArrayInputStream(this.generateHttpRequest(jsonEvent, "POST", "/message", + mapper.writeValueAsString(new MessageData("test message")),false, null)); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + handler.handleRequest(targetStream, output, null); + Map result = mapper.readValue(output.toString(StandardCharsets.UTF_8), Map.class); + assertEquals(200, result.get("statusCode")); + assertEquals("test message", result.get("body")); + } + + @MethodSource("data") + @ParameterizedTest + public void voidPost_returns200(String jsonEvent) throws Exception { + initServletAppTest(); + InputStream targetStream = new ByteArrayInputStream(this.generateHttpRequest(jsonEvent, "POST", + "/void-post", "{\"key\":\"value\"}", false, null)); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + handler.handleRequest(targetStream, output, null); + Map result = mapper.readValue(output.toString(StandardCharsets.UTF_8), Map.class); + assertEquals(200, result.get("statusCode")); + } + + @SuppressWarnings({"unchecked" }) + @MethodSource("data") + @ParameterizedTest + void messageObject_propertiesInContentType_returnsCorrectMessage(String jsonEvent) throws Exception { + initServletAppTest(); + + Map headers = new HashMap<>(); + headers.put(HttpHeaders.CONTENT_TYPE, "application/json;v=1"); + headers.put(HttpHeaders.ACCEPT, "application/json;v=1"); + InputStream targetStream = new ByteArrayInputStream(this.generateHttpRequest(jsonEvent, "POST", "/message", + mapper.writeValueAsString(new MessageData("test message")),false, headers)); + + ByteArrayOutputStream output = new ByteArrayOutputStream(); + handler.handleRequest(targetStream, output, null); + Map result = mapper.readValue(output.toString(StandardCharsets.UTF_8), Map.class); + assertEquals("test message", result.get("body")); + } + + private byte[] generateHttpRequest(String jsonEvent, String method, String path, String body,boolean isBase64Encoded, Map headers) throws Exception { + Map requestMap = mapper.readValue(jsonEvent, Map.class); + if (requestMap.get("version").equals("2.0")) { + return generateHttpRequest2(requestMap, method, path, body, isBase64Encoded,headers); + } + return generateHttpRequest(requestMap, method, path, body,isBase64Encoded, headers); + } + + @SuppressWarnings({ "unchecked"}) + private byte[] generateHttpRequest(Map requestMap, String method, String path, String body,boolean isBase64Encoded, Map headers) throws Exception { + requestMap.put("path", path); + requestMap.put("httpMethod", method); + requestMap.put("body", body); + requestMap.put("isBase64Encoded", isBase64Encoded); + if (!CollectionUtils.isEmpty(headers)) { + requestMap.put("headers", headers); + } + return mapper.writeValueAsBytes(requestMap); + } + + @SuppressWarnings({ "unchecked"}) + private byte[] generateHttpRequest2(Map requestMap, String method, String path, String body,boolean isBase64Encoded, Map headers) throws Exception { + Map map = mapper.readValue(API_GATEWAY_EVENT_V2, Map.class); + Map http = (Map) ((Map) map.get("requestContext")).get("http"); + http.put("path", path); + http.put("method", method); + map.put("body", body); + map.put("isBase64Encoded", isBase64Encoded); + if (!CollectionUtils.isEmpty(headers)) { + map.put("headers", headers); + } + return mapper.writeValueAsBytes(map); + } +} diff --git a/aws-serverless-java-container-springboot4/src/test/java/com/amazonaws/serverless/proxy/spring/WebFluxAppTest.java b/aws-serverless-java-container-springboot4/src/test/java/com/amazonaws/serverless/proxy/spring/WebFluxAppTest.java new file mode 100644 index 00000000..cc7d3365 --- /dev/null +++ b/aws-serverless-java-container-springboot4/src/test/java/com/amazonaws/serverless/proxy/spring/WebFluxAppTest.java @@ -0,0 +1,68 @@ +package com.amazonaws.serverless.proxy.spring; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import java.util.Arrays; +import java.util.Collection; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +import com.amazonaws.serverless.proxy.internal.testutils.AwsProxyRequestBuilder; +import com.amazonaws.serverless.proxy.internal.testutils.MockLambdaContext; +import com.amazonaws.serverless.proxy.model.AwsProxyResponse; +import com.amazonaws.serverless.proxy.spring.webfluxapp.LambdaHandler; +import com.amazonaws.serverless.proxy.spring.webfluxapp.MessageController; +import com.amazonaws.serverless.proxy.spring.webfluxapp.MessageData; +import tools.jackson.core.JacksonException; + +public class WebFluxAppTest { + + LambdaHandler handler; + MockLambdaContext lambdaContext = new MockLambdaContext(); + + private String type; + + public static Collection data() { + return Arrays.asList(new Object[]{"API_GW", "ALB", "HTTP_API"}); + } + + public void initWebFluxAppTest(String reqType) { + type = reqType; + handler = new LambdaHandler(type); + } + + @MethodSource("data") + @ParameterizedTest + void helloRequest_respondsWithSingleMessage(String reqType) { + initWebFluxAppTest(reqType); + AwsProxyRequestBuilder req = new AwsProxyRequestBuilder("/single", "GET"); + AwsProxyResponse resp = handler.handleRequest(req, lambdaContext); + System.out.println(resp.getBody()); + assertEquals(MessageController.MESSAGE, resp.getBody()); + } + + @MethodSource("data") + @ParameterizedTest + void helloDoubleRequest_respondsWithDoubleMessage(String reqType) { + initWebFluxAppTest(reqType); + AwsProxyRequestBuilder req = new AwsProxyRequestBuilder("/double", "GET"); + AwsProxyResponse resp = handler.handleRequest(req, lambdaContext); + + assertEquals(MessageController.MESSAGE + MessageController.MESSAGE, resp.getBody()); + } + + @MethodSource("data") + @ParameterizedTest + void messageObject_parsesObject_returnsCorrectMessage(String reqType) throws JacksonException { + initWebFluxAppTest(reqType); + AwsProxyRequestBuilder req = new AwsProxyRequestBuilder("/message", "POST") + .json() + .body(new MessageData("test message")); + AwsProxyResponse resp = handler.handleRequest(req, lambdaContext); + assertNotNull(resp); + assertEquals(200, resp.getStatusCode()); + assertEquals("test message", resp.getBody()); + } +} diff --git a/aws-serverless-java-container-springboot4/src/test/java/com/amazonaws/serverless/proxy/spring/embedded/ServerlessServletEmbeddedServerFactoryTest.java b/aws-serverless-java-container-springboot4/src/test/java/com/amazonaws/serverless/proxy/spring/embedded/ServerlessServletEmbeddedServerFactoryTest.java new file mode 100644 index 00000000..5ffd4a31 --- /dev/null +++ b/aws-serverless-java-container-springboot4/src/test/java/com/amazonaws/serverless/proxy/spring/embedded/ServerlessServletEmbeddedServerFactoryTest.java @@ -0,0 +1,49 @@ +package com.amazonaws.serverless.proxy.spring.embedded; + +import com.amazonaws.serverless.exceptions.ContainerInitializationException; +import com.amazonaws.serverless.proxy.AwsProxyExceptionHandler; +import com.amazonaws.serverless.proxy.AwsProxySecurityContextWriter; +import com.amazonaws.serverless.proxy.InitializationWrapper; +import com.amazonaws.serverless.proxy.internal.servlet.AwsProxyHttpServletRequestReader; +import com.amazonaws.serverless.proxy.internal.servlet.AwsProxyHttpServletResponseWriter; +import com.amazonaws.serverless.proxy.model.AwsProxyRequest; +import com.amazonaws.serverless.proxy.model.AwsProxyResponse; +import com.amazonaws.serverless.proxy.spring.SpringBootLambdaContainerHandler; +import org.junit.jupiter.api.Test; +import org.springframework.boot.WebApplicationType; +import org.springframework.boot.web.servlet.ServletContextInitializer; + +import jakarta.servlet.ServletContext; +import jakarta.servlet.ServletException; + +import static org.junit.jupiter.api.Assertions.fail; + +public class ServerlessServletEmbeddedServerFactoryTest { + private SpringBootLambdaContainerHandler handler = new SpringBootLambdaContainerHandler<>( + AwsProxyRequest.class, + AwsProxyResponse.class, + new AwsProxyHttpServletRequestReader(), + new AwsProxyHttpServletResponseWriter(), + new AwsProxySecurityContextWriter(), + new AwsProxyExceptionHandler(), + null, + new InitializationWrapper(), + WebApplicationType.REACTIVE + ); + + public ServerlessServletEmbeddedServerFactoryTest() throws ContainerInitializationException { + } + + @Test + void getWebServer_callsInitializers() { + ServerlessServletEmbeddedServerFactory factory = new ServerlessServletEmbeddedServerFactory(); + factory.getWebServer(new ServletContextInitializer() { + @Override + public void onStartup(ServletContext servletContext) throws ServletException { + if (servletContext == null) { + fail("Null servlet context"); + } + } + }); + } +} diff --git a/aws-serverless-java-container-springboot4/src/test/java/com/amazonaws/serverless/proxy/spring/jpaapp/DatabaseConfig.java b/aws-serverless-java-container-springboot4/src/test/java/com/amazonaws/serverless/proxy/spring/jpaapp/DatabaseConfig.java new file mode 100644 index 00000000..aeef7c65 --- /dev/null +++ b/aws-serverless-java-container-springboot4/src/test/java/com/amazonaws/serverless/proxy/spring/jpaapp/DatabaseConfig.java @@ -0,0 +1,23 @@ +package com.amazonaws.serverless.proxy.spring.jpaapp; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.jdbc.datasource.DriverManagerDataSource; + +import javax.sql.DataSource; + +@Configuration +public class DatabaseConfig { + + @Bean + public DataSource dataSource() { + DriverManagerDataSource dataSource = new DriverManagerDataSource(); + dataSource.setDriverClassName("org.h2.Driver"); + dataSource.setUrl("jdbc:h2:mem:testdb"); + dataSource.setUsername("sa"); + dataSource.setPassword(""); + + return dataSource; + } +} + diff --git a/aws-serverless-java-container-springboot4/src/test/java/com/amazonaws/serverless/proxy/spring/jpaapp/JpaApplication.java b/aws-serverless-java-container-springboot4/src/test/java/com/amazonaws/serverless/proxy/spring/jpaapp/JpaApplication.java new file mode 100644 index 00000000..bc80c95a --- /dev/null +++ b/aws-serverless-java-container-springboot4/src/test/java/com/amazonaws/serverless/proxy/spring/jpaapp/JpaApplication.java @@ -0,0 +1,12 @@ +package com.amazonaws.serverless.proxy.spring.jpaapp; + +import org.springframework.beans.factory.InitializingBean; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.logging.LogLevel; +import org.springframework.boot.logging.LoggingSystem; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; + +@SpringBootApplication +@Import(MessageController.class) +public class JpaApplication {} diff --git a/aws-serverless-java-container-springboot4/src/test/java/com/amazonaws/serverless/proxy/spring/jpaapp/LambdaHandler.java b/aws-serverless-java-container-springboot4/src/test/java/com/amazonaws/serverless/proxy/spring/jpaapp/LambdaHandler.java new file mode 100644 index 00000000..0cf67c10 --- /dev/null +++ b/aws-serverless-java-container-springboot4/src/test/java/com/amazonaws/serverless/proxy/spring/jpaapp/LambdaHandler.java @@ -0,0 +1,59 @@ +package com.amazonaws.serverless.proxy.spring.jpaapp; + +import com.amazonaws.serverless.exceptions.ContainerInitializationException; +import com.amazonaws.serverless.proxy.InitializationWrapper; +import com.amazonaws.serverless.proxy.internal.testutils.AwsProxyRequestBuilder; +import com.amazonaws.serverless.proxy.model.AwsProxyRequest; +import com.amazonaws.serverless.proxy.model.AwsProxyResponse; +import com.amazonaws.serverless.proxy.model.HttpApiV2ProxyRequest; +import com.amazonaws.serverless.proxy.spring.SpringBootLambdaContainerHandler; +import com.amazonaws.serverless.proxy.spring.SpringBootProxyHandlerBuilder; +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; + +public class LambdaHandler implements RequestHandler { + private static SpringBootLambdaContainerHandler handler; + private static SpringBootLambdaContainerHandler httpApiHandler; + private String type; + + public LambdaHandler(String reqType) { + type = reqType; + try { + switch (type) { + case "API_GW": + case "ALB": + handler = new SpringBootProxyHandlerBuilder() + .defaultProxy() + .initializationWrapper(new InitializationWrapper()) + .servletApplication() + .springBootApplication(JpaApplication.class) + .buildAndInitialize(); + break; + case "HTTP_API": + httpApiHandler = new SpringBootProxyHandlerBuilder() + .defaultHttpApiV2Proxy() + .initializationWrapper(new InitializationWrapper()) + .servletApplication() + .springBootApplication(JpaApplication.class) + .buildAndInitialize(); + break; + } + } catch (ContainerInitializationException e) { + e.printStackTrace(); + } + } + + @Override + public AwsProxyResponse handleRequest(AwsProxyRequestBuilder awsProxyRequest, Context context) { + switch (type) { + case "API_GW": + return handler.proxy(awsProxyRequest.build(), context); + case "ALB": + return handler.proxy(awsProxyRequest.alb().build(), context); + case "HTTP_API": + return httpApiHandler.proxy(awsProxyRequest.toHttpApiV2Request(), context); + default: + throw new RuntimeException("Unknown request type: " + type); + } + } +} diff --git a/aws-serverless-java-container-springboot4/src/test/java/com/amazonaws/serverless/proxy/spring/jpaapp/MessageController.java b/aws-serverless-java-container-springboot4/src/test/java/com/amazonaws/serverless/proxy/spring/jpaapp/MessageController.java new file mode 100644 index 00000000..a8529226 --- /dev/null +++ b/aws-serverless-java-container-springboot4/src/test/java/com/amazonaws/serverless/proxy/spring/jpaapp/MessageController.java @@ -0,0 +1,31 @@ +package com.amazonaws.serverless.proxy.spring.jpaapp; + +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.ResponseBody; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.context.request.async.DeferredResult; +import java.util.Collections; +import java.util.Map; + +@RestController +public class MessageController { + + public static final String HELLO_MESSAGE = "Hello"; + + @RequestMapping(path="/hello", method=RequestMethod.GET, produces = {"text/plain"}) + public String hello() { + return HELLO_MESSAGE; + } + + @SuppressWarnings({ "unchecked", "rawtypes" }) + @RequestMapping(path = "/async", method = RequestMethod.POST) + @ResponseBody + public DeferredResult> asyncResult(@RequestBody Map value) { + DeferredResult result = new DeferredResult<>(); + result.setResult(Collections.singletonMap("name", value.get("name").toUpperCase())); + return result; + } + +} diff --git a/aws-serverless-java-container-springboot4/src/test/java/com/amazonaws/serverless/proxy/spring/securityapp/LambdaHandler.java b/aws-serverless-java-container-springboot4/src/test/java/com/amazonaws/serverless/proxy/spring/securityapp/LambdaHandler.java new file mode 100644 index 00000000..ae8ba21a --- /dev/null +++ b/aws-serverless-java-container-springboot4/src/test/java/com/amazonaws/serverless/proxy/spring/securityapp/LambdaHandler.java @@ -0,0 +1,25 @@ +package com.amazonaws.serverless.proxy.spring.securityapp; + +import com.amazonaws.serverless.exceptions.ContainerInitializationException; +import com.amazonaws.serverless.proxy.model.AwsProxyRequest; +import com.amazonaws.serverless.proxy.model.AwsProxyResponse; +import com.amazonaws.serverless.proxy.spring.SpringBootLambdaContainerHandler; +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; + +public class LambdaHandler implements RequestHandler { + private static SpringBootLambdaContainerHandler handler; + + static { + try { + handler = SpringBootLambdaContainerHandler.getAwsProxyHandler(SecurityApplication.class); + } catch (ContainerInitializationException e) { + e.printStackTrace(); + } + } + + @Override + public AwsProxyResponse handleRequest(AwsProxyRequest awsProxyRequest, Context context) { + return handler.proxy(awsProxyRequest, context); + } +} diff --git a/aws-serverless-java-container-springboot4/src/test/java/com/amazonaws/serverless/proxy/spring/securityapp/MessageController.java b/aws-serverless-java-container-springboot4/src/test/java/com/amazonaws/serverless/proxy/spring/securityapp/MessageController.java new file mode 100644 index 00000000..ad67d476 --- /dev/null +++ b/aws-serverless-java-container-springboot4/src/test/java/com/amazonaws/serverless/proxy/spring/securityapp/MessageController.java @@ -0,0 +1,17 @@ +package com.amazonaws.serverless.proxy.spring.securityapp; + +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.RestController; +import reactor.core.publisher.Mono; + + +@RestController +public class MessageController { + public static final String HELLO_MESSAGE = "Hello"; + + @RequestMapping(path="/hello", method=RequestMethod.GET, produces = {"text/plain"}) + public Mono hello() { + return Mono.just(HELLO_MESSAGE); + } +} diff --git a/aws-serverless-java-container-springboot4/src/test/java/com/amazonaws/serverless/proxy/spring/securityapp/SecurityApplication.java b/aws-serverless-java-container-springboot4/src/test/java/com/amazonaws/serverless/proxy/spring/securityapp/SecurityApplication.java new file mode 100644 index 00000000..cafcd400 --- /dev/null +++ b/aws-serverless-java-container-springboot4/src/test/java/com/amazonaws/serverless/proxy/spring/securityapp/SecurityApplication.java @@ -0,0 +1,14 @@ +package com.amazonaws.serverless.proxy.spring.securityapp; + +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Import; +import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity; +import org.springframework.web.reactive.config.EnableWebFlux; + +@SpringBootApplication +@EnableWebFluxSecurity +@EnableWebFlux +@Import(SecurityConfig.class) +public class SecurityApplication { +} diff --git a/aws-serverless-java-container-springboot4/src/test/java/com/amazonaws/serverless/proxy/spring/securityapp/SecurityConfig.java b/aws-serverless-java-container-springboot4/src/test/java/com/amazonaws/serverless/proxy/spring/securityapp/SecurityConfig.java new file mode 100644 index 00000000..497d2084 --- /dev/null +++ b/aws-serverless-java-container-springboot4/src/test/java/com/amazonaws/serverless/proxy/spring/securityapp/SecurityConfig.java @@ -0,0 +1,45 @@ +package com.amazonaws.serverless.proxy.spring.securityapp; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity; +import org.springframework.security.config.web.server.ServerHttpSecurity; +import org.springframework.security.core.userdetails.MapReactiveUserDetailsService; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.web.server.SecurityWebFilterChain; + +@Configuration +@EnableWebFluxSecurity +public class SecurityConfig +{ + public static final String USERNAME = "admin"; + public static final String PASSWORD = "{noop}password"; + private static BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder(); + + @Bean + public SecurityWebFilterChain securitygWebFilterChain( + ServerHttpSecurity http) { + return http.authorizeExchange(exchanges -> exchanges + .anyExchange().authenticated()) + .csrf(csrf -> csrf.disable()) + .httpBasic(httpBasic -> {}) + .build(); + } + + @Bean + public static BCryptPasswordEncoder passwordEncoder() { + return passwordEncoder; + } + + @Bean + public MapReactiveUserDetailsService userDetailsService() { + UserDetails user = User + .withUsername(USERNAME) + .password(passwordEncoder.encode(PASSWORD)) + .roles("USER") + .build(); + return new MapReactiveUserDetailsService(user); + } +} \ No newline at end of file diff --git a/aws-serverless-java-container-springboot4/src/test/java/com/amazonaws/serverless/proxy/spring/servletapp/LambdaHandler.java b/aws-serverless-java-container-springboot4/src/test/java/com/amazonaws/serverless/proxy/spring/servletapp/LambdaHandler.java new file mode 100644 index 00000000..88441988 --- /dev/null +++ b/aws-serverless-java-container-springboot4/src/test/java/com/amazonaws/serverless/proxy/spring/servletapp/LambdaHandler.java @@ -0,0 +1,60 @@ +package com.amazonaws.serverless.proxy.spring.servletapp; + +import com.amazonaws.serverless.exceptions.ContainerInitializationException; +import com.amazonaws.serverless.proxy.InitializationWrapper; +import com.amazonaws.serverless.proxy.internal.servlet.AwsProxyHttpServletRequest; +import com.amazonaws.serverless.proxy.internal.testutils.AwsProxyRequestBuilder; +import com.amazonaws.serverless.proxy.model.AwsProxyRequest; +import com.amazonaws.serverless.proxy.model.AwsProxyResponse; +import com.amazonaws.serverless.proxy.model.HttpApiV2ProxyRequest; +import com.amazonaws.serverless.proxy.spring.SpringBootLambdaContainerHandler; +import com.amazonaws.serverless.proxy.spring.SpringBootProxyHandlerBuilder; +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; + +public class LambdaHandler implements RequestHandler { + private static SpringBootLambdaContainerHandler handler; + private static SpringBootLambdaContainerHandler httpApiHandler; + private String type; + + public LambdaHandler(String reqType) { + type = reqType; + try { + switch (type) { + case "API_GW": + case "ALB": + handler = new SpringBootProxyHandlerBuilder() + .defaultProxy() + .initializationWrapper(new InitializationWrapper()) + .servletApplication() + .springBootApplication(ServletApplication.class) + .buildAndInitialize(); + break; + case "HTTP_API": + httpApiHandler = new SpringBootProxyHandlerBuilder() + .defaultHttpApiV2Proxy() + .initializationWrapper(new InitializationWrapper()) + .servletApplication() + .springBootApplication(ServletApplication.class) + .buildAndInitialize(); + break; + } + } catch (ContainerInitializationException e) { + e.printStackTrace(); + } + } + + @Override + public AwsProxyResponse handleRequest(AwsProxyRequestBuilder awsProxyRequest, Context context) { + switch (type) { + case "API_GW": + return handler.proxy(awsProxyRequest.build(), context); + case "ALB": + return handler.proxy(awsProxyRequest.alb().build(), context); + case "HTTP_API": + return httpApiHandler.proxy(awsProxyRequest.toHttpApiV2Request(), context); + default: + throw new RuntimeException("Unknown request type: " + type); + } + } +} diff --git a/aws-serverless-java-container-springboot4/src/test/java/com/amazonaws/serverless/proxy/spring/servletapp/LambdaStreamHandler.java b/aws-serverless-java-container-springboot4/src/test/java/com/amazonaws/serverless/proxy/spring/servletapp/LambdaStreamHandler.java new file mode 100644 index 00000000..fd7d71d7 --- /dev/null +++ b/aws-serverless-java-container-springboot4/src/test/java/com/amazonaws/serverless/proxy/spring/servletapp/LambdaStreamHandler.java @@ -0,0 +1,63 @@ +package com.amazonaws.serverless.proxy.spring.servletapp; + +import com.amazonaws.serverless.exceptions.ContainerInitializationException; +import com.amazonaws.serverless.proxy.InitializationWrapper; +import com.amazonaws.serverless.proxy.internal.testutils.AwsProxyRequestBuilder; +import com.amazonaws.serverless.proxy.model.AwsProxyRequest; +import com.amazonaws.serverless.proxy.model.AwsProxyResponse; +import com.amazonaws.serverless.proxy.model.HttpApiV2ProxyRequest; +import com.amazonaws.serverless.proxy.spring.SpringBootLambdaContainerHandler; +import com.amazonaws.serverless.proxy.spring.SpringBootProxyHandlerBuilder; +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; +import com.amazonaws.services.lambda.runtime.RequestStreamHandler; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +public class LambdaStreamHandler implements RequestStreamHandler { + private static SpringBootLambdaContainerHandler handler; + private static SpringBootLambdaContainerHandler httpApiHandler; + private String type; + + public LambdaStreamHandler(String reqType) { + type = reqType; + try { + switch (type) { + case "API_GW": + case "ALB": + handler = new SpringBootProxyHandlerBuilder() + .defaultProxy() + .initializationWrapper(new InitializationWrapper()) + .servletApplication() + .springBootApplication(ServletApplication.class) + .buildAndInitialize(); + break; + case "HTTP_API": + httpApiHandler = new SpringBootProxyHandlerBuilder() + .defaultHttpApiV2Proxy() + .initializationWrapper(new InitializationWrapper()) + .servletApplication() + .springBootApplication(ServletApplication.class) + .buildAndInitialize(); + break; + } + } catch (ContainerInitializationException e) { + e.printStackTrace(); + } + } + + @Override + public void handleRequest(InputStream inputStream, OutputStream outputStream, Context context) throws IOException { + switch (type) { + case "API_GW": + case "ALB": + handler.proxyStream(inputStream, outputStream, context); + break; + case "HTTP_API": + httpApiHandler.proxyStream(inputStream, outputStream, context); + } + + } +} diff --git a/aws-serverless-java-container-springboot4/src/test/java/com/amazonaws/serverless/proxy/spring/servletapp/MessageController.java b/aws-serverless-java-container-springboot4/src/test/java/com/amazonaws/serverless/proxy/spring/servletapp/MessageController.java new file mode 100644 index 00000000..1923396c --- /dev/null +++ b/aws-serverless-java-container-springboot4/src/test/java/com/amazonaws/serverless/proxy/spring/servletapp/MessageController.java @@ -0,0 +1,75 @@ +package com.amazonaws.serverless.proxy.spring.servletapp; + +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.Errors; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.context.request.async.DeferredResult; +import org.springframework.web.server.ResponseStatusException; + +import jakarta.validation.Valid; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +@RestController +public class MessageController { + public static final String HELLO_MESSAGE = "Hello"; + public static final String VALID_MESSAGE = "VALID"; + public static final String UTF8_RESPONSE = "öüäß фрыцшщ"; + public static final String EX_MESSAGE = "404 exception message"; + + @SuppressWarnings({ "unchecked", "rawtypes" }) + @RequestMapping(path = "/async", method = RequestMethod.POST) + @ResponseBody + public DeferredResult> asyncResult(@RequestBody Map value) { + DeferredResult result = new DeferredResult<>(); + result.setResult(Collections.singletonMap("name", value.get("name").toUpperCase())); + return result; + } + + @RequestMapping(path="/hello", method=RequestMethod.GET, produces = {"text/plain"}) + public String hello() { + return HELLO_MESSAGE; + } + + @RequestMapping(path="/validate", method=RequestMethod.POST, produces = {"text/plain"}) + public ResponseEntity validateBody(@RequestBody @Valid UserData userData, Errors errors) { + if (errors != null && errors.hasErrors()) { + return ResponseEntity.badRequest().body(errors.getErrorCount() + ""); + } + return ResponseEntity.ok(VALID_MESSAGE); + } + + @RequestMapping(path="/message", method = RequestMethod.POST) + public String returnMessage(@RequestBody MessageData data) { + if (data == null) { + throw new RuntimeException("No message data"); + } + return data.getMessage(); + } + + @RequestMapping(path="/echo/{message}", method=RequestMethod.GET) + public String returnPathMessage(@PathVariable(value="message") String message) { + return message; + } + + @GetMapping(value = "/content-type/utf8", produces = "text/plain") + public ResponseEntity getUtf8String() { + return ResponseEntity.ok(UTF8_RESPONSE); + } + + @GetMapping(value = "/content-type/jsonutf8", produces=MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity> getUtf8Json() { + Map resp = new HashMap(); + resp.put("s", UTF8_RESPONSE); + return ResponseEntity.ok(resp); + } + + @GetMapping(value = "/ex/customstatus") + public String throw404Exception() { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, EX_MESSAGE); + } +} diff --git a/aws-serverless-java-container-springboot4/src/test/java/com/amazonaws/serverless/proxy/spring/servletapp/MessageData.java b/aws-serverless-java-container-springboot4/src/test/java/com/amazonaws/serverless/proxy/spring/servletapp/MessageData.java new file mode 100644 index 00000000..129101cb --- /dev/null +++ b/aws-serverless-java-container-springboot4/src/test/java/com/amazonaws/serverless/proxy/spring/servletapp/MessageData.java @@ -0,0 +1,20 @@ +package com.amazonaws.serverless.proxy.spring.servletapp; + +public class MessageData { + private String message; + + public MessageData() { + } + + public MessageData(String m) { + setMessage(m); + } + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } +} diff --git a/aws-serverless-java-container-springboot4/src/test/java/com/amazonaws/serverless/proxy/spring/servletapp/ServletApplication.java b/aws-serverless-java-container-springboot4/src/test/java/com/amazonaws/serverless/proxy/spring/servletapp/ServletApplication.java new file mode 100644 index 00000000..8ec4d57f --- /dev/null +++ b/aws-serverless-java-container-springboot4/src/test/java/com/amazonaws/serverless/proxy/spring/servletapp/ServletApplication.java @@ -0,0 +1,29 @@ +package com.amazonaws.serverless.proxy.spring.servletapp; + +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@SpringBootApplication +@Import(MessageController.class) +@RestController +public class ServletApplication { + + @RequestMapping(path = "/foo/{gender}/list/{age}", method = RequestMethod.POST, produces = MediaType.APPLICATION_JSON_VALUE) + public String complexRequest( + @PathVariable("gender") String gender, + @PathVariable("age") String age, + @RequestParam("name") String name + ) { + return gender + "/" + age + "/" + name; + } + + @RequestMapping(path = "/void-post", method = RequestMethod.POST) + public void voidPost() { + } +} diff --git a/aws-serverless-java-container-springboot4/src/test/java/com/amazonaws/serverless/proxy/spring/servletapp/UserData.java b/aws-serverless-java-container-springboot4/src/test/java/com/amazonaws/serverless/proxy/spring/servletapp/UserData.java new file mode 100644 index 00000000..379291a3 --- /dev/null +++ b/aws-serverless-java-container-springboot4/src/test/java/com/amazonaws/serverless/proxy/spring/servletapp/UserData.java @@ -0,0 +1,50 @@ +package com.amazonaws.serverless.proxy.spring.servletapp; + + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public class UserData { + @NotBlank + private String firstName; + @NotBlank + private String lastName; + @NotNull @Email + private String email; + private String error; + + public UserData() { + + } + + public UserData(String err) { + error = err; + } + + public String getFirstName() { + return firstName; + } + + public void setFirstName(String firstName) { + this.firstName = firstName; + } + + public String getLastName() { + return lastName; + } + + public void setLastName(String lastName) { + this.lastName = lastName; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public String getError() { return error; } +} diff --git a/aws-serverless-java-container-springboot4/src/test/java/com/amazonaws/serverless/proxy/spring/slowapp/LambdaHandler.java b/aws-serverless-java-container-springboot4/src/test/java/com/amazonaws/serverless/proxy/spring/slowapp/LambdaHandler.java new file mode 100644 index 00000000..22f75e7a --- /dev/null +++ b/aws-serverless-java-container-springboot4/src/test/java/com/amazonaws/serverless/proxy/spring/slowapp/LambdaHandler.java @@ -0,0 +1,40 @@ +package com.amazonaws.serverless.proxy.spring.slowapp; + +import com.amazonaws.serverless.exceptions.ContainerInitializationException; +import com.amazonaws.serverless.proxy.internal.servlet.AwsProxyHttpServletRequest; +import com.amazonaws.serverless.proxy.model.AwsProxyRequest; +import com.amazonaws.serverless.proxy.model.AwsProxyResponse; +import com.amazonaws.serverless.proxy.spring.SpringBootLambdaContainerHandler; +import com.amazonaws.serverless.proxy.spring.SpringBootProxyHandlerBuilder; +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; + +import java.time.Instant; + +public class LambdaHandler implements RequestHandler { + private SpringBootLambdaContainerHandler handler; + private long constructorTime; + + public LambdaHandler() { + try { + long startTime = Instant.now().toEpochMilli(); + System.out.println("startCall: " + startTime); + handler = new SpringBootProxyHandlerBuilder() + .defaultProxy() + .springBootApplication(SlowTestApplication.class) + .buildAndInitialize(); + constructorTime = Instant.now().toEpochMilli() - startTime; + } catch (ContainerInitializationException e) { + e.printStackTrace(); + } + } + + public long getConstructorTime() { + return constructorTime; + } + + @Override + public AwsProxyResponse handleRequest(AwsProxyRequest awsProxyRequest, Context context) { + return handler.proxy(awsProxyRequest, context); + } +} diff --git a/aws-serverless-java-container-springboot4/src/test/java/com/amazonaws/serverless/proxy/spring/slowapp/MessageController.java b/aws-serverless-java-container-springboot4/src/test/java/com/amazonaws/serverless/proxy/spring/slowapp/MessageController.java new file mode 100644 index 00000000..098e8e7d --- /dev/null +++ b/aws-serverless-java-container-springboot4/src/test/java/com/amazonaws/serverless/proxy/spring/slowapp/MessageController.java @@ -0,0 +1,15 @@ +package com.amazonaws.serverless.proxy.spring.slowapp; + +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class MessageController { + public static final String HELLO_MESSAGE = "Hello"; + + @RequestMapping(path="/hello", method= RequestMethod.GET) + public String hello() { + return HELLO_MESSAGE; + } +} diff --git a/aws-serverless-java-container-springboot4/src/test/java/com/amazonaws/serverless/proxy/spring/slowapp/SlowTestApplication.java b/aws-serverless-java-container-springboot4/src/test/java/com/amazonaws/serverless/proxy/spring/slowapp/SlowTestApplication.java new file mode 100644 index 00000000..adb4bcb3 --- /dev/null +++ b/aws-serverless-java-container-springboot4/src/test/java/com/amazonaws/serverless/proxy/spring/slowapp/SlowTestApplication.java @@ -0,0 +1,21 @@ +package com.amazonaws.serverless.proxy.spring.slowapp; + +import org.springframework.beans.factory.InitializingBean; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.stereotype.Component; + +import java.time.Instant; + +@SpringBootApplication +public class SlowTestApplication { + + @Component + public static class SlowDownInit implements InitializingBean { + public static final int INIT_SLEEP_TIME_MS = 13_000; + + @Override + public void afterPropertiesSet() throws Exception { + Thread.sleep(INIT_SLEEP_TIME_MS); + } + } +} diff --git a/aws-serverless-java-container-springboot4/src/test/java/com/amazonaws/serverless/proxy/spring/webfluxapp/LambdaHandler.java b/aws-serverless-java-container-springboot4/src/test/java/com/amazonaws/serverless/proxy/spring/webfluxapp/LambdaHandler.java new file mode 100644 index 00000000..0eb52a7b --- /dev/null +++ b/aws-serverless-java-container-springboot4/src/test/java/com/amazonaws/serverless/proxy/spring/webfluxapp/LambdaHandler.java @@ -0,0 +1,58 @@ +package com.amazonaws.serverless.proxy.spring.webfluxapp; + +import com.amazonaws.serverless.exceptions.ContainerInitializationException; +import com.amazonaws.serverless.proxy.InitializationWrapper; +import com.amazonaws.serverless.proxy.internal.testutils.AwsProxyRequestBuilder; +import com.amazonaws.serverless.proxy.model.AwsProxyRequest; +import com.amazonaws.serverless.proxy.model.AwsProxyResponse; +import com.amazonaws.serverless.proxy.model.HttpApiV2ProxyRequest; +import com.amazonaws.serverless.proxy.spring.SpringBootLambdaContainerHandler; +import com.amazonaws.serverless.proxy.spring.SpringBootProxyHandlerBuilder; +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; + +public class LambdaHandler implements RequestHandler { + private static SpringBootLambdaContainerHandler handler; + private static SpringBootLambdaContainerHandler httpApiHandler; + + private String type; + + public LambdaHandler(String reqType) { + type = reqType; + try { + switch (type) { + case "API_GW": + case "ALB": + handler = new SpringBootProxyHandlerBuilder() + .defaultProxy() + .initializationWrapper(new InitializationWrapper()) + .springBootApplication(WebFluxTestApplication.class) + .buildAndInitialize(); + break; + case "HTTP_API": + httpApiHandler = new SpringBootProxyHandlerBuilder() + .defaultHttpApiV2Proxy() + .initializationWrapper(new InitializationWrapper()) + .springBootApplication(WebFluxTestApplication.class) + .buildAndInitialize(); + break; + } + } catch (ContainerInitializationException e) { + e.printStackTrace(); + } + } + + @Override + public AwsProxyResponse handleRequest(AwsProxyRequestBuilder awsProxyRequest, Context context) { + switch (type) { + case "API_GW": + return handler.proxy(awsProxyRequest.build(), context); + case "ALB": + return handler.proxy(awsProxyRequest.alb().build(), context); + case "HTTP_API": + return httpApiHandler.proxy(awsProxyRequest.toHttpApiV2Request(), context); + default: + throw new RuntimeException("Unknown request type: " + type); + } + } +} diff --git a/aws-serverless-java-container-springboot4/src/test/java/com/amazonaws/serverless/proxy/spring/webfluxapp/MessageController.java b/aws-serverless-java-container-springboot4/src/test/java/com/amazonaws/serverless/proxy/spring/webfluxapp/MessageController.java new file mode 100644 index 00000000..61e55a13 --- /dev/null +++ b/aws-serverless-java-container-springboot4/src/test/java/com/amazonaws/serverless/proxy/spring/webfluxapp/MessageController.java @@ -0,0 +1,38 @@ +package com.amazonaws.serverless.proxy.spring.webfluxapp; + +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.config.annotation.EnableWebMvc; +import reactor.core.publisher.Flux; + +@EnableAutoConfiguration +@RestController +public class MessageController { + public static final String MESSAGE = "Hello"; + + @RequestMapping(path="/single", method= RequestMethod.GET, produces = {"text/plain"}) + Flux singleMessage(){ + return Flux.just( + MESSAGE + ); + } + + @RequestMapping(path="/double", method= RequestMethod.GET, produces={"text/plain"}) + Flux doubleMessage(){ + return Flux.just( + MESSAGE, + MESSAGE + ); + } + + @RequestMapping(path="/message", method = RequestMethod.POST, produces={"text/plain"}, consumes = {"application/json"}) + public Flux returnMessage(@RequestBody MessageData data) { + if (data == null) { + throw new RuntimeException("No message data"); + } + return Flux.just(data.getMessage()); + } +} \ No newline at end of file diff --git a/aws-serverless-java-container-springboot4/src/test/java/com/amazonaws/serverless/proxy/spring/webfluxapp/MessageData.java b/aws-serverless-java-container-springboot4/src/test/java/com/amazonaws/serverless/proxy/spring/webfluxapp/MessageData.java new file mode 100644 index 00000000..2be6b4f2 --- /dev/null +++ b/aws-serverless-java-container-springboot4/src/test/java/com/amazonaws/serverless/proxy/spring/webfluxapp/MessageData.java @@ -0,0 +1,20 @@ +package com.amazonaws.serverless.proxy.spring.webfluxapp; + +public class MessageData { + private String message; + + public MessageData() { + } + + public MessageData(String m) { + setMessage(m); + } + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } +} diff --git a/aws-serverless-java-container-springboot4/src/test/java/com/amazonaws/serverless/proxy/spring/webfluxapp/WebFluxTestApplication.java b/aws-serverless-java-container-springboot4/src/test/java/com/amazonaws/serverless/proxy/spring/webfluxapp/WebFluxTestApplication.java new file mode 100644 index 00000000..891abd18 --- /dev/null +++ b/aws-serverless-java-container-springboot4/src/test/java/com/amazonaws/serverless/proxy/spring/webfluxapp/WebFluxTestApplication.java @@ -0,0 +1,14 @@ +package com.amazonaws.serverless.proxy.spring.webfluxapp; + +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Import; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.web.reactive.config.EnableWebFlux; +import org.springframework.web.servlet.config.annotation.EnableWebMvc; + +@SpringBootApplication +public class WebFluxTestApplication { + +} diff --git a/aws-serverless-jersey-archetype/pom.xml b/aws-serverless-jersey-archetype/pom.xml new file mode 100644 index 00000000..310041ca --- /dev/null +++ b/aws-serverless-jersey-archetype/pom.xml @@ -0,0 +1,81 @@ + + 4.0.0 + + + com.amazonaws.serverless + aws-serverless-java-container + 3.0.3-SNAPSHOT + + + com.amazonaws.serverless.archetypes + aws-serverless-jersey-archetype + 3.0.3-SNAPSHOT + maven-archetype + AWS Serverless Jersey Archetype + + + https://github.com/aws/serverless-java-container.git + HEAD + + + + + The Apache Software License, Version 2.0 + http://www.apache.org/licenses/LICENSE-2.0.txt + repo + + + + + + + src/main/resources + true + + archetype-resources/pom.xml + archetype-resources/README.md + + + + src/main/resources + false + + archetype-resources/pom.xml + + + + + + + org.apache.maven.archetype + archetype-packaging + 3.4.0 + + + + + + + org.apache.maven.plugins + maven-resources-plugin + 3.3.1 + + \ + + + + org.apache.maven.plugins + maven-archetype-plugin + 3.4.0 + + + + integration-test + + + + + + + + diff --git a/aws-serverless-jersey-archetype/src/main/resources/META-INF/maven/archetype-metadata.xml b/aws-serverless-jersey-archetype/src/main/resources/META-INF/maven/archetype-metadata.xml new file mode 100644 index 00000000..dc8cb620 --- /dev/null +++ b/aws-serverless-jersey-archetype/src/main/resources/META-INF/maven/archetype-metadata.xml @@ -0,0 +1,33 @@ + + + + src/main/java + + **/*.java + + + + src/test/java + + **/*.java + + + + src/assembly + + * + + + + + + template.yml + README.md + build.gradle + + + + \ No newline at end of file diff --git a/aws-serverless-jersey-archetype/src/main/resources/archetype-resources/README.md b/aws-serverless-jersey-archetype/src/main/resources/archetype-resources/README.md new file mode 100644 index 00000000..311c40ae --- /dev/null +++ b/aws-serverless-jersey-archetype/src/main/resources/archetype-resources/README.md @@ -0,0 +1,99 @@ +#set($resourceName = $artifactId) +#macro(replaceChar $originalName, $char) + #if($originalName.contains($char)) + #set($tokens = $originalName.split($char)) + #set($newResourceName = "") + #foreach($token in $tokens) + #set($newResourceName = $newResourceName + $token.substring(0,1).toUpperCase() + $token.substring(1).toLowerCase()) + #end + ${newResourceName} + #else + #set($newResourceName = $originalName.substring(0,1).toUpperCase() + $originalName.substring(1)) + ${newResourceName} + #end +#end +#set($resourceName = "#replaceChar($resourceName, '-')") +#set($resourceName = "#replaceChar($resourceName, '.')") +#set($resourceName = $resourceName.replaceAll("\n", "").trim()) +# \${artifactId} serverless API +The \${artifactId} project, created with [`aws-serverless-java-container`](https://github.com/aws/serverless-java-container). + +The starter project defines a simple `/ping` resource that can accept `GET` requests with its tests. + +The project folder also includes a `template.yml` file. You can use this [SAM](https://github.com/awslabs/serverless-application-model) file to deploy the project to AWS Lambda and Amazon API Gateway or test in local with the [SAM CLI](https://github.com/awslabs/aws-sam-cli). + +#[[##]]# Pre-requisites +* [AWS CLI](https://aws.amazon.com/cli/) +* [SAM CLI](https://github.com/awslabs/aws-sam-cli) +* [Gradle](https://gradle.org/) or [Maven](https://maven.apache.org/) + +#[[##]]# Building the project +You can use the SAM CLI to quickly build the project +```bash +$ mvn archetype:generate -DartifactId=\${artifactId} -DarchetypeGroupId=com.amazonaws.serverless.archetypes -DarchetypeArtifactId=aws-serverless-jersey-archetype -DarchetypeVersion=${project.version} -DgroupId=\${groupId} -Dversion=\${version} -Dinteractive=false +$ cd \${artifactId} +$ sam build +Building resource '\${resourceName}Function' +Running JavaGradleWorkflow:GradleBuild +Running JavaGradleWorkflow:CopyArtifacts + +Build Succeeded + +Built Artifacts : .aws-sam/build +Built Template : .aws-sam/build/template.yaml + +Commands you can use next +========================= +[*] Invoke Function: sam local invoke +[*] Deploy: sam deploy --guided +``` + +#[[##]]# Testing locally with the SAM CLI + +From the project root folder - where the `template.yml` file is located - start the API with the SAM CLI. + +```bash +$ sam local start-api + +... +Mounting ${groupId}.StreamLambdaHandler::handleRequest (java11) at http://127.0.0.1:3000/{proxy+} [OPTIONS GET HEAD POST PUT DELETE PATCH] +... +``` + +Using a new shell, you can send a test ping request to your API: + +```bash +$ curl -s http://127.0.0.1:3000/ping | python -m json.tool + +{ + "pong": "Hello, World!" +} +``` + +#[[##]]# Deploying to AWS +To deploy the application in your AWS account, you can use the SAM CLI's guided deployment process and follow the instructions on the screen + +``` +$ sam deploy --guided +``` + +Once the deployment is completed, the SAM CLI will print out the stack's outputs, including the new application URL. You can use `curl` or a web browser to make a call to the URL + +``` +... +------------------------------------------------------------------------------------------------------------- +OutputKey-Description OutputValue +------------------------------------------------------------------------------------------------------------- +\${resourceName}Api - URL for application https://xxxxxxxxxx.execute-api.us-west-2.amazonaws.com/Prod/pets +------------------------------------------------------------------------------------------------------------- +``` + +Copy the `OutputValue` into a browser or use curl to test your first request: + +```bash +$ curl -s https://xxxxxxx.execute-api.us-west-2.amazonaws.com/Prod/ping | python -m json.tool + +{ + "pong": "Hello, World!" +} +``` diff --git a/aws-serverless-jersey-archetype/src/main/resources/archetype-resources/build.gradle b/aws-serverless-jersey-archetype/src/main/resources/archetype-resources/build.gradle new file mode 100644 index 00000000..34ddfa29 --- /dev/null +++ b/aws-serverless-jersey-archetype/src/main/resources/archetype-resources/build.gradle @@ -0,0 +1,43 @@ +apply plugin: 'java' + +repositories { + mavenLocal() + mavenCentral() +} + +dependencies { + implementation ( + 'com.amazonaws.serverless:aws-serverless-java-container-jersey:[2.0-SNAPSHOT,)', + 'com.fasterxml.jackson.core:jackson-databind:2.19.1', + ) + + implementation("org.glassfish.jersey.media:jersey-media-json-jackson:3.1.10") { + exclude group: 'com.fasterxml.jackson.core', module: "jackson-annotations" + exclude group: 'com.fasterxml.jackson.core', module: "jackson-databind" + exclude group: 'com.fasterxml.jackson.core', module: "jackson-core" + } + + implementation("org.glassfish.jersey.inject:jersey-hk2:3.1.10") { + exclude group: 'javax.inject', module: "javax.inject" + } + + testImplementation("com.amazonaws.serverless:aws-serverless-java-container-core:[2.0-SNAPSHOT,):tests") + testImplementation("org.apache.httpcomponents.client5:httpclient5:5.5") + testImplementation(platform("org.junit:junit-bom:5.13.1")) + testImplementation("org.junit.jupiter:junit-jupiter") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") +} + +task buildZip(type: Zip) { + from compileJava + from processResources + into('lib') { + from configurations.compileClasspath + } +} + +test { + useJUnitPlatform() +} + +build.dependsOn buildZip diff --git a/aws-serverless-jersey-archetype/src/main/resources/archetype-resources/pom.xml b/aws-serverless-jersey-archetype/src/main/resources/archetype-resources/pom.xml new file mode 100644 index 00000000..6f51a9c4 --- /dev/null +++ b/aws-serverless-jersey-archetype/src/main/resources/archetype-resources/pom.xml @@ -0,0 +1,197 @@ +#set($dollar = '$') + + 4.0.0 + + \${groupId} + \${artifactId} + \${version} + jar + + Serverless Jersey API + https://github.com/aws/serverless-java-container + + + 1.8 + 1.8 + + 3.1.10 + 3.0.2 + 5.12.1 + + + + + com.amazonaws.serverless + aws-serverless-java-container-jersey + ${project.version} + + + com.amazonaws.serverless + aws-serverless-java-container-core + ${project.version} + tests + test-jar + test + + + org.apache.httpcomponents.client5 + httpclient5 + 5.4.3 + test + + + + org.glassfish.jersey.media + jersey-media-json-jackson + \${jersey.version} + + + com.fasterxml.jackson.core + jackson-annotations + + + tools.jackson.core + jackson-databind + + + tools.jackson.core + jackson-core + + + + + + tools.jackson.core + jackson-databind + \${jackson.version} + + + + org.glassfish.jersey.inject + jersey-hk2 + \${jersey.version} + + + + javax.inject + javax.inject + + + + + + org.junit.jupiter + junit-jupiter + test + + + + + + + org.junit + junit-bom + ${junit.version} + import + pom + + + + + + + shaded-jar + + + + org.apache.maven.plugins + maven-shade-plugin + 3.6.0 + + false + + + + package + + shade + + + + + + + + + assembly-zip + + true + + + + + + org.apache.maven.plugins + maven-jar-plugin + 3.4.2 + + + default-jar + none + + + + + org.apache.maven.plugins + maven-install-plugin + 3.1.2 + + true + + + + + org.apache.maven.plugins + maven-dependency-plugin + 3.8.1 + + + copy-dependencies + package + + copy-dependencies + + + ${dollar}{project.build.directory}${dollar}{file.separator}lib + runtime + + + + + + org.apache.maven.plugins + maven-assembly-plugin + 3.7.1 + + + zip-assembly + package + + single + + + ${dollar}{project.artifactId}-${dollar}{project.version} + + src${dollar}{file.separator}assembly${dollar}{file.separator}bin.xml + + false + + + + + + + + + diff --git a/aws-serverless-jersey-archetype/src/main/resources/archetype-resources/src/assembly/bin.xml b/aws-serverless-jersey-archetype/src/main/resources/archetype-resources/src/assembly/bin.xml new file mode 100644 index 00000000..1ffd82d1 --- /dev/null +++ b/aws-serverless-jersey-archetype/src/main/resources/archetype-resources/src/assembly/bin.xml @@ -0,0 +1,24 @@ + + lambda-package + + zip + + false + + + + ${project.build.directory}${file.separator}lib + lib + + + + ${project.build.directory}${file.separator}classes + + ** + + ${file.separator} + + + \ No newline at end of file diff --git a/aws-serverless-jersey-archetype/src/main/resources/archetype-resources/src/main/java/StreamLambdaHandler.java b/aws-serverless-jersey-archetype/src/main/resources/archetype-resources/src/main/java/StreamLambdaHandler.java new file mode 100644 index 00000000..a4e2251e --- /dev/null +++ b/aws-serverless-jersey-archetype/src/main/resources/archetype-resources/src/main/java/StreamLambdaHandler.java @@ -0,0 +1,40 @@ +package ${groupId}; + +import com.amazonaws.serverless.proxy.jersey.JerseyLambdaContainerHandler; +import com.amazonaws.serverless.proxy.model.AwsProxyRequest; +import com.amazonaws.serverless.proxy.model.AwsProxyResponse; +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestStreamHandler; + +import org.glassfish.jersey.jackson.JacksonFeature; +import org.glassfish.jersey.server.ResourceConfig; +import org.glassfish.jersey.server.ServerProperties; + +import tools.jackson.core.JacksonException; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +import ${groupId}.resource.PingResource; + + +public class StreamLambdaHandler implements RequestStreamHandler { + private static final ResourceConfig jerseyApplication = new ResourceConfig() + // properties to speed up Jersey start time + .property(ServerProperties.FEATURE_AUTO_DISCOVERY_DISABLE,true) + .property(ServerProperties.WADL_FEATURE_DISABLE,true) + .property(ServerProperties.METAINF_SERVICES_LOOKUP_DISABLE,true) + .property(ServerProperties.BV_FEATURE_DISABLE,true) + .property(ServerProperties.JSON_PROCESSING_FEATURE_DISABLE,true) + .property(ServerProperties.MOXY_JSON_FEATURE_DISABLE,true) + .register(PingResource.class) + .register(JacksonFeature.class); + private static final JerseyLambdaContainerHandler handler + = JerseyLambdaContainerHandler.getAwsProxyHandler(jerseyApplication); + + @Override + public void handleRequest(InputStream inputStream, OutputStream outputStream, Context context) + throws IOException { + handler.proxyStream(inputStream, outputStream, context); + } +} \ No newline at end of file diff --git a/aws-serverless-jersey-archetype/src/main/resources/archetype-resources/src/main/java/resource/PingResource.java b/aws-serverless-jersey-archetype/src/main/resources/archetype-resources/src/main/java/resource/PingResource.java new file mode 100644 index 00000000..1c56254f --- /dev/null +++ b/aws-serverless-jersey-archetype/src/main/resources/archetype-resources/src/main/java/resource/PingResource.java @@ -0,0 +1,26 @@ +package ${groupId}.resource; + + +import java.util.Map; +import java.util.HashMap; + +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; + +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; + +@Path("/ping") +public class PingResource { + + @GET + @Produces(MediaType.APPLICATION_JSON) + @Consumes(MediaType.WILDCARD) + public Response ping() { + Map pong = new HashMap<>(); + pong.put("pong", "Hello, World!"); + return Response.status(200).entity(pong).build(); + } +} diff --git a/aws-serverless-jersey-archetype/src/main/resources/archetype-resources/src/test/java/StreamLambdaHandlerTest.java b/aws-serverless-jersey-archetype/src/main/resources/archetype-resources/src/test/java/StreamLambdaHandlerTest.java new file mode 100644 index 00000000..a247daed --- /dev/null +++ b/aws-serverless-jersey-archetype/src/main/resources/archetype-resources/src/test/java/StreamLambdaHandlerTest.java @@ -0,0 +1,91 @@ +package ${groupId}; + + +import com.amazonaws.serverless.proxy.internal.LambdaContainerHandler; +import com.amazonaws.serverless.proxy.internal.testutils.AwsProxyRequestBuilder; +import com.amazonaws.serverless.proxy.internal.testutils.MockLambdaContext; +import com.amazonaws.serverless.proxy.model.AwsProxyResponse; +import com.amazonaws.services.lambda.runtime.Context; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import jakarta.ws.rs.HttpMethod; +import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; + +import java.io.ByteArrayOutputStream; +import tools.jackson.core.JacksonException; +import java.io.IOException; +import java.io.InputStream; + +import static org.junit.jupiter.api.Assertions.*; + + +public class StreamLambdaHandlerTest { + + private static StreamLambdaHandler handler; + private static Context lambdaContext; + + @BeforeAll + public static void setUp() { + handler = new StreamLambdaHandler(); + lambdaContext = new MockLambdaContext(); + } + + @Test + public void ping_streamRequest_respondsWithHello() { + InputStream requestStream = new AwsProxyRequestBuilder("/ping", HttpMethod.GET) + .header(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON) + .buildStream(); + ByteArrayOutputStream responseStream = new ByteArrayOutputStream(); + + handle(requestStream, responseStream); + + AwsProxyResponse response = readResponse(responseStream); + assertNotNull(response); + assertEquals(Response.Status.OK.getStatusCode(), response.getStatusCode()); + + assertFalse(response.isBase64Encoded()); + + assertTrue(response.getBody().contains("pong")); + assertTrue(response.getBody().contains("Hello, World!")); + + assertTrue(response.getMultiValueHeaders().containsKey(HttpHeaders.CONTENT_TYPE)); + assertTrue(response.getMultiValueHeaders().getFirst(HttpHeaders.CONTENT_TYPE).startsWith(MediaType.APPLICATION_JSON)); + } + + @Test + public void invalidResource_streamRequest_responds404() { + InputStream requestStream = new AwsProxyRequestBuilder("/pong", HttpMethod.GET) + .header(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON) + .buildStream(); + ByteArrayOutputStream responseStream = new ByteArrayOutputStream(); + + handle(requestStream, responseStream); + + AwsProxyResponse response = readResponse(responseStream); + assertNotNull(response); + assertEquals(Response.Status.NOT_FOUND.getStatusCode(), response.getStatusCode()); + } + + private void handle(InputStream is, ByteArrayOutputStream os) { + try { + handler.handleRequest(is, os, lambdaContext); + } catch (IOException e) { + e.printStackTrace(); + fail(e.getMessage()); + } + } + + private AwsProxyResponse readResponse(ByteArrayOutputStream responseStream) { + try { + return LambdaContainerHandler.getObjectMapper().readValue(responseStream.toByteArray(), AwsProxyResponse.class); + } catch (JacksonException e) { + e.printStackTrace(); + fail("Error while parsing response: " + e.getMessage()); + } + return null; + } +} diff --git a/aws-serverless-jersey-archetype/src/main/resources/archetype-resources/template.yml b/aws-serverless-jersey-archetype/src/main/resources/archetype-resources/template.yml new file mode 100644 index 00000000..0ee7360d --- /dev/null +++ b/aws-serverless-jersey-archetype/src/main/resources/archetype-resources/template.yml @@ -0,0 +1,52 @@ +#set($resourceName = $artifactId) +#macro(replaceChar $originalName, $char) + #if($originalName.contains($char)) + #set($tokens = $originalName.split($char)) + #set($newResourceName = "") + #foreach($token in $tokens) + #set($newResourceName = $newResourceName + $token.substring(0,1).toUpperCase() + $token.substring(1).toLowerCase()) + #end + ${newResourceName} + #else + #set($newResourceName = $originalName.substring(0,1).toUpperCase() + $originalName.substring(1)) + ${newResourceName} + #end +#end +#set($resourceName = "#replaceChar($resourceName, '-')") +#set($resourceName = "#replaceChar($resourceName, '.')") +#set($resourceName = $resourceName.replaceAll("\n", "").trim()) +#macro(regionVar) + AWS::Region +#end +#set($awsRegion = "#regionVar()") +#set($awsRegion = $awsRegion.replaceAll("\n", "").trim()) +AWSTemplateFormatVersion: '2010-09-09' +Transform: AWS::Serverless-2016-10-31 +Description: AWS Serverless Jersey API - ${groupId}::${artifactId} +Globals: + Api: + EndpointConfiguration: REGIONAL + +Resources: + ${resourceName}Function: + Type: AWS::Serverless::Function + Properties: + Handler: ${groupId}.StreamLambdaHandler::handleRequest + Runtime: java21 + CodeUri: . + MemorySize: 512 + Policies: AWSLambdaBasicExecutionRole + Timeout: 15 + Events: + ProxyResource: + Type: Api + Properties: + Path: /{proxy+} + Method: any + +Outputs: + ${resourceName}Api: + Description: URL for application + Value: !Sub 'https://${ServerlessRestApi}.execute-api.${${awsRegion}}.amazonaws.com/Prod/ping' + Export: + Name: ${resourceName}Api diff --git a/aws-serverless-jersey-archetype/src/test/resources/projects/base/archetype.properties b/aws-serverless-jersey-archetype/src/test/resources/projects/base/archetype.properties new file mode 100644 index 00000000..06e7362c --- /dev/null +++ b/aws-serverless-jersey-archetype/src/test/resources/projects/base/archetype.properties @@ -0,0 +1,3 @@ +groupId=test.service +artifactId=jersey-archetype-test +version=1.0-SNAPSHOT diff --git a/aws-serverless-jersey-archetype/src/test/resources/projects/base/goal.txt b/aws-serverless-jersey-archetype/src/test/resources/projects/base/goal.txt new file mode 100644 index 00000000..597acc76 --- /dev/null +++ b/aws-serverless-jersey-archetype/src/test/resources/projects/base/goal.txt @@ -0,0 +1 @@ +package \ No newline at end of file diff --git a/aws-serverless-spring-archetype/pom.xml b/aws-serverless-spring-archetype/pom.xml new file mode 100644 index 00000000..669f5c20 --- /dev/null +++ b/aws-serverless-spring-archetype/pom.xml @@ -0,0 +1,82 @@ + + 4.0.0 + + + com.amazonaws.serverless + aws-serverless-java-container + 3.0.3-SNAPSHOT + + + com.amazonaws.serverless.archetypes + aws-serverless-spring-archetype + 3.0.3-SNAPSHOT + maven-archetype + AWS Serverless Spring Archetype + + + https://github.com/aws/serverless-java-container.git + HEAD + + + + + The Apache Software License, Version 2.0 + http://www.apache.org/licenses/LICENSE-2.0.txt + repo + + + + + + + src/main/resources + true + + archetype-resources/pom.xml + archetype-resources/README.md + + + + src/main/resources + false + + archetype-resources/pom.xml + + + + + + + org.apache.maven.archetype + archetype-packaging + 3.4.0 + + + + + + + org.apache.maven.plugins + maven-resources-plugin + 3.3.1 + + \ + + + + + org.apache.maven.plugins + maven-archetype-plugin + 3.4.0 + + + + integration-test + + + + + + + + diff --git a/aws-serverless-spring-archetype/src/main/resources/META-INF/maven/archetype-metadata.xml b/aws-serverless-spring-archetype/src/main/resources/META-INF/maven/archetype-metadata.xml new file mode 100644 index 00000000..e279efa5 --- /dev/null +++ b/aws-serverless-spring-archetype/src/main/resources/META-INF/maven/archetype-metadata.xml @@ -0,0 +1,39 @@ + + + + src/main/java + + **/*.java + + + + src/test/java + + **/*.java + + + + src/main/resources + + **/*.xml + + + + src/assembly + + * + + + + + + template.yml + README.md + build.gradle + + + + \ No newline at end of file diff --git a/aws-serverless-spring-archetype/src/main/resources/archetype-resources/README.md b/aws-serverless-spring-archetype/src/main/resources/archetype-resources/README.md new file mode 100644 index 00000000..311c40ae --- /dev/null +++ b/aws-serverless-spring-archetype/src/main/resources/archetype-resources/README.md @@ -0,0 +1,99 @@ +#set($resourceName = $artifactId) +#macro(replaceChar $originalName, $char) + #if($originalName.contains($char)) + #set($tokens = $originalName.split($char)) + #set($newResourceName = "") + #foreach($token in $tokens) + #set($newResourceName = $newResourceName + $token.substring(0,1).toUpperCase() + $token.substring(1).toLowerCase()) + #end + ${newResourceName} + #else + #set($newResourceName = $originalName.substring(0,1).toUpperCase() + $originalName.substring(1)) + ${newResourceName} + #end +#end +#set($resourceName = "#replaceChar($resourceName, '-')") +#set($resourceName = "#replaceChar($resourceName, '.')") +#set($resourceName = $resourceName.replaceAll("\n", "").trim()) +# \${artifactId} serverless API +The \${artifactId} project, created with [`aws-serverless-java-container`](https://github.com/aws/serverless-java-container). + +The starter project defines a simple `/ping` resource that can accept `GET` requests with its tests. + +The project folder also includes a `template.yml` file. You can use this [SAM](https://github.com/awslabs/serverless-application-model) file to deploy the project to AWS Lambda and Amazon API Gateway or test in local with the [SAM CLI](https://github.com/awslabs/aws-sam-cli). + +#[[##]]# Pre-requisites +* [AWS CLI](https://aws.amazon.com/cli/) +* [SAM CLI](https://github.com/awslabs/aws-sam-cli) +* [Gradle](https://gradle.org/) or [Maven](https://maven.apache.org/) + +#[[##]]# Building the project +You can use the SAM CLI to quickly build the project +```bash +$ mvn archetype:generate -DartifactId=\${artifactId} -DarchetypeGroupId=com.amazonaws.serverless.archetypes -DarchetypeArtifactId=aws-serverless-jersey-archetype -DarchetypeVersion=${project.version} -DgroupId=\${groupId} -Dversion=\${version} -Dinteractive=false +$ cd \${artifactId} +$ sam build +Building resource '\${resourceName}Function' +Running JavaGradleWorkflow:GradleBuild +Running JavaGradleWorkflow:CopyArtifacts + +Build Succeeded + +Built Artifacts : .aws-sam/build +Built Template : .aws-sam/build/template.yaml + +Commands you can use next +========================= +[*] Invoke Function: sam local invoke +[*] Deploy: sam deploy --guided +``` + +#[[##]]# Testing locally with the SAM CLI + +From the project root folder - where the `template.yml` file is located - start the API with the SAM CLI. + +```bash +$ sam local start-api + +... +Mounting ${groupId}.StreamLambdaHandler::handleRequest (java11) at http://127.0.0.1:3000/{proxy+} [OPTIONS GET HEAD POST PUT DELETE PATCH] +... +``` + +Using a new shell, you can send a test ping request to your API: + +```bash +$ curl -s http://127.0.0.1:3000/ping | python -m json.tool + +{ + "pong": "Hello, World!" +} +``` + +#[[##]]# Deploying to AWS +To deploy the application in your AWS account, you can use the SAM CLI's guided deployment process and follow the instructions on the screen + +``` +$ sam deploy --guided +``` + +Once the deployment is completed, the SAM CLI will print out the stack's outputs, including the new application URL. You can use `curl` or a web browser to make a call to the URL + +``` +... +------------------------------------------------------------------------------------------------------------- +OutputKey-Description OutputValue +------------------------------------------------------------------------------------------------------------- +\${resourceName}Api - URL for application https://xxxxxxxxxx.execute-api.us-west-2.amazonaws.com/Prod/pets +------------------------------------------------------------------------------------------------------------- +``` + +Copy the `OutputValue` into a browser or use curl to test your first request: + +```bash +$ curl -s https://xxxxxxx.execute-api.us-west-2.amazonaws.com/Prod/ping | python -m json.tool + +{ + "pong": "Hello, World!" +} +``` diff --git a/aws-serverless-spring-archetype/src/main/resources/archetype-resources/build.gradle b/aws-serverless-spring-archetype/src/main/resources/archetype-resources/build.gradle new file mode 100644 index 00000000..9ba70135 --- /dev/null +++ b/aws-serverless-spring-archetype/src/main/resources/archetype-resources/build.gradle @@ -0,0 +1,39 @@ +apply plugin: 'java' + +repositories { + mavenLocal() + mavenCentral() +} + +dependencies { + implementation ( + 'org.springframework:spring-webmvc:6.2.8', + 'org.springframework:spring-context:6.2.8', + 'com.amazonaws.serverless:aws-serverless-java-container-spring:[2.0-SNAPSHOT,)', + 'org.apache.logging.log4j:log4j-core:2.24.3', + 'org.apache.logging.log4j:log4j-api:2.24.3', + 'org.apache.logging.log4j:log4j-slf4j-impl:2.24.3', + 'com.fasterxml.jackson.core:jackson-databind:2.19.1', + 'com.amazonaws:aws-lambda-java-log4j2:1.6.0', + ) + + testImplementation("com.amazonaws.serverless:aws-serverless-java-container-core:[2.0-SNAPSHOT,):tests") + testImplementation("org.apache.httpcomponents.client5:httpclient5:5.5") + testImplementation(platform("org.junit:junit-bom:5.13.1")) + testImplementation("org.junit.jupiter:junit-jupiter") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") +} + +task buildZip(type: Zip) { + from compileJava + from processResources + into('lib') { + from(configurations.compileClasspath) + } +} + +test { + useJUnitPlatform() +} + +build.dependsOn buildZip diff --git a/aws-serverless-spring-archetype/src/main/resources/archetype-resources/pom.xml b/aws-serverless-spring-archetype/src/main/resources/archetype-resources/pom.xml new file mode 100644 index 00000000..3a03375c --- /dev/null +++ b/aws-serverless-spring-archetype/src/main/resources/archetype-resources/pom.xml @@ -0,0 +1,220 @@ +#set($dollar = '$') + + + 4.0.0 + + \${groupId} + \${artifactId} + \${version} + jar + + Serverless Spring API + https://github.com/aws/serverless-java-container + + + 1.8 + 1.8 + 7.0.0 + 6.0.0 + 2.24.2 + + + + + com.amazonaws.serverless + aws-serverless-java-container-spring + ${project.version} + + + com.amazonaws.serverless + aws-serverless-java-container-core + ${project.version} + tests + test-jar + test + + + org.apache.httpcomponents.client5 + httpclient5 + 5.4.3 + test + + + + org.springframework + spring-webmvc + \${spring.version} + + + + org.springframework + spring-context + \${spring.version} + + + + + org.springframework + spring-context-indexer + \${spring.version} + true + + + + org.apache.logging.log4j + log4j-core + \${log4j.version} + + + + org.apache.logging.log4j + log4j-api + \${log4j.version} + + + + org.apache.logging.log4j + log4j-slf4j-impl + \${log4j.version} + + + + com.amazonaws + aws-lambda-java-log4j2 + 1.6.0 + + + + org.junit.jupiter + junit-jupiter + test + + + + + + + + org.junit + junit-bom + ${junit.version} + import + pom + + + + + + + shaded-jar + + + + org.apache.maven.plugins + maven-shade-plugin + 3.6.0 + + + package + + shade + + + + + + + + + + + + io.github.edwgiz + log4j-maven-shade-plugin-extensions + 2.20.0 + + + + + + + + assembly-zip + + true + + + + + + org.apache.maven.plugins + maven-jar-plugin + 3.4.2 + + + default-jar + none + + + + + org.apache.maven.plugins + maven-install-plugin + 3.1.2 + + true + + + + + org.apache.maven.plugins + maven-dependency-plugin + 3.8.1 + + + copy-dependencies + package + + copy-dependencies + + + ${dollar}{project.build.directory}${dollar}{file.separator}lib + runtime + + + + + + org.apache.maven.plugins + maven-assembly-plugin + 3.7.1 + + + zip-assembly + package + + single + + + ${dollar}{project.artifactId}-${dollar}{project.version} + + src${dollar}{file.separator}assembly${dollar}{file.separator}bin.xml + + false + + + + + + + + + diff --git a/aws-serverless-spring-archetype/src/main/resources/archetype-resources/src/assembly/bin.xml b/aws-serverless-spring-archetype/src/main/resources/archetype-resources/src/assembly/bin.xml new file mode 100644 index 00000000..1ffd82d1 --- /dev/null +++ b/aws-serverless-spring-archetype/src/main/resources/archetype-resources/src/assembly/bin.xml @@ -0,0 +1,24 @@ + + lambda-package + + zip + + false + + + + ${project.build.directory}${file.separator}lib + lib + + + + ${project.build.directory}${file.separator}classes + + ** + + ${file.separator} + + + \ No newline at end of file diff --git a/aws-serverless-spring-archetype/src/main/resources/archetype-resources/src/main/java/SpringApiConfig.java b/aws-serverless-spring-archetype/src/main/resources/archetype-resources/src/main/java/SpringApiConfig.java new file mode 100644 index 00000000..83926b5d --- /dev/null +++ b/aws-serverless-spring-archetype/src/main/resources/archetype-resources/src/main/java/SpringApiConfig.java @@ -0,0 +1,60 @@ +package ${groupId}; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.web.servlet.HandlerAdapter; +import org.springframework.web.servlet.HandlerExceptionResolver; +import org.springframework.web.servlet.HandlerMapping; +import org.springframework.web.servlet.ModelAndView; +import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter; +import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import ${groupId}.controller.PingController; + + +@Configuration +// We use direct @Import instead of @ComponentScan to speed up cold starts +// @ComponentScan("${groupId}.controller") +@Import({ PingController.class }) +public class SpringApiConfig { + /* + * Create required HandlerMapping, to avoid several default HandlerMapping instances being created + */ + @Bean + public HandlerMapping handlerMapping() { + return new RequestMappingHandlerMapping(); + } + + /* + * Create required HandlerAdapter, to avoid several default HandlerAdapter instances being created + */ + @Bean + public HandlerAdapter handlerAdapter() { + return new RequestMappingHandlerAdapter(); + } + + /* + * optimization - avoids creating default exception resolvers; not required as the serverless container handles + * all exceptions + * + * By default, an ExceptionHandlerExceptionResolver is created which creates many dependent object, including + * an expensive ObjectMapper instance. + * + * To enable custom @ControllerAdvice classes remove this bean. + */ + @Bean + public HandlerExceptionResolver handlerExceptionResolver() { + return new HandlerExceptionResolver() { + + @Override + public ModelAndView resolveException(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { + return null; + } + }; + } +} \ No newline at end of file diff --git a/aws-serverless-spring-archetype/src/main/resources/archetype-resources/src/main/java/StreamLambdaHandler.java b/aws-serverless-spring-archetype/src/main/resources/archetype-resources/src/main/java/StreamLambdaHandler.java new file mode 100644 index 00000000..8cddb47e --- /dev/null +++ b/aws-serverless-spring-archetype/src/main/resources/archetype-resources/src/main/java/StreamLambdaHandler.java @@ -0,0 +1,33 @@ +package ${groupId}; + + +import com.amazonaws.serverless.exceptions.ContainerInitializationException; +import com.amazonaws.serverless.proxy.model.AwsProxyRequest; +import com.amazonaws.serverless.proxy.model.AwsProxyResponse; +import com.amazonaws.serverless.proxy.spring.SpringLambdaContainerHandler; +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestStreamHandler; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + + +public class StreamLambdaHandler implements RequestStreamHandler { + private static SpringLambdaContainerHandler handler; + static { + try { + handler = SpringLambdaContainerHandler.getAwsProxyHandler(SpringApiConfig.class); + } catch (ContainerInitializationException e) { + // if we fail here. We re-throw the exception to force another cold start + e.printStackTrace(); + throw new RuntimeException("Could not initialize Spring framework", e); + } + } + + @Override + public void handleRequest(InputStream inputStream, OutputStream outputStream, Context context) + throws IOException { + handler.proxyStream(inputStream, outputStream, context); + } +} \ No newline at end of file diff --git a/aws-serverless-spring-archetype/src/main/resources/archetype-resources/src/main/java/controller/PingController.java b/aws-serverless-spring-archetype/src/main/resources/archetype-resources/src/main/java/controller/PingController.java new file mode 100644 index 00000000..94f517f0 --- /dev/null +++ b/aws-serverless-spring-archetype/src/main/resources/archetype-resources/src/main/java/controller/PingController.java @@ -0,0 +1,20 @@ +package ${groupId}.controller; + + +import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.config.annotation.EnableWebMvc; + +import java.util.HashMap; +import java.util.Map; + + +@RestController +@EnableWebMvc +public class PingController { + @RequestMapping(path = "/ping", method = RequestMethod.GET) + public Map ping() { + Map pong = new HashMap<>(); + pong.put("pong", "Hello, World!"); + return pong; + } +} diff --git a/aws-serverless-spring-archetype/src/main/resources/archetype-resources/src/main/resources/log4j2.xml b/aws-serverless-spring-archetype/src/main/resources/archetype-resources/src/main/resources/log4j2.xml new file mode 100644 index 00000000..fada3ade --- /dev/null +++ b/aws-serverless-spring-archetype/src/main/resources/archetype-resources/src/main/resources/log4j2.xml @@ -0,0 +1,15 @@ + + + + + + %d{yyyy-MM-dd HH:mm:ss} %X{AWSRequestId} %-5p %c{1}:%L - %m%n + + + + + + + + + \ No newline at end of file diff --git a/aws-serverless-spring-archetype/src/main/resources/archetype-resources/src/test/java/StreamLambdaHandlerTest.java b/aws-serverless-spring-archetype/src/main/resources/archetype-resources/src/test/java/StreamLambdaHandlerTest.java new file mode 100644 index 00000000..cfb809f8 --- /dev/null +++ b/aws-serverless-spring-archetype/src/main/resources/archetype-resources/src/test/java/StreamLambdaHandlerTest.java @@ -0,0 +1,90 @@ +package ${groupId}; + + +import com.amazonaws.serverless.proxy.internal.LambdaContainerHandler; +import com.amazonaws.serverless.proxy.internal.testutils.AwsProxyRequestBuilder; +import com.amazonaws.serverless.proxy.internal.testutils.MockLambdaContext; +import com.amazonaws.serverless.proxy.model.AwsProxyResponse; +import com.amazonaws.services.lambda.runtime.Context; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import jakarta.ws.rs.HttpMethod; +import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; + +import static org.junit.jupiter.api.Assertions.*; + + +public class StreamLambdaHandlerTest { + + private static StreamLambdaHandler handler; + private static Context lambdaContext; + + @BeforeAll + public static void setUp() { + handler = new StreamLambdaHandler(); + lambdaContext = new MockLambdaContext(); + } + + @Test + public void ping_streamRequest_respondsWithHello() { + InputStream requestStream = new AwsProxyRequestBuilder("/ping", HttpMethod.GET) + .header(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON) + .buildStream(); + ByteArrayOutputStream responseStream = new ByteArrayOutputStream(); + + handle(requestStream, responseStream); + + AwsProxyResponse response = readResponse(responseStream); + assertNotNull(response); + assertEquals(Response.Status.OK.getStatusCode(), response.getStatusCode()); + + assertFalse(response.isBase64Encoded()); + + assertTrue(response.getBody().contains("pong")); + assertTrue(response.getBody().contains("Hello, World!")); + + assertTrue(response.getMultiValueHeaders().containsKey(HttpHeaders.CONTENT_TYPE)); + assertTrue(response.getMultiValueHeaders().getFirst(HttpHeaders.CONTENT_TYPE).startsWith(MediaType.APPLICATION_JSON)); + } + + @Test + public void invalidResource_streamRequest_responds404() { + InputStream requestStream = new AwsProxyRequestBuilder("/pong", HttpMethod.GET) + .header(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON) + .buildStream(); + ByteArrayOutputStream responseStream = new ByteArrayOutputStream(); + + handle(requestStream, responseStream); + + AwsProxyResponse response = readResponse(responseStream); + assertNotNull(response); + assertEquals(Response.Status.NOT_FOUND.getStatusCode(), response.getStatusCode()); + } + + private void handle(InputStream is, ByteArrayOutputStream os) { + try { + handler.handleRequest(is, os, lambdaContext); + } catch (IOException e) { + e.printStackTrace(); + fail(e.getMessage()); + } + } + + private AwsProxyResponse readResponse(ByteArrayOutputStream responseStream) { + try { + return LambdaContainerHandler.getObjectMapper().readValue(responseStream.toByteArray(), AwsProxyResponse.class); + } catch (tools.jackson.core.JacksonException e) { + e.printStackTrace(); + fail("Error while parsing response: " + e.getMessage()); + } + return null; + } +} diff --git a/aws-serverless-spring-archetype/src/main/resources/archetype-resources/template.yml b/aws-serverless-spring-archetype/src/main/resources/archetype-resources/template.yml new file mode 100644 index 00000000..fe49737e --- /dev/null +++ b/aws-serverless-spring-archetype/src/main/resources/archetype-resources/template.yml @@ -0,0 +1,52 @@ +#set($resourceName = $artifactId) +#macro(replaceChar $originalName, $char) + #if($originalName.contains($char)) + #set($tokens = $originalName.split($char)) + #set($newResourceName = "") + #foreach($token in $tokens) + #set($newResourceName = $newResourceName + $token.substring(0,1).toUpperCase() + $token.substring(1).toLowerCase()) + #end + ${newResourceName} + #else + #set($newResourceName = $originalName.substring(0,1).toUpperCase() + $originalName.substring(1)) + ${newResourceName} + #end +#end +#set($resourceName = "#replaceChar($resourceName, '-')") +#set($resourceName = "#replaceChar($resourceName, '.')") +#set($resourceName = $resourceName.replaceAll("\n", "").trim()) +#macro(regionVar) + AWS::Region +#end +#set($awsRegion = "#regionVar()") +#set($awsRegion = $awsRegion.replaceAll("\n", "").trim()) +AWSTemplateFormatVersion: '2010-09-09' +Transform: AWS::Serverless-2016-10-31 +Description: AWS Serverless Spring API - ${groupId}::${artifactId} +Globals: + Api: + EndpointConfiguration: REGIONAL + +Resources: + ${resourceName}Function: + Type: AWS::Serverless::Function + Properties: + Handler: ${groupId}.StreamLambdaHandler::handleRequest + Runtime: java21 + CodeUri: . + MemorySize: 512 + Policies: AWSLambdaBasicExecutionRole + Timeout: 15 + Events: + ProxyResource: + Type: Api + Properties: + Path: /{proxy+} + Method: any + +Outputs: + ${resourceName}Api: + Description: URL for application + Value: !Sub 'https://${ServerlessRestApi}.execute-api.${${awsRegion}}.amazonaws.com/Prod/ping' + Export: + Name: ${resourceName}Api diff --git a/aws-serverless-spring-archetype/src/test/resources/projects/base/archetype.properties b/aws-serverless-spring-archetype/src/test/resources/projects/base/archetype.properties new file mode 100644 index 00000000..20f24920 --- /dev/null +++ b/aws-serverless-spring-archetype/src/test/resources/projects/base/archetype.properties @@ -0,0 +1,3 @@ +groupId=test.service +artifactId=spring-archetype-test +version=1.0-SNAPSHOT diff --git a/aws-serverless-spring-archetype/src/test/resources/projects/base/goal.txt b/aws-serverless-spring-archetype/src/test/resources/projects/base/goal.txt new file mode 100644 index 00000000..597acc76 --- /dev/null +++ b/aws-serverless-spring-archetype/src/test/resources/projects/base/goal.txt @@ -0,0 +1 @@ +package \ No newline at end of file diff --git a/aws-serverless-springboot4-archetype/pom.xml b/aws-serverless-springboot4-archetype/pom.xml new file mode 100644 index 00000000..a89b26e1 --- /dev/null +++ b/aws-serverless-springboot4-archetype/pom.xml @@ -0,0 +1,81 @@ + + 4.0.0 + + + com.amazonaws.serverless + aws-serverless-java-container + 3.0.3-SNAPSHOT + + + com.amazonaws.serverless.archetypes + aws-serverless-springboot4-archetype + 3.0.3-SNAPSHOT + maven-archetype + AWS Serverless Spring Boot 4 Archetype + + + https://github.com/aws/serverless-java-container.git + HEAD + + + + + The Apache Software License, Version 2.0 + http://www.apache.org/licenses/LICENSE-2.0.txt + repo + + + + + + + src/main/resources + true + + archetype-resources/pom.xml + archetype-resources/README.md + + + + src/main/resources + false + + archetype-resources/pom.xml + + + + + + + org.apache.maven.archetype + archetype-packaging + 3.4.0 + + + + + + + org.apache.maven.plugins + maven-resources-plugin + 3.3.1 + + \ + + + + org.apache.maven.plugins + maven-archetype-plugin + 3.4.0 + + + + integration-test + + + + + + + + diff --git a/aws-serverless-springboot4-archetype/src/main/resources/META-INF/maven/archetype-metadata.xml b/aws-serverless-springboot4-archetype/src/main/resources/META-INF/maven/archetype-metadata.xml new file mode 100644 index 00000000..5379692b --- /dev/null +++ b/aws-serverless-springboot4-archetype/src/main/resources/META-INF/maven/archetype-metadata.xml @@ -0,0 +1,39 @@ + + + + src/main/java + + **/*.java + + + + src/main/resources + + **/*.properties + + + + src/test/java + + **/*.java + + + + src/assembly + + * + + + + + + template.yml + README.md + build.gradle + + + + \ No newline at end of file diff --git a/aws-serverless-springboot4-archetype/src/main/resources/archetype-resources/README.md b/aws-serverless-springboot4-archetype/src/main/resources/archetype-resources/README.md new file mode 100644 index 00000000..311c40ae --- /dev/null +++ b/aws-serverless-springboot4-archetype/src/main/resources/archetype-resources/README.md @@ -0,0 +1,99 @@ +#set($resourceName = $artifactId) +#macro(replaceChar $originalName, $char) + #if($originalName.contains($char)) + #set($tokens = $originalName.split($char)) + #set($newResourceName = "") + #foreach($token in $tokens) + #set($newResourceName = $newResourceName + $token.substring(0,1).toUpperCase() + $token.substring(1).toLowerCase()) + #end + ${newResourceName} + #else + #set($newResourceName = $originalName.substring(0,1).toUpperCase() + $originalName.substring(1)) + ${newResourceName} + #end +#end +#set($resourceName = "#replaceChar($resourceName, '-')") +#set($resourceName = "#replaceChar($resourceName, '.')") +#set($resourceName = $resourceName.replaceAll("\n", "").trim()) +# \${artifactId} serverless API +The \${artifactId} project, created with [`aws-serverless-java-container`](https://github.com/aws/serverless-java-container). + +The starter project defines a simple `/ping` resource that can accept `GET` requests with its tests. + +The project folder also includes a `template.yml` file. You can use this [SAM](https://github.com/awslabs/serverless-application-model) file to deploy the project to AWS Lambda and Amazon API Gateway or test in local with the [SAM CLI](https://github.com/awslabs/aws-sam-cli). + +#[[##]]# Pre-requisites +* [AWS CLI](https://aws.amazon.com/cli/) +* [SAM CLI](https://github.com/awslabs/aws-sam-cli) +* [Gradle](https://gradle.org/) or [Maven](https://maven.apache.org/) + +#[[##]]# Building the project +You can use the SAM CLI to quickly build the project +```bash +$ mvn archetype:generate -DartifactId=\${artifactId} -DarchetypeGroupId=com.amazonaws.serverless.archetypes -DarchetypeArtifactId=aws-serverless-jersey-archetype -DarchetypeVersion=${project.version} -DgroupId=\${groupId} -Dversion=\${version} -Dinteractive=false +$ cd \${artifactId} +$ sam build +Building resource '\${resourceName}Function' +Running JavaGradleWorkflow:GradleBuild +Running JavaGradleWorkflow:CopyArtifacts + +Build Succeeded + +Built Artifacts : .aws-sam/build +Built Template : .aws-sam/build/template.yaml + +Commands you can use next +========================= +[*] Invoke Function: sam local invoke +[*] Deploy: sam deploy --guided +``` + +#[[##]]# Testing locally with the SAM CLI + +From the project root folder - where the `template.yml` file is located - start the API with the SAM CLI. + +```bash +$ sam local start-api + +... +Mounting ${groupId}.StreamLambdaHandler::handleRequest (java11) at http://127.0.0.1:3000/{proxy+} [OPTIONS GET HEAD POST PUT DELETE PATCH] +... +``` + +Using a new shell, you can send a test ping request to your API: + +```bash +$ curl -s http://127.0.0.1:3000/ping | python -m json.tool + +{ + "pong": "Hello, World!" +} +``` + +#[[##]]# Deploying to AWS +To deploy the application in your AWS account, you can use the SAM CLI's guided deployment process and follow the instructions on the screen + +``` +$ sam deploy --guided +``` + +Once the deployment is completed, the SAM CLI will print out the stack's outputs, including the new application URL. You can use `curl` or a web browser to make a call to the URL + +``` +... +------------------------------------------------------------------------------------------------------------- +OutputKey-Description OutputValue +------------------------------------------------------------------------------------------------------------- +\${resourceName}Api - URL for application https://xxxxxxxxxx.execute-api.us-west-2.amazonaws.com/Prod/pets +------------------------------------------------------------------------------------------------------------- +``` + +Copy the `OutputValue` into a browser or use curl to test your first request: + +```bash +$ curl -s https://xxxxxxx.execute-api.us-west-2.amazonaws.com/Prod/ping | python -m json.tool + +{ + "pong": "Hello, World!" +} +``` diff --git a/aws-serverless-springboot4-archetype/src/main/resources/archetype-resources/build.gradle b/aws-serverless-springboot4-archetype/src/main/resources/archetype-resources/build.gradle new file mode 100644 index 00000000..3aa54825 --- /dev/null +++ b/aws-serverless-springboot4-archetype/src/main/resources/archetype-resources/build.gradle @@ -0,0 +1,37 @@ +apply plugin: 'java' + +repositories { + mavenLocal() + mavenCentral() + maven {url "https://repo.spring.io/milestone"} + maven {url "https://repo.spring.io/snapshot"} +} + +dependencies { + implementation ( + 'org.springframework.boot:spring-boot-starter-web:3.4.5', + 'com.amazonaws.serverless:aws-serverless-java-container-springboot3:[2.0-SNAPSHOT,)', + ) + + testImplementation("com.amazonaws.serverless:aws-serverless-java-container-core:[2.0-SNAPSHOT,):tests") + testImplementation("org.apache.httpcomponents.client5:httpclient5:5.5") + testImplementation(platform("org.junit:junit-bom:5.13.1")) + testImplementation("org.junit.jupiter:junit-jupiter") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") +} + +task buildZip(type: Zip) { + from compileJava + from processResources + into('lib') { + from(configurations.compileClasspath) { + exclude 'tomcat-embed-*' + } + } +} + +test { + useJUnitPlatform() +} + +build.dependsOn buildZip diff --git a/aws-serverless-springboot4-archetype/src/main/resources/archetype-resources/pom.xml b/aws-serverless-springboot4-archetype/src/main/resources/archetype-resources/pom.xml new file mode 100644 index 00000000..c122a405 --- /dev/null +++ b/aws-serverless-springboot4-archetype/src/main/resources/archetype-resources/pom.xml @@ -0,0 +1,180 @@ +#set($dollar = '$') + + + 4.0.0 + + \${groupId} + \${artifactId} + \${version} + jar + + Serverless Spring Boot 4 API + https://github.com/aws/serverless-java-container + + + org.springframework.boot + spring-boot-starter-parent + 4.0.0 + + + + 17 + 5.12.1 + + + + + com.amazonaws.serverless + aws-serverless-java-container-springboot4 + ${project.version} + + + com.amazonaws.serverless + aws-serverless-java-container-core + ${project.version} + tests + test-jar + test + + + org.apache.httpcomponents.client5 + httpclient5 + 5.4.3 + test + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-tomcat + + + + + + org.junit.jupiter + junit-jupiter + test + + + + + + + org.junit + junit-bom + ${junit.version} + import + pom + + + + + + + shaded-jar + + + + org.apache.maven.plugins + maven-shade-plugin + 3.6.0 + + false + + + + package + + shade + + + + + org.apache.tomcat.embed:* + + + + + + + + + + + assembly-zip + + true + + + + + + org.apache.maven.plugins + maven-jar-plugin + 3.4.2 + + + default-jar + none + + + + + org.apache.maven.plugins + maven-install-plugin + 3.1.2 + + true + + + + + org.apache.maven.plugins + maven-dependency-plugin + 3.8.1 + + + copy-dependencies + package + + copy-dependencies + + + ${dollar}{project.build.directory}${dollar}{file.separator}lib + runtime + + + + + + org.apache.maven.plugins + maven-assembly-plugin + 3.7.1 + + + zip-assembly + package + + single + + + ${dollar}{project.artifactId}-${dollar}{project.version} + + src${dollar}{file.separator}assembly${dollar}{file.separator}bin.xml + + false + + + + + + + + + diff --git a/aws-serverless-springboot4-archetype/src/main/resources/archetype-resources/src/assembly/bin.xml b/aws-serverless-springboot4-archetype/src/main/resources/archetype-resources/src/assembly/bin.xml new file mode 100644 index 00000000..1e085057 --- /dev/null +++ b/aws-serverless-springboot4-archetype/src/main/resources/archetype-resources/src/assembly/bin.xml @@ -0,0 +1,27 @@ + + lambda-package + + zip + + false + + + + ${project.build.directory}${file.separator}lib + lib + + tomcat-embed* + + + + + ${project.build.directory}${file.separator}classes + + ** + + ${file.separator} + + + \ No newline at end of file diff --git a/aws-serverless-springboot4-archetype/src/main/resources/archetype-resources/src/main/java/Application.java b/aws-serverless-springboot4-archetype/src/main/resources/archetype-resources/src/main/java/Application.java new file mode 100644 index 00000000..1b74086f --- /dev/null +++ b/aws-serverless-springboot4-archetype/src/main/resources/archetype-resources/src/main/java/Application.java @@ -0,0 +1,24 @@ +#macro(loggingOff) + logging.level.root:OFF +#end +#set($logging = "#loggingOff()") +#set($logging = $logging.replaceAll("\n", "").trim()) +package ${groupId}; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Import; + +import ${groupId}.controller.PingController; + + +@SpringBootApplication +// We use direct @Import instead of @ComponentScan to speed up cold starts +// @ComponentScan(basePackages = "${groupId}.controller") +@Import({ PingController.class }) +public class Application { + + public static void main(String[] args) { + SpringApplication.run(Application.class, args); + } +} \ No newline at end of file diff --git a/aws-serverless-springboot4-archetype/src/main/resources/archetype-resources/src/main/java/StreamLambdaHandler.java b/aws-serverless-springboot4-archetype/src/main/resources/archetype-resources/src/main/java/StreamLambdaHandler.java new file mode 100644 index 00000000..e022540c --- /dev/null +++ b/aws-serverless-springboot4-archetype/src/main/resources/archetype-resources/src/main/java/StreamLambdaHandler.java @@ -0,0 +1,33 @@ +package ${groupId}; + + +import com.amazonaws.serverless.exceptions.ContainerInitializationException; +import com.amazonaws.serverless.proxy.model.AwsProxyRequest; +import com.amazonaws.serverless.proxy.model.AwsProxyResponse; +import com.amazonaws.serverless.proxy.spring.SpringBootLambdaContainerHandler; +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestStreamHandler; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + + +public class StreamLambdaHandler implements RequestStreamHandler { + private static SpringBootLambdaContainerHandler handler; + static { + try { + handler = SpringBootLambdaContainerHandler.getAwsProxyHandler(Application.class); + } catch (ContainerInitializationException e) { + // if we fail here. We re-throw the exception to force another cold start + e.printStackTrace(); + throw new RuntimeException("Could not initialize Spring Boot application", e); + } + } + + @Override + public void handleRequest(InputStream inputStream, OutputStream outputStream, Context context) + throws IOException { + handler.proxyStream(inputStream, outputStream, context); + } +} \ No newline at end of file diff --git a/aws-serverless-springboot4-archetype/src/main/resources/archetype-resources/src/main/java/controller/PingController.java b/aws-serverless-springboot4-archetype/src/main/resources/archetype-resources/src/main/java/controller/PingController.java new file mode 100644 index 00000000..94f517f0 --- /dev/null +++ b/aws-serverless-springboot4-archetype/src/main/resources/archetype-resources/src/main/java/controller/PingController.java @@ -0,0 +1,20 @@ +package ${groupId}.controller; + + +import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.config.annotation.EnableWebMvc; + +import java.util.HashMap; +import java.util.Map; + + +@RestController +@EnableWebMvc +public class PingController { + @RequestMapping(path = "/ping", method = RequestMethod.GET) + public Map ping() { + Map pong = new HashMap<>(); + pong.put("pong", "Hello, World!"); + return pong; + } +} diff --git a/aws-serverless-springboot4-archetype/src/main/resources/archetype-resources/src/main/resources/application.properties b/aws-serverless-springboot4-archetype/src/main/resources/archetype-resources/src/main/resources/application.properties new file mode 100644 index 00000000..070e632f --- /dev/null +++ b/aws-serverless-springboot4-archetype/src/main/resources/archetype-resources/src/main/resources/application.properties @@ -0,0 +1,3 @@ +# Reduce logging level to make sure the application works with SAM local +# https://github.com/aws/serverless-java-container/issues/134 +logging.level.root=WARN \ No newline at end of file diff --git a/aws-serverless-springboot4-archetype/src/main/resources/archetype-resources/src/test/java/StreamLambdaHandlerTest.java b/aws-serverless-springboot4-archetype/src/main/resources/archetype-resources/src/test/java/StreamLambdaHandlerTest.java new file mode 100644 index 00000000..4bceb78f --- /dev/null +++ b/aws-serverless-springboot4-archetype/src/main/resources/archetype-resources/src/test/java/StreamLambdaHandlerTest.java @@ -0,0 +1,88 @@ +package ${groupId}; + + +import com.amazonaws.serverless.proxy.internal.LambdaContainerHandler; +import com.amazonaws.serverless.proxy.internal.testutils.AwsProxyRequestBuilder; +import com.amazonaws.serverless.proxy.internal.testutils.MockLambdaContext; +import com.amazonaws.serverless.proxy.model.AwsProxyResponse; +import com.amazonaws.services.lambda.runtime.Context; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import jakarta.ws.rs.HttpMethod; +import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; + +import java.io.ByteArrayOutputStream; +import java.io.InputStream; + +import static org.junit.jupiter.api.Assertions.*; + +public class StreamLambdaHandlerTest { + + private static StreamLambdaHandler handler; + private static Context lambdaContext; + + @BeforeAll + public static void setUp() { + handler = new StreamLambdaHandler(); + lambdaContext = new MockLambdaContext(); + } + + @Test + public void ping_streamRequest_respondsWithHello() { + InputStream requestStream = new AwsProxyRequestBuilder("/ping", HttpMethod.GET) + .header(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON) + .buildStream(); + ByteArrayOutputStream responseStream = new ByteArrayOutputStream(); + + handle(requestStream, responseStream); + + AwsProxyResponse response = readResponse(responseStream); + assertNotNull(response); + assertEquals(Response.Status.OK.getStatusCode(), response.getStatusCode()); + + assertFalse(response.isBase64Encoded()); + + assertTrue(response.getBody().contains("pong")); + assertTrue(response.getBody().contains("Hello, World!")); + + assertTrue(response.getMultiValueHeaders().containsKey(HttpHeaders.CONTENT_TYPE)); + assertTrue(response.getMultiValueHeaders().getFirst(HttpHeaders.CONTENT_TYPE).startsWith(MediaType.APPLICATION_JSON)); + } + + @Test + public void invalidResource_streamRequest_responds404() { + InputStream requestStream = new AwsProxyRequestBuilder("/pong", HttpMethod.GET) + .header(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON) + .buildStream(); + ByteArrayOutputStream responseStream = new ByteArrayOutputStream(); + + handle(requestStream, responseStream); + + AwsProxyResponse response = readResponse(responseStream); + assertNotNull(response); + assertEquals(Response.Status.NOT_FOUND.getStatusCode(), response.getStatusCode()); + } + + private void handle(InputStream is, ByteArrayOutputStream os) { + try { + handler.handleRequest(is, os, lambdaContext); + } catch (Exception e) { + e.printStackTrace(); + fail(e.getMessage()); + } + } + + private AwsProxyResponse readResponse(ByteArrayOutputStream responseStream) { + try { + return LambdaContainerHandler.getObjectMapper().readValue(responseStream.toByteArray(), AwsProxyResponse.class); + } catch (Exception e) { + e.printStackTrace(); + fail("Error while parsing response: " + e.getMessage()); + } + return null; + } +} diff --git a/aws-serverless-springboot4-archetype/src/main/resources/archetype-resources/template.yml b/aws-serverless-springboot4-archetype/src/main/resources/archetype-resources/template.yml new file mode 100644 index 00000000..18c23187 --- /dev/null +++ b/aws-serverless-springboot4-archetype/src/main/resources/archetype-resources/template.yml @@ -0,0 +1,52 @@ +#set($resourceName = $artifactId) +#macro(replaceChar $originalName, $char) + #if($originalName.contains($char)) + #set($tokens = $originalName.split($char)) + #set($newResourceName = "") + #foreach($token in $tokens) + #set($newResourceName = $newResourceName + $token.substring(0,1).toUpperCase() + $token.substring(1).toLowerCase()) + #end + ${newResourceName} + #else + #set($newResourceName = $originalName.substring(0,1).toUpperCase() + $originalName.substring(1)) + ${newResourceName} + #end +#end +#set($resourceName = "#replaceChar($resourceName, '-')") +#set($resourceName = "#replaceChar($resourceName, '.')") +#set($resourceName = $resourceName.replaceAll("\n", "").trim()) +#macro(regionVar) + AWS::Region +#end +#set($awsRegion = "#regionVar()") +#set($awsRegion = $awsRegion.replaceAll("\n", "").trim()) +AWSTemplateFormatVersion: '2010-09-09' +Transform: AWS::Serverless-2016-10-31 +Description: AWS Serverless Spring Boot 2 API - ${groupId}::${artifactId} +Globals: + Api: + EndpointConfiguration: REGIONAL + +Resources: + ${resourceName}Function: + Type: AWS::Serverless::Function + Properties: + Handler: ${groupId}.StreamLambdaHandler::handleRequest + Runtime: java25 + CodeUri: . + MemorySize: 512 + Policies: AWSLambdaBasicExecutionRole + Timeout: 30 + Events: + ProxyResource: + Type: Api + Properties: + Path: /{proxy+} + Method: any + +Outputs: + ${resourceName}Api: + Description: URL for application + Value: !Sub 'https://${ServerlessRestApi}.execute-api.${${awsRegion}}.amazonaws.com/Prod/ping' + Export: + Name: ${resourceName}Api diff --git a/aws-serverless-springboot4-archetype/src/test/resources/projects/base/archetype.properties b/aws-serverless-springboot4-archetype/src/test/resources/projects/base/archetype.properties new file mode 100644 index 00000000..7df3bf6e --- /dev/null +++ b/aws-serverless-springboot4-archetype/src/test/resources/projects/base/archetype.properties @@ -0,0 +1,3 @@ +groupId=test.service +artifactId=springboot-archetype-test +version=1.0-SNAPSHOT diff --git a/aws-serverless-springboot4-archetype/src/test/resources/projects/base/goal.txt b/aws-serverless-springboot4-archetype/src/test/resources/projects/base/goal.txt new file mode 100644 index 00000000..597acc76 --- /dev/null +++ b/aws-serverless-springboot4-archetype/src/test/resources/projects/base/goal.txt @@ -0,0 +1 @@ +package \ No newline at end of file diff --git a/gha_build.sh b/gha_build.sh new file mode 100755 index 00000000..247e0910 --- /dev/null +++ b/gha_build.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash + +WORKING_DIR=$(pwd) +FRAMEWORK=$1 +RUN_ARCHETYPE=$2 +RUN_SAMPLES=$3 +EXTRA_PARAMS=${*:4} + +echo "Starting build script for ${FRAMEWORK} with params ${EXTRA_PARAMS}" + +if [[ -z ${FRAMEWORK} ]] ; then + echo "Missing framework parameter" + exit 1 +fi + +function install { + # we skip tests for core because we assume they will be run in a separate branch of the workflow + cd ${WORKING_DIR}/aws-serverless-java-container-core && mvn -q clean install -DskipTests + if [[ "$?" -ne 0 ]]; then + exit 1 + fi + cd ${WORKING_DIR}/aws-serverless-java-container-$1 && mvn -q clean install ${@:2} + if [[ "$?" -ne 0 ]]; then + exit 1 + fi +} + +function archetype { + ARCHETYPE_NAME=aws-serverless-$1-archetype + PROJ_NAME=$1-archetype-test + cd ${WORKING_DIR}/${ARCHETYPE_NAME} && mvn -q clean install + ARCHETYPE_TEST_DIR=${WORKING_DIR}/$1_archetype_test + mkdir -p ${ARCHETYPE_TEST_DIR} + cd ${ARCHETYPE_TEST_DIR} && mvn archetype:generate -DgroupId=my.service -DartifactId=${PROJ_NAME} -Dversion=2.0-SNAPSHOT \ + -DarchetypeGroupId=com.amazonaws.serverless.archetypes \ + -DarchetypeArtifactId=${ARCHETYPE_NAME} \ + -DarchetypeCatalog=local \ + -DinteractiveMode=false + if [[ "$?" -ne 0 ]]; then + exit 1 + fi + cd ${ARCHETYPE_TEST_DIR}/${PROJ_NAME} && mvn -q clean package -Pshaded-jar + if [[ "$?" -ne 0 ]]; then + exit 1 + fi + cd ${ARCHETYPE_TEST_DIR}/${PROJ_NAME} && mvn -q clean package + if [[ "$?" -ne 0 ]]; then + exit 1 + fi + cd ${ARCHETYPE_TEST_DIR}/${PROJ_NAME} && gradle -q wrapper + if [[ "$?" -ne 0 ]]; then + exit 1 + fi + cd ${ARCHETYPE_TEST_DIR}/${PROJ_NAME} && ./gradlew -q clean build + if [[ "$?" -ne 0 ]]; then + exit 1 + fi +} + +function sample { + for d in ${WORKING_DIR}/samples/$1/*/ ; do + SAMPLE_FOLDER="$d" + cd ${SAMPLE_FOLDER} && mvn -q clean package + if [[ "$?" -ne 0 ]]; then + exit 1 + fi + if [ -n "$(find ${SAMPLE_FOLDER} -name '*gradle*' | head -1)" ]; then + cd ${SAMPLE_FOLDER} && gradle -q wrapper + if [[ "$?" -ne 0 ]]; then + exit 1 + fi + cd ${SAMPLE_FOLDER} && ./gradlew -q clean build + if [[ "$?" -ne 0 ]]; then + exit 1 + fi + fi + done +} + +# set up the master pom otherwise we won't be able to find new dependencies +cd ${WORKING_DIR}/ && mvn -q --non-recursive clean install + +install ${FRAMEWORK} ${EXTRA_PARAMS} +if [[ "$RUN_ARCHETYPE" = true ]] ; then + archetype ${FRAMEWORK} +fi +if [[ "$RUN_SAMPLES" = true ]] ; then + sample ${FRAMEWORK} +fi \ No newline at end of file diff --git a/owasp-suppression.xml b/owasp-suppression.xml new file mode 100644 index 00000000..0f16cbd3 --- /dev/null +++ b/owasp-suppression.xml @@ -0,0 +1,27 @@ + + + + + + + ^pkg:maven/com.fasterxml.jackson.core/jackson-databind@.*$ + CVE-2023-35116 + + \ No newline at end of file diff --git a/pom.xml b/pom.xml index 225d83d3..3bf29adb 100644 --- a/pom.xml +++ b/pom.xml @@ -4,29 +4,69 @@ com.amazonaws.serverless aws-serverless-java-container pom - 0.5-SNAPSHOT + 3.0.3-SNAPSHOT AWS Serverless Java container - - - - org.sonatype.oss - oss-parent - 7 - + A Java framework to run Spring, Spring Boot, Jersey, Spark, and Struts applications inside AWS Lambda + https://github.com/aws/serverless-java-container + + GitHub Issues + https://github.com/aws/serverless-java-container/issues + + + + + amazonwebservices + Amazon Web Services + https://aws.amazon.com + + developer + + + aws-serverless-java-container-core aws-serverless-java-container-jersey - aws-serverless-java-container-spark aws-serverless-java-container-spring + aws-serverless-java-container-springboot4 + aws-serverless-jersey-archetype + aws-serverless-spring-archetype + aws-serverless-springboot4-archetype - https://github.com/awslabs/aws-serverless-java-container.git - scm:git:git@github.com:awslabs/aws-serverless-java-container.git - scm:git:git@github.com:awslabs/aws-serverless-java-container.git + https://github.com/aws/serverless-java-container + scm:git:https://github.com/aws/serverless-java-container.git + HEAD + + + sonatype-central-snapshots + Sonatype Central Snapshots + https://ossrh-staging-api.central.sonatype.com/content/repositories/snapshots + + false + + + true + + + + + + + sonatype-central-snapshots + Sonatype Central Snapshots + https://ossrh-staging-api.central.sonatype.com/content/repositories/snapshots/ + + + sonatype-central-staging + Central Release Repository + https://ossrh-staging-api.central.sonatype.com/service/local/staging/deploy/maven2/ + + + The Apache Software License, Version 2.0 @@ -35,12 +75,122 @@ + + 0.7 + 12.1.1 + + 2.0.17 + 6.0.0 + 5.19.0 + 1.3 + UTF-8 + + + + + org.junit.jupiter + junit-jupiter-api + test + + + org.junit.jupiter + junit-jupiter-engine + test + + + org.junit.jupiter + junit-jupiter-params + test + + + + org.slf4j + slf4j-api + ${slf4j.version} + + + + org.slf4j + slf4j-simple + ${slf4j.version} + test + + + + org.apache.httpcomponents.client5 + httpclient5 + 5.5 + test + + + + org.mockito + mockito-core + ${mockito.version} + test + + + + org.hamcrest + hamcrest-all + ${hamcrest.version} + test + + + + com.github.spotbugs + spotbugs-annotations + 4.9.3 + provided + + + + + + + org.junit + junit-bom + ${junit.version} + import + pom + + + + + + + org.apache.maven.plugins + maven-enforcer-plugin + + + + org.apache.maven.plugins + maven-enforcer-plugin + 3.5.0 + + + enforce + + + + + 3.6 + + + + + enforce + + + + org.apache.maven.plugins maven-compiler-plugin + 3.14.0 1.8 1.8 @@ -51,37 +201,98 @@ org.apache.maven.plugins maven-javadoc-plugin - 2.10.4 + 3.11.2 + + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.3 + + + org.apache.maven.plugins + maven-clean-plugin + 3.4.1 + + + org.apache.maven.plugins + maven-jar-plugin + 3.4.2 + + + org.apache.maven.plugins + maven-resources-plugin + 3.3.1 + + + org.apache.maven.plugins + maven-deploy-plugin + 3.1.4 + + + org.apache.maven.plugins + maven-install-plugin + 3.1.4 - org.apache.maven.plugins maven-release-plugin + 3.1.1 + serverless-java-container-release clean verify install - false true + chore: release - + true + + + com.github.spotbugs + spotbugs-maven-plugin + 4.9.3.0 + + + Max + + Low + + High + + true + + ${project.build.directory}/spotbugs + ${project.basedir}/../spotbugs-excludeFilter.xml + + + com.h3xstream.findsecbugs + findsecbugs-plugin + 1.14.0 + + + + + + org.jacoco + jacoco-maven-plugin + 0.8.13 + - release-sign-artifacts - - - performRelease - true - - + serverless-java-container-release org.apache.maven.plugins maven-gpg-plugin - 1.4 + 3.2.7 sign-artifacts @@ -92,9 +303,34 @@ + + org.apache.maven.plugins + maven-javadoc-plugin + + + attach-javadocs + + jar + + + + + + org.apache.maven.plugins + maven-source-plugin + 3.3.1 + + + attach-sources + + jar-no-fork + + + + - \ No newline at end of file + diff --git a/samples/jersey/pet-store/README.md b/samples/jersey/pet-store/README.md index bfb44164..d85d5628 100644 --- a/samples/jersey/pet-store/README.md +++ b/samples/jersey/pet-store/README.md @@ -1,62 +1,36 @@ # Serverless Jersey example -A basic pet store written with the [Jersey framework](https://jersey.java.net/). The `LambdaHandler` object is the main entry point for Lambda. +A basic pet store written with the [Jersey framework](https://jersey.java.net/). The `StreamLambdaHandler` object is the main entry point for Lambda. -The application can be deployed in an AWS account using the [Serverless Application Model](https://github.com/awslabs/serverless-application-model). The `sam.yaml` file in the root folder contains the application definition +The application can be deployed in an AWS account using the [Serverless Application Model](https://github.com/awslabs/serverless-application-model). The `template.yml` file in the root folder contains the application definition. -## Installation -To build and install the sample application you will need [Maven](https://maven.apache.org/) and the [AWS CLI](https://aws.amazon.com/cli/) installed on your computer. +## Pre-requisites +* [AWS CLI](https://aws.amazon.com/cli/) +* [SAM CLI](https://github.com/awslabs/aws-sam-cli) +* [Gradle](https://gradle.org/) or [Maven](https://maven.apache.org/) -In a shell, navigate to the sample's folder and use maven to build a deployable jar. +## Deployment +In a shell, navigate to the sample's folder and use the SAM CLI to build a deployable package ``` -$ mvn package +$ sam build ``` -This command should generate a `serverless-jersey-example-1.0-SNAPSHOT.jar` in the `target` folder. Now that we have generated the jar file, we can use the AWS CLI to package the template for deployment. +This command compiles the application and prepares a deployment package in the `.aws-sam` sub-directory. -You will need an S3 bucket to store the artifacts for deployment. Once you have created the S3 bucket, run the following command from the sample's folder: +To deploy the application in your AWS account, you can use the SAM CLI's guided deployment process and follow the instructions on the screen ``` -$ aws cloudformation package --template-file sam.yaml --output-template-file output-sam.yaml --s3-bucket -Uploading to xxxxxxxxxxxxxxxxxxxxxxxxxx 6464692 / 6464692.0 (100.00%) -Successfully packaged artifacts and wrote output template to file output-sam.yaml. -Execute the following command to deploy the packaged template -aws cloudformation deploy --template-file /your/path/output-sam.yaml --stack-name +$ sam deploy --guided ``` -As the command output suggests, you can now use the cli to deploy the application. Choose a stack name and run the `aws cloudformation deploy` command from the output of the package command. - -``` -$ aws cloudformation deploy --template-file output-sam.yaml --stack-name ServerlessJerseySample --capabilities CAPABILITY_IAM -``` - -Once the application is deployed, you can describe the stack to show the API endpoint that was created. The endpoint should be the `JerseyPetStoreApi` key of the `Outputs` property: - -``` -$ aws cloudformation describe-stacks --stack-name ServerlessJerseySample -{ - "Stacks": [ - { - "StackId": "arn:aws:cloudformation:us-west-2:xxxxxxxx:stack/JerseySample/xxxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxx", - "Description": "Example Pet Store API written in jersey with the aws-serverless-java-container library", - "Tags": [], - "Outputs": [ - { - "Description": "URL for application", - "OutputKey": "JerseyPetStoreApi", - "OutputValue": "https://xxxxxxx.execute-api.us-west-2.amazonaws.com/Prod/pets" - } - ], - "CreationTime": "2016-12-13T22:59:31.552Z", - "Capabilities": [ - "CAPABILITY_IAM" - ], - "StackName": "JerseySample", - "NotificationARNs": [], - "StackStatus": "UPDATE_COMPLETE" - } - ] -} +Once the deployment is completed, the SAM CLI will print out the stack's outputs, including the new application URL. You can use `curl` or a web browser to make a call to the URL ``` +... +--------------------------------------------------------------------------------------------------------- +OutputKey-Description OutputValue +--------------------------------------------------------------------------------------------------------- +PetStoreApi - URL for application https://xxxxxxxxxx.execute-api.us-west-2.amazonaws.com/pets +--------------------------------------------------------------------------------------------------------- -Copy the `OutputValue` into a browser to test a first request. \ No newline at end of file +$ curl https://xxxxxxxxxx.execute-api.us-west-2.amazonaws.com/pets +``` \ No newline at end of file diff --git a/samples/jersey/pet-store/build.gradle b/samples/jersey/pet-store/build.gradle new file mode 100644 index 00000000..0194402b --- /dev/null +++ b/samples/jersey/pet-store/build.gradle @@ -0,0 +1,33 @@ +apply plugin: 'java' + +repositories { + mavenLocal() + mavenCentral() +} + +dependencies { + implementation ( + 'com.amazonaws.serverless:aws-serverless-java-container-jersey:[2.0-SNAPSHOT,)', + 'com.fasterxml.jackson.core:jackson-databind:2.19.1', + ) + + implementation("org.glassfish.jersey.media:jersey-media-json-jackson:3.1.10") { + exclude group: 'com.fasterxml.jackson.core', module: "jackson-annotations" + exclude group: 'com.fasterxml.jackson.core', module: "jackson-databind" + exclude group: 'com.fasterxml.jackson.core', module: "jackson-core" + } + + implementation("org.glassfish.jersey.inject:jersey-hk2:3.1.10") { + exclude group: 'javax.inject', module: "javax.inject" + } +} + +task buildZip(type: Zip) { + from compileJava + from processResources + into('lib') { + from configurations.compileClasspath + } +} + +build.dependsOn buildZip diff --git a/samples/jersey/pet-store/pom.xml b/samples/jersey/pet-store/pom.xml index f380e7bc..aa3e1cae 100644 --- a/samples/jersey/pet-store/pom.xml +++ b/samples/jersey/pet-store/pom.xml @@ -6,13 +6,13 @@ com.amazonaws.serverless.sample serverless-jersey-example - 1.0-SNAPSHOT + 2.0-SNAPSHOT Jersey example for the aws-serverless-java-container library Simple pet store written in Jersey https://aws.amazon.com/lambda/ - https://github.com/awslabs/aws-serverless-java-container.git + https://github.com/aws/serverless-java-container.git @@ -26,48 +26,150 @@ 1.8 1.8 - 2.24 - 2.8.5 + 3.1.10 + 2.19.1 com.amazonaws.serverless aws-serverless-java-container-jersey - LATEST + [2.0-SNAPSHOT,) - com.amazonaws - aws-lambda-java-core - 1.1.0 + org.glassfish.jersey.media + jersey-media-json-jackson + ${jersey.version} + + + com.fasterxml.jackson.core + jackson-annotations + + + com.fasterxml.jackson.core + jackson-databind + + + com.fasterxml.jackson.core + jackson-core + + - org.glassfish.jersey.media - jersey-media-json-jackson + org.glassfish.jersey.inject + jersey-hk2 ${jersey.version} + + + javax.inject + javax.inject + + + + + com.fasterxml.jackson.core + jackson-databind + ${jackson.version} + + - - - - org.apache.maven.plugins - maven-shade-plugin - 2.3 - - false - - - - package - - shade - - - - - - + + + shaded-jar + + + + org.apache.maven.plugins + maven-shade-plugin + 3.6.0 + + false + + + + package + + shade + + + + + + + + + assembly-zip + + true + + + + + + org.apache.maven.plugins + maven-jar-plugin + 3.4.2 + + + default-jar + none + + + + + org.apache.maven.plugins + maven-install-plugin + 3.1.4 + + true + + + + + org.apache.maven.plugins + maven-dependency-plugin + 3.8.1 + + + copy-dependencies + package + + copy-dependencies + + + ${project.build.directory}/lib + runtime + + + + + + org.apache.maven.plugins + maven-assembly-plugin + 3.7.1 + + + zip-assembly + package + + single + + + ${project.artifactId}-${project.version} + + src${file.separator}assembly${file.separator}bin.xml + + false + + + + + + + + diff --git a/samples/jersey/pet-store/src/assembly/bin.xml b/samples/jersey/pet-store/src/assembly/bin.xml new file mode 100644 index 00000000..1ffd82d1 --- /dev/null +++ b/samples/jersey/pet-store/src/assembly/bin.xml @@ -0,0 +1,24 @@ + + lambda-package + + zip + + false + + + + ${project.build.directory}${file.separator}lib + lib + + + + ${project.build.directory}${file.separator}classes + + ** + + ${file.separator} + + + \ No newline at end of file diff --git a/samples/jersey/pet-store/src/main/java/com/amazonaws/serverless/sample/jersey/LambdaHandler.java b/samples/jersey/pet-store/src/main/java/com/amazonaws/serverless/sample/jersey/LambdaHandler.java deleted file mode 100644 index 1ac88537..00000000 --- a/samples/jersey/pet-store/src/main/java/com/amazonaws/serverless/sample/jersey/LambdaHandler.java +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright 2016 Amazon.com, Inc. or its affiliates. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance - * with the License. A copy of the License is located at - * - * http://aws.amazon.com/apache2.0/ - * - * or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES - * OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions - * and limitations under the License. - */ -package com.amazonaws.serverless.sample.jersey; - -import com.amazonaws.serverless.proxy.internal.model.AwsProxyRequest; -import com.amazonaws.serverless.proxy.internal.model.AwsProxyResponse; -import com.amazonaws.serverless.proxy.jersey.JerseyLambdaContainerHandler; -import com.amazonaws.services.lambda.runtime.Context; -import com.amazonaws.services.lambda.runtime.RequestHandler; -import org.glassfish.jersey.jackson.JacksonFeature; -import org.glassfish.jersey.server.ResourceConfig; - -public class LambdaHandler implements RequestHandler { - private final ResourceConfig jerseyApplication = new ResourceConfig() - .packages("com.amazonaws.serverless.sample.jersey") - .register(JacksonFeature.class); - private final JerseyLambdaContainerHandler handler - = JerseyLambdaContainerHandler.getAwsProxyHandler(jerseyApplication); - - @Override - public AwsProxyResponse handleRequest(AwsProxyRequest awsProxyRequest, Context context) { - return handler.proxy(awsProxyRequest, context); - } -} \ No newline at end of file diff --git a/samples/jersey/pet-store/src/main/java/com/amazonaws/serverless/sample/jersey/PetsResource.java b/samples/jersey/pet-store/src/main/java/com/amazonaws/serverless/sample/jersey/PetsResource.java index 2d92510d..c8ad7ebd 100644 --- a/samples/jersey/pet-store/src/main/java/com/amazonaws/serverless/sample/jersey/PetsResource.java +++ b/samples/jersey/pet-store/src/main/java/com/amazonaws/serverless/sample/jersey/PetsResource.java @@ -15,9 +15,9 @@ import com.amazonaws.serverless.sample.jersey.model.Pet; import com.amazonaws.serverless.sample.jersey.model.PetData; -import javax.ws.rs.*; -import javax.ws.rs.core.MediaType; -import javax.ws.rs.core.Response; +import jakarta.ws.rs.*; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; import java.util.UUID; @Path("/pets") diff --git a/samples/jersey/pet-store/src/main/java/com/amazonaws/serverless/sample/jersey/StreamLambdaHandler.java b/samples/jersey/pet-store/src/main/java/com/amazonaws/serverless/sample/jersey/StreamLambdaHandler.java new file mode 100644 index 00000000..9701a2c3 --- /dev/null +++ b/samples/jersey/pet-store/src/main/java/com/amazonaws/serverless/sample/jersey/StreamLambdaHandler.java @@ -0,0 +1,45 @@ +package com.amazonaws.serverless.sample.jersey; + + +import com.amazonaws.serverless.proxy.internal.LambdaContainerHandler; +import com.amazonaws.serverless.proxy.internal.testutils.Timer; +import com.amazonaws.serverless.proxy.jersey.JerseyLambdaContainerHandler; +import com.amazonaws.serverless.proxy.model.AwsProxyRequest; +import com.amazonaws.serverless.proxy.model.AwsProxyResponse; +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestStreamHandler; + +import org.glassfish.jersey.jackson.JacksonFeature; +import org.glassfish.jersey.server.ResourceConfig; +import org.glassfish.jersey.server.ServerProperties; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + + +public class StreamLambdaHandler implements RequestStreamHandler { + private static final ResourceConfig jerseyApplication = new ResourceConfig() + // properties to speed up Jersey start time + .property(ServerProperties.FEATURE_AUTO_DISCOVERY_DISABLE,true) + .property(ServerProperties.WADL_FEATURE_DISABLE,true) + .property(ServerProperties.METAINF_SERVICES_LOOKUP_DISABLE,true) + .property(ServerProperties.BV_FEATURE_DISABLE,true) + .property(ServerProperties.JSON_PROCESSING_FEATURE_DISABLE,true) + .property(ServerProperties.MOXY_JSON_FEATURE_DISABLE,true) + .packages("com.amazonaws.serverless.sample.jersey") + .register(JacksonFeature.class); + private static final JerseyLambdaContainerHandler handler + = JerseyLambdaContainerHandler.getAwsProxyHandler(jerseyApplication); + + public StreamLambdaHandler() { + // we enable the timer for debugging. This SHOULD NOT be enabled in production. + Timer.enable(); + } + + @Override + public void handleRequest(InputStream inputStream, OutputStream outputStream, Context context) + throws IOException { + handler.proxyStream(inputStream, outputStream, context); + } +} diff --git a/samples/jersey/pet-store/src/main/java/com/amazonaws/serverless/sample/jersey/model/PetData.java b/samples/jersey/pet-store/src/main/java/com/amazonaws/serverless/sample/jersey/model/PetData.java index fb1ee15c..a5f44461 100644 --- a/samples/jersey/pet-store/src/main/java/com/amazonaws/serverless/sample/jersey/model/PetData.java +++ b/samples/jersey/pet-store/src/main/java/com/amazonaws/serverless/sample/jersey/model/PetData.java @@ -39,17 +39,17 @@ public class PetData { names.add("Daisy"); names.add("Rocky"); names.add("Maggie"); - names.add("Jake "); + names.add("Jake"); names.add("Sophie"); - names.add("Jack "); + names.add("Jack"); names.add("Sadie"); - names.add("Toby "); + names.add("Toby"); names.add("Chloe"); - names.add("Cody "); + names.add("Cody"); names.add("Bailey"); names.add("Buster"); names.add("Lola"); - names.add("Duke "); + names.add("Duke"); names.add("Zoe"); names.add("Cooper"); names.add("Abby"); diff --git a/samples/jersey/pet-store/sam.yaml b/samples/jersey/pet-store/template.yml similarity index 53% rename from samples/jersey/pet-store/sam.yaml rename to samples/jersey/pet-store/template.yml index 4b973809..f16d04a7 100644 --- a/samples/jersey/pet-store/sam.yaml +++ b/samples/jersey/pet-store/template.yml @@ -1,26 +1,32 @@ AWSTemplateFormatVersion: '2010-09-09' Transform: AWS::Serverless-2016-10-31 Description: Example Pet Store API written in jersey with the aws-serverless-java-container library + +Globals: + Api: + # API Gateway regional endpoints + EndpointConfiguration: REGIONAL + Resources: PetStoreFunction: Type: AWS::Serverless::Function Properties: - Handler: com.amazonaws.serverless.sample.jersey.LambdaHandler::handleRequest - Runtime: java8 - CodeUri: target/serverless-jersey-example-1.0-SNAPSHOT.jar + Handler: com.amazonaws.serverless.sample.jersey.StreamLambdaHandler::handleRequest + Runtime: java21 + CodeUri: . MemorySize: 512 Policies: AWSLambdaBasicExecutionRole Timeout: 20 Events: - GetResource: - Type: Api + HttpApiEvent: + Type: HttpApi Properties: - Path: /{proxy+} - Method: any + TimeoutInMillis: 20000 + PayloadFormatVersion: '1.0' Outputs: JerseyPetStoreApi: Description: URL for application - Value: !Sub 'https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/pets' + Value: !Sub 'https://${ServerlessHttpApi}.execute-api.${AWS::Region}.amazonaws.com/pets' Export: Name: JerseyPetStoreApi diff --git a/samples/spark/pet-store/README.md b/samples/spark/pet-store/README.md deleted file mode 100644 index 5d3e34d5..00000000 --- a/samples/spark/pet-store/README.md +++ /dev/null @@ -1,62 +0,0 @@ -# Serverless Spark example -A basic pet store written with the [Spark framework](http://sparkjava.com/). The `LambdaHandler` object is the main entry point for Lambda. - -The application can be deployed in an AWS account using the [Serverless Application Model](https://github.com/awslabs/serverless-application-model). The `sam.yaml` file in the root folder contains the application definition - -## Installation -To build and install the sample application you will need [Maven](https://maven.apache.org/) and the [AWS CLI](https://aws.amazon.com/cli/) installed on your computer. - -In a shell, navigate to the sample's folder and use maven to build a deployable jar. -``` -$ mvn package -``` - -This command should generate a `serverless-spark-example-1.0-SNAPSHOT.jar` in the `target` folder. Now that we have generated the jar file, we can use the AWS CLI to package the template for deployment. - -You will need an S3 bucket to store the artifacts for deployment. Once you have created the S3 bucket, run the following command from the sample's folder: - -``` -$ aws cloudformation package --template-file sam.yaml --output-template-file output-sam.yaml --s3-bucket -Uploading to xxxxxxxxxxxxxxxxxxxxxxxxxx 6464692 / 6464692.0 (100.00%) -Successfully packaged artifacts and wrote output template to file output-sam.yaml. -Execute the following command to deploy the packaged template -aws cloudformation deploy --template-file /your/path/output-sam.yaml --stack-name -``` - -As the command output suggests, you can now use the cli to deploy the application. Choose a stack name and run the `aws cloudformation deploy` command from the output of the package command. - -``` -$ aws cloudformation deploy --template-file output-sam.yaml --stack-name ServerlessSparkSample --capabilities CAPABILITY_IAM -``` - -Once the application is deployed, you can describe the stack to show the API endpoint that was created. The endpoint should be the `SparkPetStoreApi` key of the `Outputs` property: - -``` -$ aws cloudformation describe-stacks --stack-name ServerlessSparkSample -{ - "Stacks": [ - { - "StackId": "arn:aws:cloudformation:us-west-2:xxxxxxxx:stack/SparkSample/xxxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxx", - "Description": "Example Pet Store API written with spark with the aws-serverless-java-container library", - "Tags": [], - "Outputs": [ - { - "Description": "URL for application", - "OutputKey": "PetStoreApi", - "OutputValue": "https://xxxxxxx.execute-api.us-west-2.amazonaws.com/Prod/pets" - } - ], - "CreationTime": "2016-12-13T22:59:31.552Z", - "Capabilities": [ - "CAPABILITY_IAM" - ], - "StackName": "SparkSample", - "NotificationARNs": [], - "StackStatus": "UPDATE_COMPLETE" - } - ] -} - -``` - -Copy the `OutputValue` into a browser to test a first request. diff --git a/samples/spark/pet-store/pom.xml b/samples/spark/pet-store/pom.xml deleted file mode 100644 index 25a91287..00000000 --- a/samples/spark/pet-store/pom.xml +++ /dev/null @@ -1,114 +0,0 @@ - - - 4.0.0 - - com.amazonaws.serverless.sample - serverless-spark-example - 1.0-SNAPSHOT - Spark example for the aws-serverless-java-container library - Simple pet store written with the Spark framework - https://aws.amazon.com/lambda/ - - - https://github.com/awslabs/aws-serverless-java-container.git - - - - - The Apache Software License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt - repo - - - - - 1.8 - 1.8 - 2.8.5 - 2.5.3 - - - - - com.amazonaws.serverless - aws-serverless-java-container-spark - LATEST - - - - com.amazonaws - aws-lambda-java-core - 1.1.0 - - - - com.amazonaws - aws-lambda-java-log4j - 1.0.0 - - - - - com.sparkjava - spark-core - ${spark.version} - - - - - com.fasterxml.jackson.core - jackson-core - ${jackson.version} - - - - - com.fasterxml.jackson.core - jackson-databind - ${jackson.version} - - - - - com.fasterxml.jackson.core - jackson-annotations - ${jackson.version} - - - - org.slf4j - slf4j-log4j12 - 1.7.21 - - - - - - - org.apache.maven.plugins - maven-shade-plugin - 2.3 - - false - - - - package - - shade - - - - - org.eclipse.jetty.websocket:* - - - - - - - - - diff --git a/samples/spark/pet-store/sam.yaml b/samples/spark/pet-store/sam.yaml deleted file mode 100644 index 84a85368..00000000 --- a/samples/spark/pet-store/sam.yaml +++ /dev/null @@ -1,26 +0,0 @@ -AWSTemplateFormatVersion: '2010-09-09' -Transform: AWS::Serverless-2016-10-31 -Description: Example Pet Store API written with spark with the aws-serverless-java-container library -Resources: - PetStoreFunction: - Type: AWS::Serverless::Function - Properties: - Handler: com.amazonaws.serverless.sample.spark.LambdaHandler::handleRequest - Runtime: java8 - CodeUri: target/serverless-spark-example-1.0-SNAPSHOT.jar - MemorySize: 512 - Policies: AWSLambdaBasicExecutionRole - Timeout: 20 - Events: - GetResource: - Type: Api - Properties: - Path: /{proxy+} - Method: any - -Outputs: - SparkPetStoreApi: - Description: URL for application - Value: !Sub 'https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/pets' - Export: - Name: SparkPetStoreApi diff --git a/samples/spark/pet-store/src/main/java/com/amazonaws/serverless/sample/spark/LambdaHandler.java b/samples/spark/pet-store/src/main/java/com/amazonaws/serverless/sample/spark/LambdaHandler.java deleted file mode 100644 index 08ecc151..00000000 --- a/samples/spark/pet-store/src/main/java/com/amazonaws/serverless/sample/spark/LambdaHandler.java +++ /dev/null @@ -1,102 +0,0 @@ -/* - * Copyright 2016 Amazon.com, Inc. or its affiliates. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance - * with the License. A copy of the License is located at - * - * http://aws.amazon.com/apache2.0/ - * - * or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES - * OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions - * and limitations under the License. - */ -package com.amazonaws.serverless.sample.spark; - -import com.amazonaws.serverless.exceptions.ContainerInitializationException; -import com.amazonaws.serverless.proxy.internal.model.AwsProxyRequest; -import com.amazonaws.serverless.proxy.internal.model.AwsProxyResponse; -import com.amazonaws.serverless.proxy.spark.SparkLambdaContainerHandler; -import com.amazonaws.serverless.sample.spark.model.Pet; -import com.amazonaws.serverless.sample.spark.model.PetData; -import com.amazonaws.services.lambda.runtime.Context; -import com.amazonaws.services.lambda.runtime.RequestHandler; -import com.amazonaws.services.lambda.runtime.log4j.LambdaAppender; -import com.fasterxml.jackson.databind.ObjectMapper; -import org.apache.log4j.Logger; - -import javax.ws.rs.core.Response; -import java.util.UUID; - -import static spark.Spark.before; -import static spark.Spark.get; -import static spark.Spark.post; - -public class LambdaHandler implements RequestHandler { - private ObjectMapper objectMapper = new ObjectMapper(); - private boolean isInitialized = false; - private SparkLambdaContainerHandler handler; - - @Override - public AwsProxyResponse handleRequest(AwsProxyRequest awsProxyRequest, Context context) { - if (!isInitialized) { - isInitialized = true; - try { - Logger.getRootLogger().addAppender(new LambdaAppender()); - handler = SparkLambdaContainerHandler.getAwsProxyHandler(); - defineResources(); - } catch (ContainerInitializationException e) { - e.printStackTrace(); - return null; - } - } - return handler.proxy(awsProxyRequest, context); - } - - private void defineResources() { - before((request, response) -> response.type("application/json")); - - post("/pets", (req, res) -> { - Pet newPet = objectMapper.readValue(req.body(), Pet.class); - if (newPet.getName() == null || newPet.getBreed() == null) { - return Response.status(400).entity(new Error("Invalid name or breed")).build(); - } - - Pet dbPet = newPet; - dbPet.setId(UUID.randomUUID().toString()); - - res.status(200); - return dbPet; - }, new JsonTransformer()); - - get("/pets", (req, res) -> { - int limit = 10; - if (req.queryParams("limit") != null) { - limit = Integer.parseInt(req.queryParams("limit")); - } - - Pet[] outputPets = new Pet[limit]; - - for (int i = 0; i < limit; i++) { - Pet newPet = new Pet(); - newPet.setId(UUID.randomUUID().toString()); - newPet.setName(PetData.getRandomName()); - newPet.setBreed(PetData.getRandomBreed()); - newPet.setDateOfBirth(PetData.getRandomDoB()); - outputPets[i] = newPet; - } - - res.status(200); - return outputPets; - }, new JsonTransformer()); - - get("/pets/:petId", (req, res) -> { - Pet newPet = new Pet(); - newPet.setId(UUID.randomUUID().toString()); - newPet.setBreed(PetData.getRandomBreed()); - newPet.setDateOfBirth(PetData.getRandomDoB()); - newPet.setName(PetData.getRandomName()); - res.status(200); - return newPet; - }, new JsonTransformer()); - } -} \ No newline at end of file diff --git a/samples/spring/pet-store/README.md b/samples/spring/pet-store/README.md index 44db0eac..5637b6b6 100644 --- a/samples/spring/pet-store/README.md +++ b/samples/spring/pet-store/README.md @@ -1,62 +1,36 @@ -# Serverless Jersey example -A basic pet store written with the [Spring framework](https://projects.spring.io/spring-framework/). The `LambdaHandler` object is the main entry point for Lambda. +# Serverless Spring example +A basic pet store written with the [Spring framework](https://projects.spring.io/spring-framework/). The `StreamLambdaHandler` object is the main entry point for Lambda. -The application can be deployed in an AWS account using the [Serverless Application Model](https://github.com/awslabs/serverless-application-model). The `sam.yaml` file in the root folder contains the application definition +The application can be deployed in an AWS account using the [Serverless Application Model](https://github.com/awslabs/serverless-application-model). The `template.yml` file in the root folder contains the application definition. -## Installation -To build and install the sample application you will need [Maven](https://maven.apache.org/) and the [AWS CLI](https://aws.amazon.com/cli/) installed on your computer. +## Pre-requisites +* [AWS CLI](https://aws.amazon.com/cli/) +* [SAM CLI](https://github.com/awslabs/aws-sam-cli) +* [Gradle](https://gradle.org/) or [Maven](https://maven.apache.org/) -In a shell, navigate to the sample's folder and use maven to build a deployable jar. +## Deployment +In a shell, navigate to the sample's folder and use the SAM CLI to build a deployable package ``` -$ mvn package +$ sam build ``` -This command should generate a `serverless-spring-example-1.0-SNAPSHOT.jar` in the `target` folder. Now that we have generated the jar file, we can use the AWS CLI to package the template for deployment. +This command compiles the application and prepares a deployment package in the `.aws-sam` sub-directory. -You will need an S3 bucket to store the artifacts for deployment. Once you have created the S3 bucket, run the following command from the sample's folder: +To deploy the application in your AWS account, you can use the SAM CLI's guided deployment process and follow the instructions on the screen ``` -$ aws cloudformation package --template-file sam.yaml --output-template-file output-sam.yaml --s3-bucket -Uploading to xxxxxxxxxxxxxxxxxxxxxxxxxx 6464692 / 6464692.0 (100.00%) -Successfully packaged artifacts and wrote output template to file output-sam.yaml. -Execute the following command to deploy the packaged template -aws cloudformation deploy --template-file /your/path/output-sam.yaml --stack-name +$ sam deploy --guided ``` -As the command output suggests, you can now use the cli to deploy the application. Choose a stack name and run the `aws cloudformation deploy` command from the output of the package command. - -``` -$ aws cloudformation deploy --template-file output-sam.yaml --stack-name ServerlessSpringSample --capabilities CAPABILITY_IAM -``` - -Once the application is deployed, you can describe the stack to show the API endpoint that was created. The endpoint should be the `SparkPetStoreApi` key of the `Outputs` property: - -``` -$ aws cloudformation describe-stacks --stack-name ServerlessSpringSample -{ - "Stacks": [ - { - "StackId": "arn:aws:cloudformation:us-west-2:xxxxxxxx:stack/JerseySample/xxxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxx", - "Description": "Example Pet Store API written with spark with the aws-serverless-java-container library", - "Tags": [], - "Outputs": [ - { - "Description": "URL for application", - "OutputKey": "PetStoreApi", - "OutputValue": "https://xxxxxxx.execute-api.us-west-2.amazonaws.com/Prod/pets" - } - ], - "CreationTime": "2016-12-13T22:59:31.552Z", - "Capabilities": [ - "CAPABILITY_IAM" - ], - "StackName": "JerseySample", - "NotificationARNs": [], - "StackStatus": "UPDATE_COMPLETE" - } - ] -} +Once the deployment is completed, the SAM CLI will print out the stack's outputs, including the new application URL. You can use `curl` or a web browser to make a call to the URL ``` +... +--------------------------------------------------------------------------------------------------------- +OutputKey-Description OutputValue +--------------------------------------------------------------------------------------------------------- +PetStoreApi - URL for application https://xxxxxxxxxx.execute-api.us-west-2.amazonaws.com/pets +--------------------------------------------------------------------------------------------------------- -Copy the `OutputValue` into a browser to test a first request. \ No newline at end of file +$ curl https://xxxxxxxxxx.execute-api.us-west-2.amazonaws.com/pets +``` \ No newline at end of file diff --git a/samples/spring/pet-store/build.gradle b/samples/spring/pet-store/build.gradle new file mode 100644 index 00000000..d2fecf23 --- /dev/null +++ b/samples/spring/pet-store/build.gradle @@ -0,0 +1,29 @@ +apply plugin: 'java' + +repositories { + mavenLocal() + mavenCentral() +} + +dependencies { + implementation ( + 'org.springframework:spring-webmvc:6.2.8', + 'org.springframework:spring-context:6.2.8', + 'com.amazonaws.serverless:aws-serverless-java-container-spring:[2.0-SNAPSHOT,)', + 'org.apache.logging.log4j:log4j-core:2.24.3', + 'org.apache.logging.log4j:log4j-api:2.24.3', + 'org.apache.logging.log4j:log4j-slf4j-impl:2.24.3', + 'com.fasterxml.jackson.core:jackson-databind:2.19.1', + 'com.amazonaws:aws-lambda-java-log4j2:1.6.0', + ) +} + +task buildZip(type: Zip) { + from compileJava + from processResources + into('lib') { + from(configurations.compileClasspath) + } +} + +build.dependsOn buildZip diff --git a/samples/spring/pet-store/pom.xml b/samples/spring/pet-store/pom.xml index b1253302..27a49d37 100644 --- a/samples/spring/pet-store/pom.xml +++ b/samples/spring/pet-store/pom.xml @@ -6,13 +6,13 @@ com.amazonaws.serverless.sample serverless-spring-example - 1.0-SNAPSHOT + 2.0-SNAPSHOT Spring example for the aws-serverless-java-container library Simple pet store written with the Spring framework https://aws.amazon.com/lambda/ - https://github.com/awslabs/aws-serverless-java-container.git + https://github.com/aws/serverless-java-container.git @@ -24,67 +24,173 @@ - 1.8 - 1.8 - 4.3.4.RELEASE - 2.8.4 + 6.2.10 + 2.24.3 + 17 + 17 com.amazonaws.serverless aws-serverless-java-container-spring - LATEST + [2.0-SNAPSHOT,) - com.amazonaws - aws-lambda-java-core - 1.1.0 + org.springframework + spring-webmvc + ${spring.version} - org.springframework - spring-webmvc + spring-context + ${spring.version} + + + + + org.springframework + spring-context-indexer ${spring.version} + true + + + + org.apache.logging.log4j + log4j-core + ${log4j.version} - com.fasterxml.jackson.core - jackson-annotations - ${jackson.version} + org.apache.logging.log4j + log4j-api + ${log4j.version} + - com.fasterxml.jackson.core - jackson-core - ${jackson.version} + org.apache.logging.log4j + log4j-slf4j-impl + ${log4j.version} + - com.fasterxml.jackson.core - jackson-databind - ${jackson.version} + com.amazonaws + aws-lambda-java-log4j2 + 1.6.0 - - - - org.apache.maven.plugins - maven-shade-plugin - 2.3 - - false - - - - package - - shade - - - - - - + + + shaded-jar + + + + org.apache.maven.plugins + maven-shade-plugin + 3.6.0 + + + package + + shade + + + + + + + + + + + + io.github.edwgiz + log4j-maven-shade-plugin-extensions + 2.20.0 + + + + + + + + assembly-zip + + true + + + + + + org.apache.maven.plugins + maven-jar-plugin + 3.4.2 + + + default-jar + none + + + + + org.apache.maven.plugins + maven-install-plugin + 3.1.4 + + true + + + + + org.apache.maven.plugins + maven-dependency-plugin + 3.8.1 + + + copy-dependencies + package + + copy-dependencies + + + ${project.build.directory}/lib + runtime + + + + + + org.apache.maven.plugins + maven-assembly-plugin + 3.7.1 + + + zip-assembly + package + + single + + + ${project.artifactId}-${project.version} + + src${file.separator}assembly${file.separator}bin.xml + + false + + + + + + + + diff --git a/samples/spring/pet-store/src/assembly/bin.xml b/samples/spring/pet-store/src/assembly/bin.xml new file mode 100644 index 00000000..1ffd82d1 --- /dev/null +++ b/samples/spring/pet-store/src/assembly/bin.xml @@ -0,0 +1,24 @@ + + lambda-package + + zip + + false + + + + ${project.build.directory}${file.separator}lib + lib + + + + ${project.build.directory}${file.separator}classes + + ** + + ${file.separator} + + + \ No newline at end of file diff --git a/samples/spring/pet-store/src/main/java/com/amazonaws/serverless/sample/spring/LambdaHandler.java b/samples/spring/pet-store/src/main/java/com/amazonaws/serverless/sample/spring/LambdaHandler.java deleted file mode 100644 index f5cf4473..00000000 --- a/samples/spring/pet-store/src/main/java/com/amazonaws/serverless/sample/spring/LambdaHandler.java +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Copyright 2016 Amazon.com, Inc. or its affiliates. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance - * with the License. A copy of the License is located at - * - * http://aws.amazon.com/apache2.0/ - * - * or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES - * OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions - * and limitations under the License. - */ -package com.amazonaws.serverless.sample.spring; - -import com.amazonaws.serverless.exceptions.ContainerInitializationException; -import com.amazonaws.serverless.proxy.internal.model.AwsProxyRequest; -import com.amazonaws.serverless.proxy.internal.model.AwsProxyResponse; -import com.amazonaws.serverless.proxy.spring.SpringLambdaContainerHandler; -import com.amazonaws.services.lambda.runtime.Context; -import com.amazonaws.services.lambda.runtime.RequestHandler; - -/** - * Created by bulianis on 12/13/16. - */ -public class LambdaHandler implements RequestHandler { - private SpringLambdaContainerHandler handler; - - public AwsProxyResponse handleRequest(AwsProxyRequest awsProxyRequest, Context context) { - if (handler == null) { - try { - handler = SpringLambdaContainerHandler.getAwsProxyHandler(PetStoreSpringAppConfig.class); - } catch (ContainerInitializationException e) { - e.printStackTrace(); - return null; - } - } - return handler.proxy(awsProxyRequest, context); - } -} diff --git a/samples/spring/pet-store/src/main/java/com/amazonaws/serverless/sample/spring/PetStoreSpringAppConfig.java b/samples/spring/pet-store/src/main/java/com/amazonaws/serverless/sample/spring/PetStoreSpringAppConfig.java index 5884d170..5ac5403c 100644 --- a/samples/spring/pet-store/src/main/java/com/amazonaws/serverless/sample/spring/PetStoreSpringAppConfig.java +++ b/samples/spring/pet-store/src/main/java/com/amazonaws/serverless/sample/spring/PetStoreSpringAppConfig.java @@ -12,10 +12,56 @@ */ package com.amazonaws.serverless.sample.spring; -import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.web.servlet.HandlerAdapter; +import org.springframework.web.servlet.HandlerExceptionResolver; +import org.springframework.web.servlet.HandlerMapping; +import org.springframework.web.servlet.ModelAndView; +import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter; +import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + @Configuration -@ComponentScan("com.amazonaws.serverless.sample.spring") +@Import({ PetsController.class }) public class PetStoreSpringAppConfig { + /* + * Create required HandlerMapping, to avoid several default HandlerMapping instances being created + */ + @Bean + public HandlerMapping handlerMapping() { + return new RequestMappingHandlerMapping(); + } + + /* + * Create required HandlerAdapter, to avoid several default HandlerAdapter instances being created + */ + @Bean + public HandlerAdapter handlerAdapter() { + return new RequestMappingHandlerAdapter(); + } + + /* + * optimization - avoids creating default exception resolvers; not required as the serverless container handles + * all exceptions + * + * By default, an ExceptionHandlerExceptionResolver is created which creates many dependent object, including + * an expensive ObjectMapper instance. + * + * To enable custom @ControllerAdvice classes remove this bean. + */ + @Bean + public HandlerExceptionResolver handlerExceptionResolver() { + return new HandlerExceptionResolver() { + + @Override + public ModelAndView resolveException(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { + return null; + } + }; + } } diff --git a/samples/spring/pet-store/src/main/java/com/amazonaws/serverless/sample/spring/PetsController.java b/samples/spring/pet-store/src/main/java/com/amazonaws/serverless/sample/spring/PetsController.java index 9d866041..a84f4232 100644 --- a/samples/spring/pet-store/src/main/java/com/amazonaws/serverless/sample/spring/PetsController.java +++ b/samples/spring/pet-store/src/main/java/com/amazonaws/serverless/sample/spring/PetsController.java @@ -17,6 +17,7 @@ import org.springframework.web.bind.annotation.*; import org.springframework.web.servlet.config.annotation.EnableWebMvc; +import java.security.Principal; import java.util.Optional; import java.util.UUID; @@ -35,7 +36,7 @@ public Pet createPet(@RequestBody Pet newPet) { } @RequestMapping(path = "/pets", method = RequestMethod.GET) - public Pet[] listPets(@RequestParam("limit") Optional limit) { + public Pet[] listPets(@RequestParam("limit") Optional limit, Principal principal) { int queryLimit = 10; if (limit.isPresent()) { queryLimit = limit.get(); diff --git a/samples/spring/pet-store/src/main/java/com/amazonaws/serverless/sample/spring/StreamLambdaHandler.java b/samples/spring/pet-store/src/main/java/com/amazonaws/serverless/sample/spring/StreamLambdaHandler.java index c4bafd05..07675b3f 100644 --- a/samples/spring/pet-store/src/main/java/com/amazonaws/serverless/sample/spring/StreamLambdaHandler.java +++ b/samples/spring/pet-store/src/main/java/com/amazonaws/serverless/sample/spring/StreamLambdaHandler.java @@ -2,44 +2,50 @@ import com.amazonaws.serverless.exceptions.ContainerInitializationException; -import com.amazonaws.serverless.proxy.internal.model.AwsProxyRequest; -import com.amazonaws.serverless.proxy.internal.model.AwsProxyResponse; +import com.amazonaws.serverless.proxy.internal.testutils.Timer; +import com.amazonaws.serverless.proxy.model.AwsProxyRequest; +import com.amazonaws.serverless.proxy.model.AwsProxyResponse; import com.amazonaws.serverless.proxy.spring.SpringLambdaContainerHandler; +import com.amazonaws.serverless.sample.spring.filter.CognitoIdentityFilter; import com.amazonaws.services.lambda.runtime.Context; import com.amazonaws.services.lambda.runtime.RequestStreamHandler; -import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.DispatcherType; +import jakarta.servlet.FilterRegistration; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; +import java.util.EnumSet; -/** - * Created by bulianis on 5/2/17. - */ public class StreamLambdaHandler implements RequestStreamHandler { - private SpringLambdaContainerHandler handler; - private static ObjectMapper mapper = new ObjectMapper(); + private static SpringLambdaContainerHandler handler; + static { + try { + handler = SpringLambdaContainerHandler.getAwsProxyHandler(PetStoreSpringAppConfig.class); + + // we use the onStartup method of the handler to register our custom filter + handler.onStartup(servletContext -> { + FilterRegistration.Dynamic registration = servletContext.addFilter("CognitoIdentityFilter", CognitoIdentityFilter.class); + registration.addMappingForUrlPatterns(EnumSet.of(DispatcherType.REQUEST), false, "/*"); + }); + + } catch (ContainerInitializationException e) { + // if we fail here. We re-throw the exception to force another cold start + e.printStackTrace(); + throw new RuntimeException("Could not initialize Spring framework", e); + } + } + + public StreamLambdaHandler() { + // we enable the timer for debugging. This SHOULD NOT be enabled in production. + Timer.enable(); + } @Override public void handleRequest(InputStream inputStream, OutputStream outputStream, Context context) throws IOException { - if (handler == null) { - try { - handler = SpringLambdaContainerHandler.getAwsProxyHandler(PetStoreSpringAppConfig.class); - } catch (ContainerInitializationException e) { - e.printStackTrace(); - outputStream.close(); - } - } - - AwsProxyRequest request = mapper.readValue(inputStream, AwsProxyRequest.class); - - AwsProxyResponse resp = handler.proxy(request, context); - - mapper.writeValue(outputStream, resp); - // just in case it wasn't closed by the mapper - outputStream.close(); + handler.proxyStream(inputStream, outputStream, context); } } diff --git a/samples/spring/pet-store/src/main/java/com/amazonaws/serverless/sample/spring/filter/CognitoIdentityFilter.java b/samples/spring/pet-store/src/main/java/com/amazonaws/serverless/sample/spring/filter/CognitoIdentityFilter.java new file mode 100644 index 00000000..ec4242b8 --- /dev/null +++ b/samples/spring/pet-store/src/main/java/com/amazonaws/serverless/sample/spring/filter/CognitoIdentityFilter.java @@ -0,0 +1,68 @@ +package com.amazonaws.serverless.sample.spring.filter; + + +import com.amazonaws.serverless.proxy.RequestReader; +import com.amazonaws.serverless.proxy.model.AwsProxyRequestContext; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import jakarta.servlet.Filter; +import jakarta.servlet.FilterChain; +import jakarta.servlet.FilterConfig; +import jakarta.servlet.ServletException; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; + +import java.io.IOException; + + +/** + * Simple Filter implementation that looks for a Cognito identity id in the API Gateway request context + * and stores the value in a request attribute. The filter is registered with aws-serverless-java-container + * in the onStartup method from the {@link com.amazonaws.serverless.sample.spring.StreamLambdaHandler} class. + */ +public class CognitoIdentityFilter implements Filter { + public static final String COGNITO_IDENTITY_ATTRIBUTE = "com.amazonaws.serverless.cognitoId"; + + private static Logger log = LoggerFactory.getLogger(CognitoIdentityFilter.class); + + @Override + public void init(FilterConfig filterConfig) + throws ServletException { + // nothing to do in init + } + + + @Override + public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) + throws IOException, ServletException { + Object apiGwContext = servletRequest.getAttribute(RequestReader.API_GATEWAY_CONTEXT_PROPERTY); + if (apiGwContext == null) { + log.warn("API Gateway context is null"); + filterChain.doFilter(servletRequest, servletResponse); + } + if (!AwsProxyRequestContext.class.isAssignableFrom(apiGwContext.getClass())) { + log.warn("API Gateway context object is not of valid type"); + filterChain.doFilter(servletRequest, servletResponse); + } + + AwsProxyRequestContext ctx = (AwsProxyRequestContext)apiGwContext; + if (ctx.getIdentity() == null) { + log.warn("Identity context is null"); + filterChain.doFilter(servletRequest, servletResponse); + } + String cognitoIdentityId = ctx.getIdentity().getCognitoIdentityId(); + if (cognitoIdentityId == null || "".equals(cognitoIdentityId.trim())) { + log.warn("Cognito identity id in request is null"); + } + servletRequest.setAttribute(COGNITO_IDENTITY_ATTRIBUTE, cognitoIdentityId); + filterChain.doFilter(servletRequest, servletResponse); + } + + + @Override + public void destroy() { + // nothing to do in destroy + } +} diff --git a/samples/spring/pet-store/src/main/java/com/amazonaws/serverless/sample/spring/model/PetData.java b/samples/spring/pet-store/src/main/java/com/amazonaws/serverless/sample/spring/model/PetData.java index b06348d9..d9373df7 100644 --- a/samples/spring/pet-store/src/main/java/com/amazonaws/serverless/sample/spring/model/PetData.java +++ b/samples/spring/pet-store/src/main/java/com/amazonaws/serverless/sample/spring/model/PetData.java @@ -39,17 +39,17 @@ public class PetData { names.add("Daisy"); names.add("Rocky"); names.add("Maggie"); - names.add("Jake "); + names.add("Jake"); names.add("Sophie"); - names.add("Jack "); + names.add("Jack"); names.add("Sadie"); - names.add("Toby "); + names.add("Toby"); names.add("Chloe"); - names.add("Cody "); + names.add("Cody"); names.add("Bailey"); names.add("Buster"); names.add("Lola"); - names.add("Duke "); + names.add("Duke"); names.add("Zoe"); names.add("Cooper"); names.add("Abby"); diff --git a/samples/spring/pet-store/sam.yaml b/samples/spring/pet-store/template.yml similarity index 51% rename from samples/spring/pet-store/sam.yaml rename to samples/spring/pet-store/template.yml index 82d85dbc..34cecbca 100644 --- a/samples/spring/pet-store/sam.yaml +++ b/samples/spring/pet-store/template.yml @@ -1,26 +1,32 @@ AWSTemplateFormatVersion: '2010-09-09' Transform: AWS::Serverless-2016-10-31 Description: Example Pet Store API written with spring with the aws-serverless-java-container library + +Globals: + Api: + # API Gateway regional endpoints + EndpointConfiguration: REGIONAL + Resources: PetStoreFunction: Type: AWS::Serverless::Function Properties: - Handler: com.amazonaws.serverless.sample.spring.LambdaHandler::handleRequest - Runtime: java8 - CodeUri: target/serverless-spring-example-1.0-SNAPSHOT.jar + Handler: com.amazonaws.serverless.sample.spring.StreamLambdaHandler::handleRequest + Runtime: java21 + CodeUri: . MemorySize: 512 Policies: AWSLambdaBasicExecutionRole - Timeout: 20 + Timeout: 30 Events: - GetResource: - Type: Api + HttpApiEvent: + Type: HttpApi Properties: - Path: /{proxy+} - Method: any + TimeoutInMillis: 20000 + PayloadFormatVersion: '1.0' Outputs: SpringPetStoreApi: Description: URL for application - Value: !Sub 'https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/pets' + Value: !Sub 'https://${ServerlessHttpApi}.execute-api.${AWS::Region}.amazonaws.com/pets' Export: Name: SpringPetStoreApi diff --git a/samples/springboot4/alt-pet-store/README.md b/samples/springboot4/alt-pet-store/README.md new file mode 100644 index 00000000..56cfca32 --- /dev/null +++ b/samples/springboot4/alt-pet-store/README.md @@ -0,0 +1,56 @@ +# Serverless Spring Boot 4 example +A basic pet store written with the [Spring Boot 4 framework](https://projects.spring.io/spring-boot/) and Spring Framework 7.0. Unlike older examples, this example is relying on the new +`SpringDelegatingLambdaContainerHandler`, which you simply need to identify as a _handler_ of the Lambda function. The main configuration class identified as `MAIN_CLASS` +environment variable or `Start-Class` or `Main-Class` entry in Manifest file. See provided `template.yml` file for reference. + + +The application can be deployed in an AWS account using the [Serverless Application Model](https://github.com/awslabs/serverless-application-model). The `template.yml` file in the root folder contains the application definition. + +## Pre-requisites +* [AWS CLI](https://aws.amazon.com/cli/) +* [SAM CLI](https://github.com/awslabs/aws-sam-cli) +* [Gradle](https://gradle.org/) or [Maven](https://maven.apache.org/) + +## Deployment +In a shell, navigate to the sample's folder and use the SAM CLI to build a deployable package +``` +$ sam build +``` + +This command compiles the application and prepares a deployment package in the `.aws-sam` sub-directory. + +To deploy the application in your AWS account, you can use the SAM CLI's guided deployment process and follow the instructions on the screen + +``` +$ sam deploy --guided +``` + +Once the deployment is completed, the SAM CLI will print out the stack's outputs, including the new application URL. You can use `curl` or a web browser to make a call to the URL + +``` +... +--------------------------------------------------------------------------------------------------------- +OutputKey-Description OutputValue +--------------------------------------------------------------------------------------------------------- +PetStoreApi - URL for application https://xxxxxxxxxx.execute-api.us-west-2.amazonaws.com/pets +--------------------------------------------------------------------------------------------------------- + +$ curl https://xxxxxxxxxx.execute-api.us-west-2.amazonaws.com/pets +``` + +You can also try a complex request passing both path and request parameters to complex endpoint such as this: + + +``` +@RequestMapping(path = "/foo/{gender}/bar/{age}", method = RequestMethod.POST, produces = MediaType.APPLICATION_JSON_VALUE) +public String complexRequest(@RequestBody String body, + @PathVariable("gender") String gender, + @PathVariable("age") String age, + @RequestParam("name") String name +) +``` +For example. + +``` +curl -d '{"key1":"value1", "key2":"value2"}' -H "Content-Type: application/json" -X POST https://zuhd709386.execute-api.us-east-2.amazonaws.com/foo/male/bar/25?name=Ricky +``` diff --git a/samples/springboot4/alt-pet-store/build.gradle b/samples/springboot4/alt-pet-store/build.gradle new file mode 100644 index 00000000..298feedf --- /dev/null +++ b/samples/springboot4/alt-pet-store/build.gradle @@ -0,0 +1,30 @@ +apply plugin: 'java' + +repositories { + mavenLocal() + mavenCentral() + maven {url "https://repo.spring.io/milestone"} + maven {url "https://repo.spring.io/snapshot"} +} + +dependencies { + implementation ( + implementation('org.springframework.boot:spring-boot-starter-web:3.4.5') { + exclude group: 'org.springframework.boot', module: 'spring-boot-starter-tomcat' + }, + 'com.amazonaws.serverless:aws-serverless-java-container-springboot4:[2.0-SNAPSHOT,)', + 'com.fasterxml.jackson.core:jackson-databind:2.18.2' + ) +} + +task buildZip(type: Zip) { + from compileJava + from processResources + into('lib') { + from(configurations.compileClasspath) { + exclude 'tomcat-embed-*' + } + } +} + +build.dependsOn buildZip diff --git a/samples/springboot4/alt-pet-store/pom.xml b/samples/springboot4/alt-pet-store/pom.xml new file mode 100644 index 00000000..c898b94f --- /dev/null +++ b/samples/springboot4/alt-pet-store/pom.xml @@ -0,0 +1,148 @@ + + + 4.0.0 + + com.amazonaws.serverless.sample + petstore-springboot4-example + 2.0-SNAPSHOT + Spring Boot 4 example for the aws-serverless-java-container library + Simple pet store written with Spring Framework 7.0 and Spring Boot 4.0 + https://aws.amazon.com/lambda/ + + + org.springframework.boot + spring-boot-starter-parent + 4.0.0 + + + + + The Apache Software License, Version 2.0 + http://www.apache.org/licenses/LICENSE-2.0.txt + repo + + + + + 17 + + + + + org.springframework.boot + spring-boot-starter + + + + com.amazonaws.serverless + aws-serverless-java-container-springboot4 + [2.2.0-SNAPSHOT,),[2.1.1,) + + + + + + shaded-jar + + + + org.apache.maven.plugins + maven-shade-plugin + 3.6.0 + + false + + + + package + + shade + + + + + org.apache.tomcat.embed:* + + + + + + + + + + + assembly-zip + + true + + + + + + org.apache.maven.plugins + maven-jar-plugin + 3.4.2 + + + default-jar + none + + + + + org.apache.maven.plugins + maven-install-plugin + 3.1.4 + + true + + + + + org.apache.maven.plugins + maven-dependency-plugin + 3.8.1 + + + copy-dependencies + package + + copy-dependencies + + + ${project.build.directory}/lib + runtime + + + + + + org.apache.maven.plugins + maven-assembly-plugin + 3.7.1 + + + zip-assembly + package + + single + + + ${project.artifactId}-${project.version} + + src${file.separator}assembly${file.separator}bin.xml + + false + + + + + + + + + + + diff --git a/samples/springboot4/alt-pet-store/src/assembly/bin.xml b/samples/springboot4/alt-pet-store/src/assembly/bin.xml new file mode 100644 index 00000000..1e085057 --- /dev/null +++ b/samples/springboot4/alt-pet-store/src/assembly/bin.xml @@ -0,0 +1,27 @@ + + lambda-package + + zip + + false + + + + ${project.build.directory}${file.separator}lib + lib + + tomcat-embed* + + + + + ${project.build.directory}${file.separator}classes + + ** + + ${file.separator} + + + \ No newline at end of file diff --git a/samples/springboot4/alt-pet-store/src/main/java/com/amazonaws/serverless/sample/springboot4/Application.java b/samples/springboot4/alt-pet-store/src/main/java/com/amazonaws/serverless/sample/springboot4/Application.java new file mode 100644 index 00000000..f5d30c51 --- /dev/null +++ b/samples/springboot4/alt-pet-store/src/main/java/com/amazonaws/serverless/sample/springboot4/Application.java @@ -0,0 +1,51 @@ +package com.amazonaws.serverless.sample.springboot4; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.web.servlet.HandlerAdapter; +import org.springframework.web.servlet.HandlerMapping; +import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter; +import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping; + +import com.amazonaws.serverless.sample.springboot4.controller.PetsController; +import com.amazonaws.serverless.sample.springboot4.filter.CognitoIdentityFilter; + +import jakarta.servlet.Filter; + + +@SpringBootApplication +@Import({ PetsController.class }) +public class Application { + + // silence console logging + @Value("${logging.level.root:OFF}") + String message = ""; + + /* + * Create required HandlerMapping, to avoid several default HandlerMapping instances being created + */ + @Bean + public HandlerMapping handlerMapping() { + return new RequestMappingHandlerMapping(); + } + + /* + * Create required HandlerAdapter, to avoid several default HandlerAdapter instances being created + */ + @Bean + public HandlerAdapter handlerAdapter() { + return new RequestMappingHandlerAdapter(); + } + + @Bean("CognitoIdentityFilter") + public Filter cognitoFilter() { + return new CognitoIdentityFilter(); + } + + public static void main(String[] args) { + SpringApplication.run(Application.class, args); + } +} \ No newline at end of file diff --git a/samples/springboot4/alt-pet-store/src/main/java/com/amazonaws/serverless/sample/springboot4/controller/PetsController.java b/samples/springboot4/alt-pet-store/src/main/java/com/amazonaws/serverless/sample/springboot4/controller/PetsController.java new file mode 100644 index 00000000..f31542e5 --- /dev/null +++ b/samples/springboot4/alt-pet-store/src/main/java/com/amazonaws/serverless/sample/springboot4/controller/PetsController.java @@ -0,0 +1,90 @@ +/* + * Copyright 2016 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance + * with the License. A copy of the License is located at + * + * http://aws.amazon.com/apache2.0/ + * + * or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES + * OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions + * and limitations under the License. + */ +package com.amazonaws.serverless.sample.springboot4.controller; + + + +import com.amazonaws.serverless.sample.springboot4.model.Pet; +import com.amazonaws.serverless.sample.springboot4.model.PetData; + +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.config.annotation.EnableWebMvc; + +import java.security.Principal; +import java.util.Optional; +import java.util.UUID; + + +@RestController +@EnableWebMvc +public class PetsController { + + @RequestMapping(path = "/pets", method = RequestMethod.POST) + public Pet createPet(@RequestBody Pet newPet) { + if (newPet.getName() == null || newPet.getBreed() == null) { + return null; + } + + Pet dbPet = newPet; + dbPet.setId(UUID.randomUUID().toString()); + return dbPet; + } + + @RequestMapping(path = "/pets", method = RequestMethod.GET) + public Pet[] listPets(@RequestParam("limit") Optional limit, Principal principal) { + int queryLimit = 10; + if (limit.isPresent()) { + queryLimit = limit.get(); + } + + Pet[] outputPets = new Pet[queryLimit]; + + for (int i = 0; i < queryLimit; i++) { + Pet newPet = new Pet(); + newPet.setId(UUID.randomUUID().toString()); + newPet.setName(PetData.getRandomName()); + newPet.setBreed(PetData.getRandomBreed()); + newPet.setDateOfBirth(PetData.getRandomDoB()); + outputPets[i] = newPet; + } + + return outputPets; + } + + @RequestMapping(path = "/pets/{petId}", method = RequestMethod.GET) + public Pet listPets() { + Pet newPet = new Pet(); + newPet.setId(UUID.randomUUID().toString()); + newPet.setBreed(PetData.getRandomBreed()); + newPet.setDateOfBirth(PetData.getRandomDoB()); + newPet.setName(PetData.getRandomName()); + return newPet; + } + + @RequestMapping(path = "/foo/{gender}/bar/{age}", method = RequestMethod.POST, produces = MediaType.APPLICATION_JSON_VALUE) + public String complexRequest(@RequestBody String body, + @PathVariable("gender") String gender, + @PathVariable("age") String age, + @RequestParam("name") String name + ) { + System.out.println("Body: " + body + " - " + gender + "/" + age + "/" + name); + return gender + "/" + age + "/" + name; + } + +} diff --git a/samples/springboot4/alt-pet-store/src/main/java/com/amazonaws/serverless/sample/springboot4/filter/CognitoIdentityFilter.java b/samples/springboot4/alt-pet-store/src/main/java/com/amazonaws/serverless/sample/springboot4/filter/CognitoIdentityFilter.java new file mode 100644 index 00000000..705683ae --- /dev/null +++ b/samples/springboot4/alt-pet-store/src/main/java/com/amazonaws/serverless/sample/springboot4/filter/CognitoIdentityFilter.java @@ -0,0 +1,69 @@ +package com.amazonaws.serverless.sample.springboot4.filter; + + +import com.amazonaws.serverless.proxy.RequestReader; +import com.amazonaws.serverless.proxy.model.AwsProxyRequestContext; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import jakarta.servlet.Filter; +import jakarta.servlet.FilterChain; +import jakarta.servlet.FilterConfig; +import jakarta.servlet.ServletException; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; + +import java.io.IOException; + + +/** + * Simple Filter implementation that looks for a Cognito identity id in the API Gateway request context + * and stores the value in a request attribute. The filter is registered with aws-serverless-java-container + * in the onStartup method from the {@link com.amazonaws.serverless.sample.springboot4.StreamLambdaHandler} class. + */ +public class CognitoIdentityFilter implements Filter { + public static final String COGNITO_IDENTITY_ATTRIBUTE = "com.amazonaws.serverless.cognitoId"; + + private static Logger log = LoggerFactory.getLogger(CognitoIdentityFilter.class); + + @Override + public void init(FilterConfig filterConfig) + throws ServletException { + // nothing to do in init + } + + + @Override + public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) + throws IOException, ServletException { + Object apiGwContext = servletRequest.getAttribute(RequestReader.API_GATEWAY_CONTEXT_PROPERTY); + if (apiGwContext == null) { + log.warn("API Gateway context is null"); + filterChain.doFilter(servletRequest, servletResponse); + return; + } + if (!AwsProxyRequestContext.class.isAssignableFrom(apiGwContext.getClass())) { + log.warn("API Gateway context object is not of valid type"); + filterChain.doFilter(servletRequest, servletResponse); + } + + AwsProxyRequestContext ctx = (AwsProxyRequestContext)apiGwContext; + if (ctx.getIdentity() == null) { + log.warn("Identity context is null"); + filterChain.doFilter(servletRequest, servletResponse); + } + String cognitoIdentityId = ctx.getIdentity().getCognitoIdentityId(); + if (cognitoIdentityId == null || "".equals(cognitoIdentityId.trim())) { + log.warn("Cognito identity id in request is null"); + } + servletRequest.setAttribute(COGNITO_IDENTITY_ATTRIBUTE, cognitoIdentityId); + filterChain.doFilter(servletRequest, servletResponse); + } + + + @Override + public void destroy() { + // nothing to do in destroy + } +} diff --git a/samples/spark/pet-store/src/main/java/com/amazonaws/serverless/sample/spark/model/Error.java b/samples/springboot4/alt-pet-store/src/main/java/com/amazonaws/serverless/sample/springboot4/model/Error.java similarity index 93% rename from samples/spark/pet-store/src/main/java/com/amazonaws/serverless/sample/spark/model/Error.java rename to samples/springboot4/alt-pet-store/src/main/java/com/amazonaws/serverless/sample/springboot4/model/Error.java index 3577e6a3..ddc63025 100644 --- a/samples/spark/pet-store/src/main/java/com/amazonaws/serverless/sample/spark/model/Error.java +++ b/samples/springboot4/alt-pet-store/src/main/java/com/amazonaws/serverless/sample/springboot4/model/Error.java @@ -10,7 +10,7 @@ * OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions * and limitations under the License. */ -package com.amazonaws.serverless.sample.spark.model; +package com.amazonaws.serverless.sample.springboot4.model; public class Error { private String message; diff --git a/samples/spark/pet-store/src/main/java/com/amazonaws/serverless/sample/spark/model/Pet.java b/samples/springboot4/alt-pet-store/src/main/java/com/amazonaws/serverless/sample/springboot4/model/Pet.java similarity index 95% rename from samples/spark/pet-store/src/main/java/com/amazonaws/serverless/sample/spark/model/Pet.java rename to samples/springboot4/alt-pet-store/src/main/java/com/amazonaws/serverless/sample/springboot4/model/Pet.java index a6f56959..b7e95ca9 100644 --- a/samples/spark/pet-store/src/main/java/com/amazonaws/serverless/sample/spark/model/Pet.java +++ b/samples/springboot4/alt-pet-store/src/main/java/com/amazonaws/serverless/sample/springboot4/model/Pet.java @@ -10,10 +10,11 @@ * OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions * and limitations under the License. */ -package com.amazonaws.serverless.sample.spark.model; +package com.amazonaws.serverless.sample.springboot4.model; import java.util.Date; + public class Pet { private String id; private String breed; diff --git a/samples/spark/pet-store/src/main/java/com/amazonaws/serverless/sample/spark/model/PetData.java b/samples/springboot4/alt-pet-store/src/main/java/com/amazonaws/serverless/sample/springboot4/model/PetData.java similarity index 90% rename from samples/spark/pet-store/src/main/java/com/amazonaws/serverless/sample/spark/model/PetData.java rename to samples/springboot4/alt-pet-store/src/main/java/com/amazonaws/serverless/sample/springboot4/model/PetData.java index a8c45a53..66bdd366 100644 --- a/samples/spark/pet-store/src/main/java/com/amazonaws/serverless/sample/spark/model/PetData.java +++ b/samples/springboot4/alt-pet-store/src/main/java/com/amazonaws/serverless/sample/springboot4/model/PetData.java @@ -10,11 +10,17 @@ * OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions * and limitations under the License. */ -package com.amazonaws.serverless.sample.spark.model; +package com.amazonaws.serverless.sample.springboot4.model; -import java.util.*; + +import java.util.ArrayList; +import java.util.Calendar; +import java.util.Date; +import java.util.GregorianCalendar; +import java.util.List; import java.util.concurrent.ThreadLocalRandom; + public class PetData { private static List breeds = new ArrayList<>(); static { @@ -39,17 +45,17 @@ public class PetData { names.add("Daisy"); names.add("Rocky"); names.add("Maggie"); - names.add("Jake "); + names.add("Jake"); names.add("Sophie"); - names.add("Jack "); + names.add("Jack"); names.add("Sadie"); - names.add("Toby "); + names.add("Toby"); names.add("Chloe"); - names.add("Cody "); + names.add("Cody"); names.add("Bailey"); names.add("Buster"); names.add("Lola"); - names.add("Duke "); + names.add("Duke"); names.add("Zoe"); names.add("Cooper"); names.add("Abby"); diff --git a/samples/springboot4/alt-pet-store/src/main/resources/logback.xml b/samples/springboot4/alt-pet-store/src/main/resources/logback.xml new file mode 100644 index 00000000..81d89177 --- /dev/null +++ b/samples/springboot4/alt-pet-store/src/main/resources/logback.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/samples/springboot4/alt-pet-store/template.yml b/samples/springboot4/alt-pet-store/template.yml new file mode 100644 index 00000000..4a7e8bb4 --- /dev/null +++ b/samples/springboot4/alt-pet-store/template.yml @@ -0,0 +1,41 @@ +AWSTemplateFormatVersion: '2010-09-09' +Transform: AWS::Serverless-2016-10-31 +Description: Example Pet Store API written with SpringBoot4 spring-cloud-function web-proxy support + +Globals: + Api: + # API Gateway regional endpoints + EndpointConfiguration: REGIONAL + +Resources: + PetStoreFunction: + Type: AWS::Serverless::Function + Properties: +# AutoPublishAlias: bcn + FunctionName: pet-store-boot-4 + Handler: com.amazonaws.serverless.proxy.spring.SpringDelegatingLambdaContainerHandler::handleRequest + Runtime: java25 + SnapStart: + ApplyOn: PublishedVersions + CodeUri: . + MemorySize: 1024 + Policies: AWSLambdaBasicExecutionRole + Timeout: 30 + Environment: + Variables: + MAIN_CLASS: com.amazonaws.serverless.sample.springboot4.Application + Events: + HttpApiEvent: + Type: HttpApi + Properties: + TimeoutInMillis: 20000 + PayloadFormatVersion: '1.0' + +Outputs: + SpringPetStoreApi: + Description: URL for application + Value: !Sub 'https://${ServerlessHttpApi}.execute-api.${AWS::Region}.amazonaws.com/pets' + Export: + Name: SpringPetStoreApi + + diff --git a/samples/springboot4/graphql-pet-store/README.md b/samples/springboot4/graphql-pet-store/README.md new file mode 100644 index 00000000..9f1f8db3 --- /dev/null +++ b/samples/springboot4/graphql-pet-store/README.md @@ -0,0 +1,38 @@ +# Serverless Spring Boot 4 with GraphQL example +A basic pet store written with the [Spring Boot 4 framework](https://projects.spring.io/spring-boot/) and Spring Framework 7.0. Unlike older examples, this example uses the [Spring for GraphQl](https://docs.spring.io/spring-graphql/reference/) library. + + +The application can be deployed in an AWS account using the [Serverless Application Model](https://github.com/awslabs/serverless-application-model). The `template.yml` file in the root folder contains the application definition. + +## Pre-requisites +* [AWS CLI](https://aws.amazon.com/cli/) +* [SAM CLI](https://github.com/awslabs/aws-sam-cli) +* [Gradle](https://gradle.org/) or [Maven](https://maven.apache.org/) + +## Deployment +In a shell, navigate to the sample's folder and use the SAM CLI to build a deployable package +``` +$ sam build +``` + +This command compiles the application and prepares a deployment package in the `.aws-sam` sub-directory. + +To deploy the application in your AWS account, you can use the SAM CLI's guided deployment process and follow the instructions on the screen + +``` +$ sam deploy --guided +``` + +Once the deployment is completed, the SAM CLI will print out the stack's outputs, including the new application URL. You can use `curl` to make a call to the URL + +``` +... +--------------------------------------------------------------------------------------------------------- +OutputKey-Description OutputValue +--------------------------------------------------------------------------------------------------------- +PetStoreApi - URL for application https://xxxxxxxxxx.execute-api.us-west-2.amazonaws.com/graphQl +--------------------------------------------------------------------------------------------------------- + +$ curl -X POST https://xxxxxxxxxx.execute-api.us-west-2.amazonaws.com/graphQl -d '{"query":"query petDetails {\n petById(id: \"pet-1\") {\n id\n name\n breed\n owner {\n id\n firstName\n lastName\n }\n }\n}","operationName":"petDetails"}' -H "Content-Type: application/json" + +``` \ No newline at end of file diff --git a/samples/springboot4/graphql-pet-store/pom.xml b/samples/springboot4/graphql-pet-store/pom.xml new file mode 100644 index 00000000..23558694 --- /dev/null +++ b/samples/springboot4/graphql-pet-store/pom.xml @@ -0,0 +1,168 @@ + + + 4.0.0 + + com.amazonaws.serverless.sample + serverless-springboot4-graphql-example + 2.0-SNAPSHOT + Spring Boot 4 GraphQL example for the aws-serverless-java-container library + GraphQL pet store written with Spring Framework 7.0 and Spring Boot 4.0 + https://aws.amazon.com/lambda/ + + + org.springframework.boot + spring-boot-starter-parent + 4.0.0 + + + + + The Apache Software License, Version 2.0 + http://www.apache.org/licenses/LICENSE-2.0.txt + repo + + + + + 17 + + + + + org.springframework.boot + spring-boot-starter-graphql + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-tomcat + + + + + org.springframework.graphql + spring-graphql-test + test + + + com.amazonaws.serverless + aws-serverless-java-container-springboot4 + [2.0.0-SNAPSHOT,),[2.0.0-M1,) + + + + com.fasterxml.jackson.core + jackson-databind + 2.18.2 + + + + + + shaded-jar + + + + org.apache.maven.plugins + maven-shade-plugin + 3.6.0 + + false + + + + package + + shade + + + + + org.apache.tomcat.embed:* + + + + + + + + + + + assembly-zip + + true + + + + + + org.apache.maven.plugins + maven-jar-plugin + 3.4.2 + + + default-jar + none + + + + + org.apache.maven.plugins + maven-install-plugin + 3.1.4 + + true + + + + + org.apache.maven.plugins + maven-dependency-plugin + 3.8.1 + + + copy-dependencies + package + + copy-dependencies + + + ${project.build.directory}/lib + runtime + + + + + + org.apache.maven.plugins + maven-assembly-plugin + 3.7.1 + + + zip-assembly + package + + single + + + ${project.artifactId}-${project.version} + + src${file.separator}assembly${file.separator}bin.xml + + false + + + + + + + + + + + diff --git a/samples/springboot4/graphql-pet-store/src/assembly/bin.xml b/samples/springboot4/graphql-pet-store/src/assembly/bin.xml new file mode 100644 index 00000000..efc312c2 --- /dev/null +++ b/samples/springboot4/graphql-pet-store/src/assembly/bin.xml @@ -0,0 +1,27 @@ + + lambda-package + + zip + + false + + + + ${project.build.directory}${file.separator}lib + lib + + tomcat-embed* + + + + + ${project.build.directory}${file.separator}classes + + ** + + ${file.separator} + + + diff --git a/samples/springboot4/graphql-pet-store/src/main/java/com/amazonaws/serverless/sample/springboot4/Application.java b/samples/springboot4/graphql-pet-store/src/main/java/com/amazonaws/serverless/sample/springboot4/Application.java new file mode 100644 index 00000000..b6036722 --- /dev/null +++ b/samples/springboot4/graphql-pet-store/src/main/java/com/amazonaws/serverless/sample/springboot4/Application.java @@ -0,0 +1,43 @@ +package com.amazonaws.serverless.sample.springboot4; + +import com.amazonaws.serverless.sample.springboot4.controller.PetsController; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.web.servlet.HandlerAdapter; +import org.springframework.web.servlet.HandlerMapping; +import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter; +import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping; + + +@SpringBootApplication +@Import({ PetsController.class }) +public class Application { + + // silence console logging + @Value("${logging.level.root:OFF}") + String message = ""; + + /* + * Create required HandlerMapping, to avoid several default HandlerMapping instances being created + */ + @Bean + public HandlerMapping handlerMapping() { + return new RequestMappingHandlerMapping(); + } + + /* + * Create required HandlerAdapter, to avoid several default HandlerAdapter instances being created + */ + @Bean + public HandlerAdapter handlerAdapter() { + return new RequestMappingHandlerAdapter(); + } + + public static void main(String[] args) { + SpringApplication.run(Application.class, args); + } +} diff --git a/samples/springboot4/graphql-pet-store/src/main/java/com/amazonaws/serverless/sample/springboot4/StreamLambdaHandler.java b/samples/springboot4/graphql-pet-store/src/main/java/com/amazonaws/serverless/sample/springboot4/StreamLambdaHandler.java new file mode 100644 index 00000000..863af635 --- /dev/null +++ b/samples/springboot4/graphql-pet-store/src/main/java/com/amazonaws/serverless/sample/springboot4/StreamLambdaHandler.java @@ -0,0 +1,44 @@ +package com.amazonaws.serverless.sample.springboot4; + + +import com.amazonaws.serverless.exceptions.ContainerInitializationException; +import com.amazonaws.serverless.proxy.internal.testutils.Timer; +import com.amazonaws.serverless.proxy.model.AwsProxyRequest; +import com.amazonaws.serverless.proxy.model.AwsProxyResponse; +import com.amazonaws.serverless.proxy.spring.SpringDelegatingLambdaContainerHandler; +import com.amazonaws.serverless.sample.springboot4.filter.CognitoIdentityFilter; +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestStreamHandler; + +import jakarta.servlet.DispatcherType; +import jakarta.servlet.FilterRegistration; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.EnumSet; + + +public class StreamLambdaHandler implements RequestStreamHandler { + private static SpringDelegatingLambdaContainerHandler handler; + static { + try { + handler = new SpringDelegatingLambdaContainerHandler(Application.class); + } catch (ContainerInitializationException e) { + // if we fail here. We re-throw the exception to force another cold start + e.printStackTrace(); + throw new RuntimeException("Could not initialize Spring Boot application", e); + } + } + + public StreamLambdaHandler() { + // we enable the timer for debugging. This SHOULD NOT be enabled in production. + Timer.enable(); + } + + @Override + public void handleRequest(InputStream inputStream, OutputStream outputStream, Context context) + throws IOException { + handler.handleRequest(inputStream, outputStream, context); + } +} diff --git a/samples/springboot4/graphql-pet-store/src/main/java/com/amazonaws/serverless/sample/springboot4/controller/PetsController.java b/samples/springboot4/graphql-pet-store/src/main/java/com/amazonaws/serverless/sample/springboot4/controller/PetsController.java new file mode 100644 index 00000000..c76a624e --- /dev/null +++ b/samples/springboot4/graphql-pet-store/src/main/java/com/amazonaws/serverless/sample/springboot4/controller/PetsController.java @@ -0,0 +1,21 @@ +package com.amazonaws.serverless.sample.springboot4.controller; + +import org.springframework.graphql.data.method.annotation.Argument; +import org.springframework.graphql.data.method.annotation.QueryMapping; +import org.springframework.graphql.data.method.annotation.SchemaMapping; +import org.springframework.stereotype.Controller; +import com.amazonaws.serverless.sample.springboot4.model.Owner; +import com.amazonaws.serverless.sample.springboot4.model.Pet; + +@Controller +public class PetsController { + @QueryMapping + public Pet petById(@Argument String id) { + return Pet.getById(id); + } + + @SchemaMapping + public Owner owner(Pet pet) { + return Owner.getById(pet.ownerId()); + } +} diff --git a/samples/springboot4/graphql-pet-store/src/main/java/com/amazonaws/serverless/sample/springboot4/filter/CognitoIdentityFilter.java b/samples/springboot4/graphql-pet-store/src/main/java/com/amazonaws/serverless/sample/springboot4/filter/CognitoIdentityFilter.java new file mode 100644 index 00000000..705683ae --- /dev/null +++ b/samples/springboot4/graphql-pet-store/src/main/java/com/amazonaws/serverless/sample/springboot4/filter/CognitoIdentityFilter.java @@ -0,0 +1,69 @@ +package com.amazonaws.serverless.sample.springboot4.filter; + + +import com.amazonaws.serverless.proxy.RequestReader; +import com.amazonaws.serverless.proxy.model.AwsProxyRequestContext; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import jakarta.servlet.Filter; +import jakarta.servlet.FilterChain; +import jakarta.servlet.FilterConfig; +import jakarta.servlet.ServletException; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; + +import java.io.IOException; + + +/** + * Simple Filter implementation that looks for a Cognito identity id in the API Gateway request context + * and stores the value in a request attribute. The filter is registered with aws-serverless-java-container + * in the onStartup method from the {@link com.amazonaws.serverless.sample.springboot4.StreamLambdaHandler} class. + */ +public class CognitoIdentityFilter implements Filter { + public static final String COGNITO_IDENTITY_ATTRIBUTE = "com.amazonaws.serverless.cognitoId"; + + private static Logger log = LoggerFactory.getLogger(CognitoIdentityFilter.class); + + @Override + public void init(FilterConfig filterConfig) + throws ServletException { + // nothing to do in init + } + + + @Override + public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) + throws IOException, ServletException { + Object apiGwContext = servletRequest.getAttribute(RequestReader.API_GATEWAY_CONTEXT_PROPERTY); + if (apiGwContext == null) { + log.warn("API Gateway context is null"); + filterChain.doFilter(servletRequest, servletResponse); + return; + } + if (!AwsProxyRequestContext.class.isAssignableFrom(apiGwContext.getClass())) { + log.warn("API Gateway context object is not of valid type"); + filterChain.doFilter(servletRequest, servletResponse); + } + + AwsProxyRequestContext ctx = (AwsProxyRequestContext)apiGwContext; + if (ctx.getIdentity() == null) { + log.warn("Identity context is null"); + filterChain.doFilter(servletRequest, servletResponse); + } + String cognitoIdentityId = ctx.getIdentity().getCognitoIdentityId(); + if (cognitoIdentityId == null || "".equals(cognitoIdentityId.trim())) { + log.warn("Cognito identity id in request is null"); + } + servletRequest.setAttribute(COGNITO_IDENTITY_ATTRIBUTE, cognitoIdentityId); + filterChain.doFilter(servletRequest, servletResponse); + } + + + @Override + public void destroy() { + // nothing to do in destroy + } +} diff --git a/samples/springboot4/graphql-pet-store/src/main/java/com/amazonaws/serverless/sample/springboot4/model/Owner.java b/samples/springboot4/graphql-pet-store/src/main/java/com/amazonaws/serverless/sample/springboot4/model/Owner.java new file mode 100644 index 00000000..5349a85b --- /dev/null +++ b/samples/springboot4/graphql-pet-store/src/main/java/com/amazonaws/serverless/sample/springboot4/model/Owner.java @@ -0,0 +1,20 @@ +package com.amazonaws.serverless.sample.springboot4.model; + +import java.util.Arrays; +import java.util.List; + +public record Owner (String id, String firstName, String lastName) { + + private static List owners = Arrays.asList( + new Owner("owner-1", "Joshua", "Bloch"), + new Owner("owner-2", "Douglas", "Adams"), + new Owner("owner-3", "Bill", "Bryson") + ); + + public static Owner getById(String id) { + return owners.stream() + .filter(owner -> owner.id().equals(id)) + .findFirst() + .orElse(null); + } +} diff --git a/samples/springboot4/graphql-pet-store/src/main/java/com/amazonaws/serverless/sample/springboot4/model/Pet.java b/samples/springboot4/graphql-pet-store/src/main/java/com/amazonaws/serverless/sample/springboot4/model/Pet.java new file mode 100644 index 00000000..b1419945 --- /dev/null +++ b/samples/springboot4/graphql-pet-store/src/main/java/com/amazonaws/serverless/sample/springboot4/model/Pet.java @@ -0,0 +1,20 @@ +package com.amazonaws.serverless.sample.springboot4.model; + +import java.util.Arrays; +import java.util.List; + +public record Pet (String id, String name, String breed, String ownerId) { + + private static List pets = Arrays.asList( + new Pet("pet-1", "Alpha", "Bulldog", "owner-1"), + new Pet("pet-2", "Max", "German Shepherd", "owner-2"), + new Pet("pet-3", "Rockie", "Golden Retriever", "owner-3") + ); + + public static Pet getById(String id) { + return pets.stream() + .filter(pet -> pet.id().equals(id)) + .findFirst() + .orElse(null); + } +} diff --git a/samples/springboot4/graphql-pet-store/src/main/resources/graphql/schema.graphqls b/samples/springboot4/graphql-pet-store/src/main/resources/graphql/schema.graphqls new file mode 100644 index 00000000..293cdcc4 --- /dev/null +++ b/samples/springboot4/graphql-pet-store/src/main/resources/graphql/schema.graphqls @@ -0,0 +1,16 @@ +type Query { + petById(id: ID): Pet +} + +type Pet { + id: ID + name: String + breed: String + owner: Owner +} + +type Owner { + id: ID + firstName: String + lastName: String +} diff --git a/samples/springboot4/graphql-pet-store/src/main/resources/logback.xml b/samples/springboot4/graphql-pet-store/src/main/resources/logback.xml new file mode 100644 index 00000000..8ff98899 --- /dev/null +++ b/samples/springboot4/graphql-pet-store/src/main/resources/logback.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/samples/springboot4/graphql-pet-store/template.yml b/samples/springboot4/graphql-pet-store/template.yml new file mode 100644 index 00000000..5db3eefd --- /dev/null +++ b/samples/springboot4/graphql-pet-store/template.yml @@ -0,0 +1,35 @@ +AWSTemplateFormatVersion: '2010-09-09' +Transform: AWS::Serverless-2016-10-31 +Description: Example Pet Store API written with SpringBoot4, Spring for GraphQl and the aws-serverless-java-container library + +Globals: + Api: + # API Gateway regional endpoints + EndpointConfiguration: REGIONAL + +Resources: + PetStoreFunction: + Type: AWS::Serverless::Function + Properties: + Handler: com.amazonaws.serverless.sample.springboot4.StreamLambdaHandler::handleRequest + Runtime: java25 + CodeUri: . + MemorySize: 1024 + Policies: AWSLambdaBasicExecutionRole + Timeout: 60 + Environment: + Variables: + MAIN_CLASS: com.amazonaws.serverless.sample.springboot4.Application + Events: + HttpApiEvent: + Type: HttpApi + Properties: + TimeoutInMillis: 20000 + PayloadFormatVersion: '1.0' + +Outputs: + SpringBootPetStoreApi: + Description: URL for application + Value: !Sub 'https://${ServerlessHttpApi}.execute-api.${AWS::Region}.amazonaws.com/graphql' + Export: + Name: SpringBootPetStoreApi diff --git a/samples/springboot4/pet-store-native/.gitignore b/samples/springboot4/pet-store-native/.gitignore new file mode 100644 index 00000000..549e00a2 --- /dev/null +++ b/samples/springboot4/pet-store-native/.gitignore @@ -0,0 +1,33 @@ +HELP.md +target/ +!.mvn/wrapper/maven-wrapper.jar +!**/src/main/**/target/ +!**/src/test/**/target/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ +build/ +!**/src/main/**/build/ +!**/src/test/**/build/ + +### VS Code ### +.vscode/ diff --git a/samples/springboot4/pet-store-native/.mvn/wrapper/maven-wrapper.jar b/samples/springboot4/pet-store-native/.mvn/wrapper/maven-wrapper.jar new file mode 100644 index 00000000..cb28b0e3 Binary files /dev/null and b/samples/springboot4/pet-store-native/.mvn/wrapper/maven-wrapper.jar differ diff --git a/samples/springboot4/pet-store-native/.mvn/wrapper/maven-wrapper.properties b/samples/springboot4/pet-store-native/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 00000000..7d02699a --- /dev/null +++ b/samples/springboot4/pet-store-native/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,18 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.8.7/apache-maven-3.8.7-bin.zip +wrapperUrl=https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar diff --git a/samples/springboot4/pet-store-native/Dockerfile b/samples/springboot4/pet-store-native/Dockerfile new file mode 100644 index 00000000..ec8eb4a0 --- /dev/null +++ b/samples/springboot4/pet-store-native/Dockerfile @@ -0,0 +1,37 @@ +FROM public.ecr.aws/amazonlinux/amazonlinux:2023 + +RUN yum -y update \ + && yum install -y unzip tar gzip bzip2-devel ed gcc gcc-c++ gcc-gfortran \ + less libcurl-devel openssl openssl-devel readline-devel xz-devel \ + zlib-devel glibc-static zlib-static \ + && rm -rf /var/cache/yum + +# Graal VM +ENV GRAAL_VERSION 25.0.1 +ENV ARCHITECTURE aarch64 +ENV GRAAL_FILENAME graalvm-community-jdk-${GRAAL_VERSION}_linux-${ARCHITECTURE}_bin.tar.gz +RUN curl -4 -L https://github.com/graalvm/graalvm-ce-builds/releases/download/jdk-${GRAAL_VERSION}/${GRAAL_FILENAME} | tar -xvz +RUN mv graalvm-community-openjdk-${GRAAL_VERSION}* /usr/lib/graalvm +ENV JAVA_HOME /usr/lib/graalvm + +# Maven +ENV MVN_VERSION 3.9.9 +ENV MVN_FOLDERNAME apache-maven-${MVN_VERSION} +ENV MVN_FILENAME apache-maven-${MVN_VERSION}-bin.tar.gz +RUN curl -4 -L https://archive.apache.org/dist/maven/maven-3/${MVN_VERSION}/binaries/${MVN_FILENAME} | tar -xvz +RUN mv $MVN_FOLDERNAME /usr/lib/maven +RUN ln -s /usr/lib/maven/bin/mvn /usr/bin/mvn + +# Gradle +ENV GRADLE_VERSION 7.4.1 +ENV GRADLE_FOLDERNAME gradle-${GRADLE_VERSION} +ENV GRADLE_FILENAME gradle-${GRADLE_VERSION}-bin.zip +RUN curl -LO https://services.gradle.org/distributions/gradle-${GRADLE_VERSION}-bin.zip +RUN unzip gradle-${GRADLE_VERSION}-bin.zip +RUN mv $GRADLE_FOLDERNAME /usr/lib/gradle +RUN ln -s /usr/lib/gradle/bin/gradle /usr/bin/gradle + +VOLUME /project +WORKDIR /project + +WORKDIR /pet-store-native diff --git a/samples/springboot4/pet-store-native/README.md b/samples/springboot4/pet-store-native/README.md new file mode 100644 index 00000000..57994a4e --- /dev/null +++ b/samples/springboot4/pet-store-native/README.md @@ -0,0 +1,39 @@ +In this sample, you'll build a native GraalVM image for running Spring Boot 4.0 and Spring Framework 7.0 web workloads in AWS Lambda. + +**Important**: Spring Boot 4.0 requires GraalVM 25 for native image compilation. GraalVM 21 is not compatible. + +## To build the sample + +You first need to build the function, then you will deploy it to AWS Lambda. + +Please note that the sample is for `x86` architectures. In case you want to build and run it on ARM, e.g. Apple Mac M1, M2, ... +you must change the according line in the `Dockerfile` to `ENV ARCHITECTURE aarch64`. +In addition, uncomment the `arm64` Architectures section in `template.yml`. + +### Step 1 - Build the native image + +Before starting the build, you must clone or download the code in **pet-store-native**. + +1. Change into the project directory: `samples/springboot4/pet-store-native` +2. Run the following to build a Docker container image with GraalVM 25 which will include all the necessary dependencies to build the application + ``` + docker build -t al2023-graalvm25:native-web . + ``` +3. Build the application within the previously created build image + ``` + docker run -it -v `pwd`:`pwd` -w `pwd` -v ~/.m2:/root/.m2 al2023-graalvm25:native-web mvn clean native:compile -Pnative + ``` +4. After the build finishes, you need to deploy the function: + ``` + sam deploy --guided + ``` + +This will deploy your application and will attach an AWS API Gateway +Once the deployment is finished you should see the following: +``` +Key ServerlessWebNativeApi +Description URL for application +Value https://xxxxxxxx.execute-api.us-east-2.amazonaws.com/pets +``` + +You can now simply execute GET on this URL and see the listing fo all pets. diff --git a/samples/springboot4/pet-store-native/mvnw b/samples/springboot4/pet-store-native/mvnw new file mode 100755 index 00000000..8d937f4c --- /dev/null +++ b/samples/springboot4/pet-store-native/mvnw @@ -0,0 +1,308 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Apache Maven Wrapper startup batch script, version 3.2.0 +# +# Required ENV vars: +# ------------------ +# JAVA_HOME - location of a JDK home dir +# +# Optional ENV vars +# ----------------- +# MAVEN_OPTS - parameters passed to the Java VM when running Maven +# e.g. to debug Maven itself, use +# set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 +# MAVEN_SKIP_RC - flag to disable loading of mavenrc files +# ---------------------------------------------------------------------------- + +if [ -z "$MAVEN_SKIP_RC" ] ; then + + if [ -f /usr/local/etc/mavenrc ] ; then + . /usr/local/etc/mavenrc + fi + + if [ -f /etc/mavenrc ] ; then + . /etc/mavenrc + fi + + if [ -f "$HOME/.mavenrc" ] ; then + . "$HOME/.mavenrc" + fi + +fi + +# OS specific support. $var _must_ be set to either true or false. +cygwin=false; +darwin=false; +mingw=false +case "$(uname)" in + CYGWIN*) cygwin=true ;; + MINGW*) mingw=true;; + Darwin*) darwin=true + # Use /usr/libexec/java_home if available, otherwise fall back to /Library/Java/Home + # See https://developer.apple.com/library/mac/qa/qa1170/_index.html + if [ -z "$JAVA_HOME" ]; then + if [ -x "/usr/libexec/java_home" ]; then + JAVA_HOME="$(/usr/libexec/java_home)"; export JAVA_HOME + else + JAVA_HOME="/Library/Java/Home"; export JAVA_HOME + fi + fi + ;; +esac + +if [ -z "$JAVA_HOME" ] ; then + if [ -r /etc/gentoo-release ] ; then + JAVA_HOME=$(java-config --jre-home) + fi +fi + +# For Cygwin, ensure paths are in UNIX format before anything is touched +if $cygwin ; then + [ -n "$JAVA_HOME" ] && + JAVA_HOME=$(cygpath --unix "$JAVA_HOME") + [ -n "$CLASSPATH" ] && + CLASSPATH=$(cygpath --path --unix "$CLASSPATH") +fi + +# For Mingw, ensure paths are in UNIX format before anything is touched +if $mingw ; then + [ -n "$JAVA_HOME" ] && [ -d "$JAVA_HOME" ] && + JAVA_HOME="$(cd "$JAVA_HOME" || (echo "cannot cd into $JAVA_HOME."; exit 1); pwd)" +fi + +if [ -z "$JAVA_HOME" ]; then + javaExecutable="$(which javac)" + if [ -n "$javaExecutable" ] && ! [ "$(expr "\"$javaExecutable\"" : '\([^ ]*\)')" = "no" ]; then + # readlink(1) is not available as standard on Solaris 10. + readLink=$(which readlink) + if [ ! "$(expr "$readLink" : '\([^ ]*\)')" = "no" ]; then + if $darwin ; then + javaHome="$(dirname "\"$javaExecutable\"")" + javaExecutable="$(cd "\"$javaHome\"" && pwd -P)/javac" + else + javaExecutable="$(readlink -f "\"$javaExecutable\"")" + fi + javaHome="$(dirname "\"$javaExecutable\"")" + javaHome=$(expr "$javaHome" : '\(.*\)/bin') + JAVA_HOME="$javaHome" + export JAVA_HOME + fi + fi +fi + +if [ -z "$JAVACMD" ] ; then + if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + else + JAVACMD="$(\unset -f command 2>/dev/null; \command -v java)" + fi +fi + +if [ ! -x "$JAVACMD" ] ; then + echo "Error: JAVA_HOME is not defined correctly." >&2 + echo " We cannot execute $JAVACMD" >&2 + exit 1 +fi + +if [ -z "$JAVA_HOME" ] ; then + echo "Warning: JAVA_HOME environment variable is not set." +fi + +# traverses directory structure from process work directory to filesystem root +# first directory with .mvn subdirectory is considered project base directory +find_maven_basedir() { + if [ -z "$1" ] + then + echo "Path not specified to find_maven_basedir" + return 1 + fi + + basedir="$1" + wdir="$1" + while [ "$wdir" != '/' ] ; do + if [ -d "$wdir"/.mvn ] ; then + basedir=$wdir + break + fi + # workaround for JBEAP-8937 (on Solaris 10/Sparc) + if [ -d "${wdir}" ]; then + wdir=$(cd "$wdir/.." || exit 1; pwd) + fi + # end of workaround + done + printf '%s' "$(cd "$basedir" || exit 1; pwd)" +} + +# concatenates all lines of a file +concat_lines() { + if [ -f "$1" ]; then + # Remove \r in case we run on Windows within Git Bash + # and check out the repository with auto CRLF management + # enabled. Otherwise, we may read lines that are delimited with + # \r\n and produce $'-Xarg\r' rather than -Xarg due to word + # splitting rules. + tr -s '\r\n' ' ' < "$1" + fi +} + +log() { + if [ "$MVNW_VERBOSE" = true ]; then + printf '%s\n' "$1" + fi +} + +BASE_DIR=$(find_maven_basedir "$(dirname "$0")") +if [ -z "$BASE_DIR" ]; then + exit 1; +fi + +MAVEN_PROJECTBASEDIR=${MAVEN_BASEDIR:-"$BASE_DIR"}; export MAVEN_PROJECTBASEDIR +log "$MAVEN_PROJECTBASEDIR" + +########################################################################################## +# Extension to allow automatically downloading the maven-wrapper.jar from Maven-central +# This allows using the maven wrapper in projects that prohibit checking in binary data. +########################################################################################## +wrapperJarPath="$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar" +if [ -r "$wrapperJarPath" ]; then + log "Found $wrapperJarPath" +else + log "Couldn't find $wrapperJarPath, downloading it ..." + + if [ -n "$MVNW_REPOURL" ]; then + wrapperUrl="$MVNW_REPOURL/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar" + else + wrapperUrl="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar" + fi + while IFS="=" read -r key value; do + # Remove '\r' from value to allow usage on windows as IFS does not consider '\r' as a separator ( considers space, tab, new line ('\n'), and custom '=' ) + safeValue=$(echo "$value" | tr -d '\r') + case "$key" in (wrapperUrl) wrapperUrl="$safeValue"; break ;; + esac + done < "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.properties" + log "Downloading from: $wrapperUrl" + + if $cygwin; then + wrapperJarPath=$(cygpath --path --windows "$wrapperJarPath") + fi + + if command -v wget > /dev/null; then + log "Found wget ... using wget" + [ "$MVNW_VERBOSE" = true ] && QUIET="" || QUIET="--quiet" + if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then + wget $QUIET "$wrapperUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath" + else + wget $QUIET --http-user="$MVNW_USERNAME" --http-password="$MVNW_PASSWORD" "$wrapperUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath" + fi + elif command -v curl > /dev/null; then + log "Found curl ... using curl" + [ "$MVNW_VERBOSE" = true ] && QUIET="" || QUIET="--silent" + if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then + curl $QUIET -o "$wrapperJarPath" "$wrapperUrl" -f -L || rm -f "$wrapperJarPath" + else + curl $QUIET --user "$MVNW_USERNAME:$MVNW_PASSWORD" -o "$wrapperJarPath" "$wrapperUrl" -f -L || rm -f "$wrapperJarPath" + fi + else + log "Falling back to using Java to download" + javaSource="$MAVEN_PROJECTBASEDIR/.mvn/wrapper/MavenWrapperDownloader.java" + javaClass="$MAVEN_PROJECTBASEDIR/.mvn/wrapper/MavenWrapperDownloader.class" + # For Cygwin, switch paths to Windows format before running javac + if $cygwin; then + javaSource=$(cygpath --path --windows "$javaSource") + javaClass=$(cygpath --path --windows "$javaClass") + fi + if [ -e "$javaSource" ]; then + if [ ! -e "$javaClass" ]; then + log " - Compiling MavenWrapperDownloader.java ..." + ("$JAVA_HOME/bin/javac" "$javaSource") + fi + if [ -e "$javaClass" ]; then + log " - Running MavenWrapperDownloader.java ..." + ("$JAVA_HOME/bin/java" -cp .mvn/wrapper MavenWrapperDownloader "$wrapperUrl" "$wrapperJarPath") || rm -f "$wrapperJarPath" + fi + fi + fi +fi +########################################################################################## +# End of extension +########################################################################################## + +# If specified, validate the SHA-256 sum of the Maven wrapper jar file +wrapperSha256Sum="" +while IFS="=" read -r key value; do + case "$key" in (wrapperSha256Sum) wrapperSha256Sum=$value; break ;; + esac +done < "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.properties" +if [ -n "$wrapperSha256Sum" ]; then + wrapperSha256Result=false + if command -v sha256sum > /dev/null; then + if echo "$wrapperSha256Sum $wrapperJarPath" | sha256sum -c > /dev/null 2>&1; then + wrapperSha256Result=true + fi + elif command -v shasum > /dev/null; then + if echo "$wrapperSha256Sum $wrapperJarPath" | shasum -a 256 -c > /dev/null 2>&1; then + wrapperSha256Result=true + fi + else + echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." + echo "Please install either command, or disable validation by removing 'wrapperSha256Sum' from your maven-wrapper.properties." + exit 1 + fi + if [ $wrapperSha256Result = false ]; then + echo "Error: Failed to validate Maven wrapper SHA-256, your Maven wrapper might be compromised." >&2 + echo "Investigate or delete $wrapperJarPath to attempt a clean download." >&2 + echo "If you updated your Maven version, you need to update the specified wrapperSha256Sum property." >&2 + exit 1 + fi +fi + +MAVEN_OPTS="$(concat_lines "$MAVEN_PROJECTBASEDIR/.mvn/jvm.config") $MAVEN_OPTS" + +# For Cygwin, switch paths to Windows format before running java +if $cygwin; then + [ -n "$JAVA_HOME" ] && + JAVA_HOME=$(cygpath --path --windows "$JAVA_HOME") + [ -n "$CLASSPATH" ] && + CLASSPATH=$(cygpath --path --windows "$CLASSPATH") + [ -n "$MAVEN_PROJECTBASEDIR" ] && + MAVEN_PROJECTBASEDIR=$(cygpath --path --windows "$MAVEN_PROJECTBASEDIR") +fi + +# Provide a "standardized" way to retrieve the CLI args that will +# work with both Windows and non-Windows executions. +MAVEN_CMD_LINE_ARGS="$MAVEN_CONFIG $*" +export MAVEN_CMD_LINE_ARGS + +WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain + +# shellcheck disable=SC2086 # safe args +exec "$JAVACMD" \ + $MAVEN_OPTS \ + $MAVEN_DEBUG_OPTS \ + -classpath "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar" \ + "-Dmaven.multiModuleProjectDirectory=${MAVEN_PROJECTBASEDIR}" \ + ${WRAPPER_LAUNCHER} $MAVEN_CONFIG "$@" diff --git a/samples/springboot4/pet-store-native/mvnw.cmd b/samples/springboot4/pet-store-native/mvnw.cmd new file mode 100644 index 00000000..f80fbad3 --- /dev/null +++ b/samples/springboot4/pet-store-native/mvnw.cmd @@ -0,0 +1,205 @@ +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM http://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Apache Maven Wrapper startup batch script, version 3.2.0 +@REM +@REM Required ENV vars: +@REM JAVA_HOME - location of a JDK home dir +@REM +@REM Optional ENV vars +@REM MAVEN_BATCH_ECHO - set to 'on' to enable the echoing of the batch commands +@REM MAVEN_BATCH_PAUSE - set to 'on' to wait for a keystroke before ending +@REM MAVEN_OPTS - parameters passed to the Java VM when running Maven +@REM e.g. to debug Maven itself, use +@REM set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 +@REM MAVEN_SKIP_RC - flag to disable loading of mavenrc files +@REM ---------------------------------------------------------------------------- + +@REM Begin all REM lines with '@' in case MAVEN_BATCH_ECHO is 'on' +@echo off +@REM set title of command window +title %0 +@REM enable echoing by setting MAVEN_BATCH_ECHO to 'on' +@if "%MAVEN_BATCH_ECHO%" == "on" echo %MAVEN_BATCH_ECHO% + +@REM set %HOME% to equivalent of $HOME +if "%HOME%" == "" (set "HOME=%HOMEDRIVE%%HOMEPATH%") + +@REM Execute a user defined script before this one +if not "%MAVEN_SKIP_RC%" == "" goto skipRcPre +@REM check for pre script, once with legacy .bat ending and once with .cmd ending +if exist "%USERPROFILE%\mavenrc_pre.bat" call "%USERPROFILE%\mavenrc_pre.bat" %* +if exist "%USERPROFILE%\mavenrc_pre.cmd" call "%USERPROFILE%\mavenrc_pre.cmd" %* +:skipRcPre + +@setlocal + +set ERROR_CODE=0 + +@REM To isolate internal variables from possible post scripts, we use another setlocal +@setlocal + +@REM ==== START VALIDATION ==== +if not "%JAVA_HOME%" == "" goto OkJHome + +echo. +echo Error: JAVA_HOME not found in your environment. >&2 +echo Please set the JAVA_HOME variable in your environment to match the >&2 +echo location of your Java installation. >&2 +echo. +goto error + +:OkJHome +if exist "%JAVA_HOME%\bin\java.exe" goto init + +echo. +echo Error: JAVA_HOME is set to an invalid directory. >&2 +echo JAVA_HOME = "%JAVA_HOME%" >&2 +echo Please set the JAVA_HOME variable in your environment to match the >&2 +echo location of your Java installation. >&2 +echo. +goto error + +@REM ==== END VALIDATION ==== + +:init + +@REM Find the project base dir, i.e. the directory that contains the folder ".mvn". +@REM Fallback to current working directory if not found. + +set MAVEN_PROJECTBASEDIR=%MAVEN_BASEDIR% +IF NOT "%MAVEN_PROJECTBASEDIR%"=="" goto endDetectBaseDir + +set EXEC_DIR=%CD% +set WDIR=%EXEC_DIR% +:findBaseDir +IF EXIST "%WDIR%"\.mvn goto baseDirFound +cd .. +IF "%WDIR%"=="%CD%" goto baseDirNotFound +set WDIR=%CD% +goto findBaseDir + +:baseDirFound +set MAVEN_PROJECTBASEDIR=%WDIR% +cd "%EXEC_DIR%" +goto endDetectBaseDir + +:baseDirNotFound +set MAVEN_PROJECTBASEDIR=%EXEC_DIR% +cd "%EXEC_DIR%" + +:endDetectBaseDir + +IF NOT EXIST "%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config" goto endReadAdditionalConfig + +@setlocal EnableExtensions EnableDelayedExpansion +for /F "usebackq delims=" %%a in ("%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config") do set JVM_CONFIG_MAVEN_PROPS=!JVM_CONFIG_MAVEN_PROPS! %%a +@endlocal & set JVM_CONFIG_MAVEN_PROPS=%JVM_CONFIG_MAVEN_PROPS% + +:endReadAdditionalConfig + +SET MAVEN_JAVA_EXE="%JAVA_HOME%\bin\java.exe" +set WRAPPER_JAR="%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.jar" +set WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain + +set WRAPPER_URL="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar" + +FOR /F "usebackq tokens=1,2 delims==" %%A IN ("%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.properties") DO ( + IF "%%A"=="wrapperUrl" SET WRAPPER_URL=%%B +) + +@REM Extension to allow automatically downloading the maven-wrapper.jar from Maven-central +@REM This allows using the maven wrapper in projects that prohibit checking in binary data. +if exist %WRAPPER_JAR% ( + if "%MVNW_VERBOSE%" == "true" ( + echo Found %WRAPPER_JAR% + ) +) else ( + if not "%MVNW_REPOURL%" == "" ( + SET WRAPPER_URL="%MVNW_REPOURL%/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar" + ) + if "%MVNW_VERBOSE%" == "true" ( + echo Couldn't find %WRAPPER_JAR%, downloading it ... + echo Downloading from: %WRAPPER_URL% + ) + + powershell -Command "&{"^ + "$webclient = new-object System.Net.WebClient;"^ + "if (-not ([string]::IsNullOrEmpty('%MVNW_USERNAME%') -and [string]::IsNullOrEmpty('%MVNW_PASSWORD%'))) {"^ + "$webclient.Credentials = new-object System.Net.NetworkCredential('%MVNW_USERNAME%', '%MVNW_PASSWORD%');"^ + "}"^ + "[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; $webclient.DownloadFile('%WRAPPER_URL%', '%WRAPPER_JAR%')"^ + "}" + if "%MVNW_VERBOSE%" == "true" ( + echo Finished downloading %WRAPPER_JAR% + ) +) +@REM End of extension + +@REM If specified, validate the SHA-256 sum of the Maven wrapper jar file +SET WRAPPER_SHA_256_SUM="" +FOR /F "usebackq tokens=1,2 delims==" %%A IN ("%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.properties") DO ( + IF "%%A"=="wrapperSha256Sum" SET WRAPPER_SHA_256_SUM=%%B +) +IF NOT %WRAPPER_SHA_256_SUM%=="" ( + powershell -Command "&{"^ + "$hash = (Get-FileHash \"%WRAPPER_JAR%\" -Algorithm SHA256).Hash.ToLower();"^ + "If('%WRAPPER_SHA_256_SUM%' -ne $hash){"^ + " Write-Output 'Error: Failed to validate Maven wrapper SHA-256, your Maven wrapper might be compromised.';"^ + " Write-Output 'Investigate or delete %WRAPPER_JAR% to attempt a clean download.';"^ + " Write-Output 'If you updated your Maven version, you need to update the specified wrapperSha256Sum property.';"^ + " exit 1;"^ + "}"^ + "}" + if ERRORLEVEL 1 goto error +) + +@REM Provide a "standardized" way to retrieve the CLI args that will +@REM work with both Windows and non-Windows executions. +set MAVEN_CMD_LINE_ARGS=%* + +%MAVEN_JAVA_EXE% ^ + %JVM_CONFIG_MAVEN_PROPS% ^ + %MAVEN_OPTS% ^ + %MAVEN_DEBUG_OPTS% ^ + -classpath %WRAPPER_JAR% ^ + "-Dmaven.multiModuleProjectDirectory=%MAVEN_PROJECTBASEDIR%" ^ + %WRAPPER_LAUNCHER% %MAVEN_CONFIG% %* +if ERRORLEVEL 1 goto error +goto end + +:error +set ERROR_CODE=1 + +:end +@endlocal & set ERROR_CODE=%ERROR_CODE% + +if not "%MAVEN_SKIP_RC%"=="" goto skipRcPost +@REM check for post script, once with legacy .bat ending and once with .cmd ending +if exist "%USERPROFILE%\mavenrc_post.bat" call "%USERPROFILE%\mavenrc_post.bat" +if exist "%USERPROFILE%\mavenrc_post.cmd" call "%USERPROFILE%\mavenrc_post.cmd" +:skipRcPost + +@REM pause the script if MAVEN_BATCH_PAUSE is set to 'on' +if "%MAVEN_BATCH_PAUSE%"=="on" pause + +if "%MAVEN_TERMINATE_CMD%"=="on" exit %ERROR_CODE% + +cmd /C exit /B %ERROR_CODE% diff --git a/samples/springboot4/pet-store-native/pom.xml b/samples/springboot4/pet-store-native/pom.xml new file mode 100644 index 00000000..1946401f --- /dev/null +++ b/samples/springboot4/pet-store-native/pom.xml @@ -0,0 +1,140 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 4.0.0 + + + com.amazonaws.serverless.sample + pet-store-native-springboot4 + 0.0.1-SNAPSHOT + pet-store-native-springboot4 + Sample of AWS with Spring Boot 4.0 Native + + 17 + + + + org.springframework.boot + spring-boot-starter + + + com.amazonaws.serverless + aws-serverless-java-container-springboot4 + [2.0.0-SNAPSHOT,),[2.0.0-M1,) + + + + org.crac + crac + runtime + + + com.fasterxml.jackson.core + jackson-databind + 2.18.2 + + + com.amazonaws + aws-lambda-java-events + 3.15.0 + + + com.amazonaws + aws-lambda-java-core + 1.2.3 + provided + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + native + + + + org.springframework.boot + spring-boot-maven-plugin + + -agentlib:native-image-agent=config-merge-dir=src/main/resources/META-INF/native-image/ --enable-preview + + + + + org.graalvm.buildtools + native-maven-plugin + + pet-store-native + + --enable-url-protocols=http + -march=compatibility + + + + + + build + + package + + + test + + test + + test + + + + + maven-assembly-plugin + + + native-zip + package + + single + + false + + + + + src/assembly/native.xml + + + + + + + + + + + spring-snapshots + Spring Snapshots + https://repo.spring.io/snapshot + + true + + + + spring-milestones + Spring Milestones + https://repo.spring.io/milestone + + false + + + + \ No newline at end of file diff --git a/samples/springboot4/pet-store-native/src/assembly/java.xml b/samples/springboot4/pet-store-native/src/assembly/java.xml new file mode 100644 index 00000000..bd4961b5 --- /dev/null +++ b/samples/springboot4/pet-store-native/src/assembly/java.xml @@ -0,0 +1,31 @@ + + java-zip + + zip + + + + + target/classes + / + + + src/shell/java + / + true + 0775 + + bootstrap + + + + + + /lib + false + runtime + + + \ No newline at end of file diff --git a/samples/springboot4/pet-store-native/src/assembly/native.xml b/samples/springboot4/pet-store-native/src/assembly/native.xml new file mode 100644 index 00000000..9bd97a5b --- /dev/null +++ b/samples/springboot4/pet-store-native/src/assembly/native.xml @@ -0,0 +1,29 @@ + + native-zip + + zip + + + + + src/shell/native + / + true + 0775 + + bootstrap + + + + target + / + true + 0775 + + pet-store-native + + + + \ No newline at end of file diff --git a/samples/springboot4/pet-store-native/src/main/java/com/amazonaws/serverless/sample/springboot4/DemoApplication.java b/samples/springboot4/pet-store-native/src/main/java/com/amazonaws/serverless/sample/springboot4/DemoApplication.java new file mode 100644 index 00000000..3f1d4255 --- /dev/null +++ b/samples/springboot4/pet-store-native/src/main/java/com/amazonaws/serverless/sample/springboot4/DemoApplication.java @@ -0,0 +1,12 @@ +package com.amazonaws.serverless.sample.springboot4; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class DemoApplication { + + public static void main(String[] args) throws Exception { + SpringApplication.run(DemoApplication.class, args); + } +} diff --git a/samples/springboot4/pet-store-native/src/main/java/com/amazonaws/serverless/sample/springboot4/HelloController.java b/samples/springboot4/pet-store-native/src/main/java/com/amazonaws/serverless/sample/springboot4/HelloController.java new file mode 100644 index 00000000..4f0abad7 --- /dev/null +++ b/samples/springboot4/pet-store-native/src/main/java/com/amazonaws/serverless/sample/springboot4/HelloController.java @@ -0,0 +1,17 @@ +package com.amazonaws.serverless.sample.springboot4; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class HelloController { + + public HelloController() { + System.out.println("Creating controller"); + } + + @GetMapping("/hello") + public String something(){ + return "Hello World"; + } +} diff --git a/samples/springboot4/pet-store-native/src/main/java/com/amazonaws/serverless/sample/springboot4/controller/PetsController.java b/samples/springboot4/pet-store-native/src/main/java/com/amazonaws/serverless/sample/springboot4/controller/PetsController.java new file mode 100644 index 00000000..7576b789 --- /dev/null +++ b/samples/springboot4/pet-store-native/src/main/java/com/amazonaws/serverless/sample/springboot4/controller/PetsController.java @@ -0,0 +1,82 @@ +/* + * Copyright 2016 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance + * with the License. A copy of the License is located at + * + * http://aws.amazon.com/apache2.0/ + * + * or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES + * OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions + * and limitations under the License. + */ +package com.amazonaws.serverless.sample.springboot4.controller; + + + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.config.annotation.EnableWebMvc; + +import com.amazonaws.serverless.sample.springboot4.model.Pet; +import com.amazonaws.serverless.sample.springboot4.model.PetData; + +import java.security.Principal; +import java.util.Optional; +import java.util.UUID; + + +@RestController +@EnableWebMvc +public class PetsController { + @PostMapping(path = "/pets") + public Pet createPet(@RequestBody Pet newPet) { + System.out.println("==> Creating Pet: " + newPet); + if (newPet.getName() == null || newPet.getBreed() == null) { + return null; + } + + Pet dbPet = newPet; + dbPet.setId(UUID.randomUUID().toString()); + return dbPet; + } + + @GetMapping(path = "/pets") + public Pet[] listPets(@RequestParam("limit") Optional limit, Principal principal) { + System.out.println("==> Listing Pets"); + int queryLimit = 10; + if (limit.isPresent()) { + queryLimit = limit.get(); + } + + Pet[] outputPets = new Pet[queryLimit]; + + for (int i = 0; i < queryLimit; i++) { + Pet newPet = new Pet(); + newPet.setId(UUID.randomUUID().toString()); + newPet.setName(PetData.getRandomName()); + newPet.setBreed(PetData.getRandomBreed()); + newPet.setDateOfBirth(PetData.getRandomDoB()); + outputPets[i] = newPet; + } + + return outputPets; + } + + @GetMapping(path = "/pets/{petId}") + public Pet listPets() { + System.out.println("==> Listing Pets"); + Pet newPet = new Pet(); + newPet.setId(UUID.randomUUID().toString()); + newPet.setBreed(PetData.getRandomBreed()); + newPet.setDateOfBirth(PetData.getRandomDoB()); + newPet.setName(PetData.getRandomName()); + return newPet; + } + +} diff --git a/samples/springboot4/pet-store-native/src/main/java/com/amazonaws/serverless/sample/springboot4/filter/CognitoIdentityFilter.java b/samples/springboot4/pet-store-native/src/main/java/com/amazonaws/serverless/sample/springboot4/filter/CognitoIdentityFilter.java new file mode 100644 index 00000000..705683ae --- /dev/null +++ b/samples/springboot4/pet-store-native/src/main/java/com/amazonaws/serverless/sample/springboot4/filter/CognitoIdentityFilter.java @@ -0,0 +1,69 @@ +package com.amazonaws.serverless.sample.springboot4.filter; + + +import com.amazonaws.serverless.proxy.RequestReader; +import com.amazonaws.serverless.proxy.model.AwsProxyRequestContext; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import jakarta.servlet.Filter; +import jakarta.servlet.FilterChain; +import jakarta.servlet.FilterConfig; +import jakarta.servlet.ServletException; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; + +import java.io.IOException; + + +/** + * Simple Filter implementation that looks for a Cognito identity id in the API Gateway request context + * and stores the value in a request attribute. The filter is registered with aws-serverless-java-container + * in the onStartup method from the {@link com.amazonaws.serverless.sample.springboot4.StreamLambdaHandler} class. + */ +public class CognitoIdentityFilter implements Filter { + public static final String COGNITO_IDENTITY_ATTRIBUTE = "com.amazonaws.serverless.cognitoId"; + + private static Logger log = LoggerFactory.getLogger(CognitoIdentityFilter.class); + + @Override + public void init(FilterConfig filterConfig) + throws ServletException { + // nothing to do in init + } + + + @Override + public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) + throws IOException, ServletException { + Object apiGwContext = servletRequest.getAttribute(RequestReader.API_GATEWAY_CONTEXT_PROPERTY); + if (apiGwContext == null) { + log.warn("API Gateway context is null"); + filterChain.doFilter(servletRequest, servletResponse); + return; + } + if (!AwsProxyRequestContext.class.isAssignableFrom(apiGwContext.getClass())) { + log.warn("API Gateway context object is not of valid type"); + filterChain.doFilter(servletRequest, servletResponse); + } + + AwsProxyRequestContext ctx = (AwsProxyRequestContext)apiGwContext; + if (ctx.getIdentity() == null) { + log.warn("Identity context is null"); + filterChain.doFilter(servletRequest, servletResponse); + } + String cognitoIdentityId = ctx.getIdentity().getCognitoIdentityId(); + if (cognitoIdentityId == null || "".equals(cognitoIdentityId.trim())) { + log.warn("Cognito identity id in request is null"); + } + servletRequest.setAttribute(COGNITO_IDENTITY_ATTRIBUTE, cognitoIdentityId); + filterChain.doFilter(servletRequest, servletResponse); + } + + + @Override + public void destroy() { + // nothing to do in destroy + } +} diff --git a/samples/spark/pet-store/src/main/java/com/amazonaws/serverless/sample/spark/JsonTransformer.java b/samples/springboot4/pet-store-native/src/main/java/com/amazonaws/serverless/sample/springboot4/model/Error.java similarity index 53% rename from samples/spark/pet-store/src/main/java/com/amazonaws/serverless/sample/spark/JsonTransformer.java rename to samples/springboot4/pet-store-native/src/main/java/com/amazonaws/serverless/sample/springboot4/model/Error.java index 55e5e5be..ddc63025 100644 --- a/samples/spark/pet-store/src/main/java/com/amazonaws/serverless/sample/spark/JsonTransformer.java +++ b/samples/springboot4/pet-store-native/src/main/java/com/amazonaws/serverless/sample/springboot4/model/Error.java @@ -10,23 +10,20 @@ * OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions * and limitations under the License. */ -package com.amazonaws.serverless.sample.spark; +package com.amazonaws.serverless.sample.springboot4.model; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; -import spark.ResponseTransformer; +public class Error { + private String message; -public class JsonTransformer implements ResponseTransformer { - - private ObjectMapper mapper = new ObjectMapper(); + public Error(String errorMessage) { + message = errorMessage; + } - @Override - public String render(Object model) { - try { - return mapper.writeValueAsString(model); - } catch (JsonProcessingException e) { - return null; - } + public String getMessage() { + return message; } -} \ No newline at end of file + public void setMessage(String message) { + this.message = message; + } +} diff --git a/samples/springboot4/pet-store-native/src/main/java/com/amazonaws/serverless/sample/springboot4/model/Pet.java b/samples/springboot4/pet-store-native/src/main/java/com/amazonaws/serverless/sample/springboot4/model/Pet.java new file mode 100644 index 00000000..b7e95ca9 --- /dev/null +++ b/samples/springboot4/pet-store-native/src/main/java/com/amazonaws/serverless/sample/springboot4/model/Pet.java @@ -0,0 +1,55 @@ +/* + * Copyright 2016 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance + * with the License. A copy of the License is located at + * + * http://aws.amazon.com/apache2.0/ + * + * or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES + * OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions + * and limitations under the License. + */ +package com.amazonaws.serverless.sample.springboot4.model; + +import java.util.Date; + + +public class Pet { + private String id; + private String breed; + private String name; + private Date dateOfBirth; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getBreed() { + return breed; + } + + public void setBreed(String breed) { + this.breed = breed; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public Date getDateOfBirth() { + return dateOfBirth; + } + + public void setDateOfBirth(Date dateOfBirth) { + this.dateOfBirth = dateOfBirth; + } +} diff --git a/samples/springboot4/pet-store-native/src/main/java/com/amazonaws/serverless/sample/springboot4/model/PetData.java b/samples/springboot4/pet-store-native/src/main/java/com/amazonaws/serverless/sample/springboot4/model/PetData.java new file mode 100644 index 00000000..66bdd366 --- /dev/null +++ b/samples/springboot4/pet-store-native/src/main/java/com/amazonaws/serverless/sample/springboot4/model/PetData.java @@ -0,0 +1,117 @@ +/* + * Copyright 2016 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance + * with the License. A copy of the License is located at + * + * http://aws.amazon.com/apache2.0/ + * + * or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES + * OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions + * and limitations under the License. + */ +package com.amazonaws.serverless.sample.springboot4.model; + + +import java.util.ArrayList; +import java.util.Calendar; +import java.util.Date; +import java.util.GregorianCalendar; +import java.util.List; +import java.util.concurrent.ThreadLocalRandom; + + +public class PetData { + private static List breeds = new ArrayList<>(); + static { + breeds.add("Afghan Hound"); + breeds.add("Beagle"); + breeds.add("Bernese Mountain Dog"); + breeds.add("Bloodhound"); + breeds.add("Dalmatian"); + breeds.add("Jack Russell Terrier"); + breeds.add("Norwegian Elkhound"); + } + + private static List names = new ArrayList<>(); + static { + names.add("Bailey"); + names.add("Bella"); + names.add("Max"); + names.add("Lucy"); + names.add("Charlie"); + names.add("Molly"); + names.add("Buddy"); + names.add("Daisy"); + names.add("Rocky"); + names.add("Maggie"); + names.add("Jake"); + names.add("Sophie"); + names.add("Jack"); + names.add("Sadie"); + names.add("Toby"); + names.add("Chloe"); + names.add("Cody"); + names.add("Bailey"); + names.add("Buster"); + names.add("Lola"); + names.add("Duke"); + names.add("Zoe"); + names.add("Cooper"); + names.add("Abby"); + names.add("Riley"); + names.add("Ginger"); + names.add("Harley"); + names.add("Roxy"); + names.add("Bear"); + names.add("Gracie"); + names.add("Tucker"); + names.add("Coco"); + names.add("Murphy"); + names.add("Sasha"); + names.add("Lucky"); + names.add("Lily"); + names.add("Oliver"); + names.add("Angel"); + names.add("Sam"); + names.add("Princess"); + names.add("Oscar"); + names.add("Emma"); + names.add("Teddy"); + names.add("Annie"); + names.add("Winston"); + names.add("Rosie"); + } + + public static List getBreeds() { + return breeds; + } + + public static List getNames() { + return names; + } + + public static String getRandomBreed() { + return breeds.get(ThreadLocalRandom.current().nextInt(0, breeds.size() - 1)); + } + + public static String getRandomName() { + return names.get(ThreadLocalRandom.current().nextInt(0, names.size() - 1)); + } + + public static Date getRandomDoB() { + GregorianCalendar gc = new GregorianCalendar(); + + int year = ThreadLocalRandom.current().nextInt( + Calendar.getInstance().get(Calendar.YEAR) - 15, + Calendar.getInstance().get(Calendar.YEAR) + ); + + gc.set(Calendar.YEAR, year); + + int dayOfYear = ThreadLocalRandom.current().nextInt(1, gc.getActualMaximum(Calendar.DAY_OF_YEAR)); + + gc.set(Calendar.DAY_OF_YEAR, dayOfYear); + return gc.getTime(); + } +} diff --git a/samples/springboot4/pet-store-native/src/main/resources/META-INF/.gitignore b/samples/springboot4/pet-store-native/src/main/resources/META-INF/.gitignore new file mode 100644 index 00000000..0726bbaa --- /dev/null +++ b/samples/springboot4/pet-store-native/src/main/resources/META-INF/.gitignore @@ -0,0 +1 @@ +/native-image/ diff --git a/samples/springboot4/pet-store-native/src/main/resources/application.properties b/samples/springboot4/pet-store-native/src/main/resources/application.properties new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/samples/springboot4/pet-store-native/src/main/resources/application.properties @@ -0,0 +1 @@ + diff --git a/samples/springboot4/pet-store-native/src/shell/java/bootstrap b/samples/springboot4/pet-store-native/src/shell/java/bootstrap new file mode 100644 index 00000000..e30ee22e --- /dev/null +++ b/samples/springboot4/pet-store-native/src/shell/java/bootstrap @@ -0,0 +1,7 @@ +#!/bin/sh + +cd ${LAMBDA_TASK_ROOT:-.} + +java -Dspring.main.web-application-type=none -Dlogging.level.org.springframework=DEBUG \ + -noverify -XX:TieredStopAtLevel=1 -Xss256K -XX:MaxMetaspaceSize=128M \ + -cp .:`echo lib/*.jar | tr ' ' :` com.amazonaws.serverless.sample.springboot4.DemoApplication \ No newline at end of file diff --git a/samples/springboot4/pet-store-native/src/shell/native/bootstrap b/samples/springboot4/pet-store-native/src/shell/native/bootstrap new file mode 100644 index 00000000..0156b090 --- /dev/null +++ b/samples/springboot4/pet-store-native/src/shell/native/bootstrap @@ -0,0 +1,5 @@ +#!/bin/sh + +cd ${LAMBDA_TASK_ROOT:-.} + +./pet-store-native -Dlogging.level.org.springframework=DEBUG -Dlogging.level.com.amazonaws.serverless.proxy.spring=DEBUG diff --git a/samples/springboot4/pet-store-native/template.yaml b/samples/springboot4/pet-store-native/template.yaml new file mode 100644 index 00000000..d0b63d9a --- /dev/null +++ b/samples/springboot4/pet-store-native/template.yaml @@ -0,0 +1,33 @@ +AWSTemplateFormatVersion: '2010-09-09' +Transform: AWS::Serverless-2016-10-31 +Description: Serverless Java Container GraalVM with Spring Boot 4 +Resources: + ServerlessWebNativeFunction: + Type: AWS::Serverless::Function + Properties: + MemorySize: 512 + FunctionName: pet-store-native-springboot4 + Timeout: 15 + CodeUri: ./target/pet-store-native-springboot4-0.0.1-SNAPSHOT-native-zip.zip + Handler: NOP + Runtime: provided.al2023 + Architectures: + - arm64 + Events: + HttpApiEvent: + Type: HttpApi + Properties: + TimeoutInMillis: 20000 + PayloadFormatVersion: '1.0' + +Globals: + Api: + # API Gateway regional endpoints + EndpointConfiguration: REGIONAL +Outputs: + ServerlessWebNativeApi: + Description: URL for application + Value: !Sub 'https://${ServerlessHttpApi}.execute-api.${AWS::Region}.amazonaws.com/pets' + Export: + Name: ServerlessWebNativeApi + \ No newline at end of file diff --git a/samples/springboot4/pet-store/README.md b/samples/springboot4/pet-store/README.md new file mode 100644 index 00000000..40955e96 --- /dev/null +++ b/samples/springboot4/pet-store/README.md @@ -0,0 +1,36 @@ +# Serverless Spring Boot 4 example +A basic pet store written with the [Spring Boot 4 framework](https://projects.spring.io/spring-boot/) and Spring Framework 7.0. The `StreamLambdaHandler` object is the main entry point for Lambda. + +The application can be deployed in an AWS account using the [Serverless Application Model](https://github.com/awslabs/serverless-application-model). The `template.yml` file in the root folder contains the application definition. + +## Pre-requisites +* [AWS CLI](https://aws.amazon.com/cli/) +* [SAM CLI](https://github.com/awslabs/aws-sam-cli) +* [Gradle](https://gradle.org/) or [Maven](https://maven.apache.org/) + +## Deployment +In a shell, navigate to the sample's folder and use the SAM CLI to build a deployable package +``` +$ sam build +``` + +This command compiles the application and prepares a deployment package in the `.aws-sam` sub-directory. + +To deploy the application in your AWS account, you can use the SAM CLI's guided deployment process and follow the instructions on the screen + +``` +$ sam deploy --guided +``` + +Once the deployment is completed, the SAM CLI will print out the stack's outputs, including the new application URL. You can use `curl` or a web browser to make a call to the URL + +``` +... +--------------------------------------------------------------------------------------------------------- +OutputKey-Description OutputValue +--------------------------------------------------------------------------------------------------------- +PetStoreApi - URL for application https://xxxxxxxxxx.execute-api.us-west-2.amazonaws.com/pets +--------------------------------------------------------------------------------------------------------- + +$ curl https://xxxxxxxxxx.execute-api.us-west-2.amazonaws.com/pets +``` \ No newline at end of file diff --git a/samples/springboot4/pet-store/build.gradle b/samples/springboot4/pet-store/build.gradle new file mode 100644 index 00000000..298feedf --- /dev/null +++ b/samples/springboot4/pet-store/build.gradle @@ -0,0 +1,30 @@ +apply plugin: 'java' + +repositories { + mavenLocal() + mavenCentral() + maven {url "https://repo.spring.io/milestone"} + maven {url "https://repo.spring.io/snapshot"} +} + +dependencies { + implementation ( + implementation('org.springframework.boot:spring-boot-starter-web:3.4.5') { + exclude group: 'org.springframework.boot', module: 'spring-boot-starter-tomcat' + }, + 'com.amazonaws.serverless:aws-serverless-java-container-springboot4:[2.0-SNAPSHOT,)', + 'com.fasterxml.jackson.core:jackson-databind:2.18.2' + ) +} + +task buildZip(type: Zip) { + from compileJava + from processResources + into('lib') { + from(configurations.compileClasspath) { + exclude 'tomcat-embed-*' + } + } +} + +build.dependsOn buildZip diff --git a/samples/springboot4/pet-store/pom.xml b/samples/springboot4/pet-store/pom.xml new file mode 100644 index 00000000..85be6e96 --- /dev/null +++ b/samples/springboot4/pet-store/pom.xml @@ -0,0 +1,154 @@ + + + 4.0.0 + + com.amazonaws.serverless.sample + serverless-springboot4-example + 2.0-SNAPSHOT + Spring Boot 4 example for the aws-serverless-java-container library + Simple pet store written with Spring Framework 7.0 and Spring Boot 4.0 + https://aws.amazon.com/lambda/ + + + org.springframework.boot + spring-boot-starter-parent + 4.0.0 + + + + + The Apache Software License, Version 2.0 + http://www.apache.org/licenses/LICENSE-2.0.txt + repo + + + + + 17 + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-tomcat + + + + + + com.amazonaws.serverless + aws-serverless-java-container-springboot4 + [3.0.0-SNAPSHOT,) + + + + + + shaded-jar + + + + org.apache.maven.plugins + maven-shade-plugin + 3.6.0 + + false + + + + package + + shade + + + + + org.apache.tomcat.embed:* + + + + + + + + + + + assembly-zip + + true + + + + + + org.apache.maven.plugins + maven-jar-plugin + 3.4.2 + + + default-jar + none + + + + + org.apache.maven.plugins + maven-install-plugin + 3.1.4 + + true + + + + + org.apache.maven.plugins + maven-dependency-plugin + 3.8.1 + + + copy-dependencies + package + + copy-dependencies + + + ${project.build.directory}/lib + runtime + + + + + + org.apache.maven.plugins + maven-assembly-plugin + 3.7.1 + + + zip-assembly + package + + single + + + ${project.artifactId}-${project.version} + + src${file.separator}assembly${file.separator}bin.xml + + false + + + + + + + + + + + diff --git a/samples/springboot4/pet-store/src/assembly/bin.xml b/samples/springboot4/pet-store/src/assembly/bin.xml new file mode 100644 index 00000000..1e085057 --- /dev/null +++ b/samples/springboot4/pet-store/src/assembly/bin.xml @@ -0,0 +1,27 @@ + + lambda-package + + zip + + false + + + + ${project.build.directory}${file.separator}lib + lib + + tomcat-embed* + + + + + ${project.build.directory}${file.separator}classes + + ** + + ${file.separator} + + + \ No newline at end of file diff --git a/samples/springboot4/pet-store/src/main/java/com/amazonaws/serverless/sample/springboot4/Application.java b/samples/springboot4/pet-store/src/main/java/com/amazonaws/serverless/sample/springboot4/Application.java new file mode 100644 index 00000000..13a80b32 --- /dev/null +++ b/samples/springboot4/pet-store/src/main/java/com/amazonaws/serverless/sample/springboot4/Application.java @@ -0,0 +1,49 @@ +package com.amazonaws.serverless.sample.springboot4; + +import com.amazonaws.serverless.sample.springboot4.controller.PetsController; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.web.servlet.support.SpringBootServletInitializer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.web.servlet.HandlerAdapter; +import org.springframework.web.servlet.HandlerExceptionResolver; +import org.springframework.web.servlet.HandlerMapping; +import org.springframework.web.servlet.ModelAndView; +import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter; +import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + + +@SpringBootApplication +@Import({ PetsController.class }) +public class Application { + + // silence console logging + @Value("${logging.level.root:OFF}") + String message = ""; + + /* + * Create required HandlerMapping, to avoid several default HandlerMapping instances being created + */ + @Bean + public HandlerMapping handlerMapping() { + return new RequestMappingHandlerMapping(); + } + + /* + * Create required HandlerAdapter, to avoid several default HandlerAdapter instances being created + */ + @Bean + public HandlerAdapter handlerAdapter() { + return new RequestMappingHandlerAdapter(); + } + + public static void main(String[] args) { + SpringApplication.run(Application.class, args); + } +} \ No newline at end of file diff --git a/samples/springboot4/pet-store/src/main/java/com/amazonaws/serverless/sample/springboot4/StreamLambdaHandler.java b/samples/springboot4/pet-store/src/main/java/com/amazonaws/serverless/sample/springboot4/StreamLambdaHandler.java new file mode 100644 index 00000000..61cde4fa --- /dev/null +++ b/samples/springboot4/pet-store/src/main/java/com/amazonaws/serverless/sample/springboot4/StreamLambdaHandler.java @@ -0,0 +1,50 @@ +package com.amazonaws.serverless.sample.springboot4; + + +import com.amazonaws.serverless.exceptions.ContainerInitializationException; +import com.amazonaws.serverless.proxy.internal.testutils.Timer; +import com.amazonaws.serverless.proxy.model.AwsProxyRequest; +import com.amazonaws.serverless.proxy.model.AwsProxyResponse; +import com.amazonaws.serverless.proxy.spring.SpringBootLambdaContainerHandler; +import com.amazonaws.serverless.sample.springboot4.filter.CognitoIdentityFilter; +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestStreamHandler; + +import jakarta.servlet.DispatcherType; +import jakarta.servlet.FilterRegistration; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.EnumSet; + + +public class StreamLambdaHandler implements RequestStreamHandler { + private static SpringBootLambdaContainerHandler handler; + static { + try { + handler = SpringBootLambdaContainerHandler.getAwsProxyHandler(Application.class); + + // we use the onStartup method of the handler to register our custom filter + handler.onStartup(servletContext -> { + FilterRegistration.Dynamic registration = servletContext.addFilter("CognitoIdentityFilter", CognitoIdentityFilter.class); + registration.addMappingForUrlPatterns(EnumSet.of(DispatcherType.REQUEST), true, "/*"); + }); + } catch (ContainerInitializationException e) { + // if we fail here. We re-throw the exception to force another cold start + e.printStackTrace(); + throw new RuntimeException("Could not initialize Spring Boot application", e); + } + } + + public StreamLambdaHandler() { + // we enable the timer for debugging. This SHOULD NOT be enabled in production. + Timer.enable(); + } + + @Override + public void handleRequest(InputStream inputStream, OutputStream outputStream, Context context) + throws IOException { + handler.proxyStream(inputStream, outputStream, context); + } +} diff --git a/samples/springboot4/pet-store/src/main/java/com/amazonaws/serverless/sample/springboot4/controller/PetsController.java b/samples/springboot4/pet-store/src/main/java/com/amazonaws/serverless/sample/springboot4/controller/PetsController.java new file mode 100644 index 00000000..cb80068b --- /dev/null +++ b/samples/springboot4/pet-store/src/main/java/com/amazonaws/serverless/sample/springboot4/controller/PetsController.java @@ -0,0 +1,77 @@ +/* + * Copyright 2016 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance + * with the License. A copy of the License is located at + * + * http://aws.amazon.com/apache2.0/ + * + * or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES + * OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions + * and limitations under the License. + */ +package com.amazonaws.serverless.sample.springboot4.controller; + + + +import com.amazonaws.serverless.sample.springboot4.model.Pet; +import com.amazonaws.serverless.sample.springboot4.model.PetData; + +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.config.annotation.EnableWebMvc; + +import java.security.Principal; +import java.util.Optional; +import java.util.UUID; + + +@RestController +@EnableWebMvc +public class PetsController { + @RequestMapping(path = "/pets", method = RequestMethod.POST) + public Pet createPet(@RequestBody Pet newPet) { + if (newPet.getName() == null || newPet.getBreed() == null) { + return null; + } + + Pet dbPet = newPet; + dbPet.setId(UUID.randomUUID().toString()); + return dbPet; + } + + @RequestMapping(path = "/pets", method = RequestMethod.GET) + public Pet[] listPets(@RequestParam("limit") Optional limit, Principal principal) { + int queryLimit = 10; + if (limit.isPresent()) { + queryLimit = limit.get(); + } + + Pet[] outputPets = new Pet[queryLimit]; + + for (int i = 0; i < queryLimit; i++) { + Pet newPet = new Pet(); + newPet.setId(UUID.randomUUID().toString()); + newPet.setName(PetData.getRandomName()); + newPet.setBreed(PetData.getRandomBreed()); + newPet.setDateOfBirth(PetData.getRandomDoB()); + outputPets[i] = newPet; + } + + return outputPets; + } + + @RequestMapping(path = "/pets/{petId}", method = RequestMethod.GET) + public Pet listPets() { + Pet newPet = new Pet(); + newPet.setId(UUID.randomUUID().toString()); + newPet.setBreed(PetData.getRandomBreed()); + newPet.setDateOfBirth(PetData.getRandomDoB()); + newPet.setName(PetData.getRandomName()); + return newPet; + } + +} diff --git a/samples/springboot4/pet-store/src/main/java/com/amazonaws/serverless/sample/springboot4/filter/CognitoIdentityFilter.java b/samples/springboot4/pet-store/src/main/java/com/amazonaws/serverless/sample/springboot4/filter/CognitoIdentityFilter.java new file mode 100644 index 00000000..705683ae --- /dev/null +++ b/samples/springboot4/pet-store/src/main/java/com/amazonaws/serverless/sample/springboot4/filter/CognitoIdentityFilter.java @@ -0,0 +1,69 @@ +package com.amazonaws.serverless.sample.springboot4.filter; + + +import com.amazonaws.serverless.proxy.RequestReader; +import com.amazonaws.serverless.proxy.model.AwsProxyRequestContext; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import jakarta.servlet.Filter; +import jakarta.servlet.FilterChain; +import jakarta.servlet.FilterConfig; +import jakarta.servlet.ServletException; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; + +import java.io.IOException; + + +/** + * Simple Filter implementation that looks for a Cognito identity id in the API Gateway request context + * and stores the value in a request attribute. The filter is registered with aws-serverless-java-container + * in the onStartup method from the {@link com.amazonaws.serverless.sample.springboot4.StreamLambdaHandler} class. + */ +public class CognitoIdentityFilter implements Filter { + public static final String COGNITO_IDENTITY_ATTRIBUTE = "com.amazonaws.serverless.cognitoId"; + + private static Logger log = LoggerFactory.getLogger(CognitoIdentityFilter.class); + + @Override + public void init(FilterConfig filterConfig) + throws ServletException { + // nothing to do in init + } + + + @Override + public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) + throws IOException, ServletException { + Object apiGwContext = servletRequest.getAttribute(RequestReader.API_GATEWAY_CONTEXT_PROPERTY); + if (apiGwContext == null) { + log.warn("API Gateway context is null"); + filterChain.doFilter(servletRequest, servletResponse); + return; + } + if (!AwsProxyRequestContext.class.isAssignableFrom(apiGwContext.getClass())) { + log.warn("API Gateway context object is not of valid type"); + filterChain.doFilter(servletRequest, servletResponse); + } + + AwsProxyRequestContext ctx = (AwsProxyRequestContext)apiGwContext; + if (ctx.getIdentity() == null) { + log.warn("Identity context is null"); + filterChain.doFilter(servletRequest, servletResponse); + } + String cognitoIdentityId = ctx.getIdentity().getCognitoIdentityId(); + if (cognitoIdentityId == null || "".equals(cognitoIdentityId.trim())) { + log.warn("Cognito identity id in request is null"); + } + servletRequest.setAttribute(COGNITO_IDENTITY_ATTRIBUTE, cognitoIdentityId); + filterChain.doFilter(servletRequest, servletResponse); + } + + + @Override + public void destroy() { + // nothing to do in destroy + } +} diff --git a/samples/springboot4/pet-store/src/main/java/com/amazonaws/serverless/sample/springboot4/model/Error.java b/samples/springboot4/pet-store/src/main/java/com/amazonaws/serverless/sample/springboot4/model/Error.java new file mode 100644 index 00000000..ddc63025 --- /dev/null +++ b/samples/springboot4/pet-store/src/main/java/com/amazonaws/serverless/sample/springboot4/model/Error.java @@ -0,0 +1,29 @@ +/* + * Copyright 2016 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance + * with the License. A copy of the License is located at + * + * http://aws.amazon.com/apache2.0/ + * + * or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES + * OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions + * and limitations under the License. + */ +package com.amazonaws.serverless.sample.springboot4.model; + +public class Error { + private String message; + + public Error(String errorMessage) { + message = errorMessage; + } + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } +} diff --git a/samples/springboot4/pet-store/src/main/java/com/amazonaws/serverless/sample/springboot4/model/Pet.java b/samples/springboot4/pet-store/src/main/java/com/amazonaws/serverless/sample/springboot4/model/Pet.java new file mode 100644 index 00000000..b7e95ca9 --- /dev/null +++ b/samples/springboot4/pet-store/src/main/java/com/amazonaws/serverless/sample/springboot4/model/Pet.java @@ -0,0 +1,55 @@ +/* + * Copyright 2016 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance + * with the License. A copy of the License is located at + * + * http://aws.amazon.com/apache2.0/ + * + * or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES + * OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions + * and limitations under the License. + */ +package com.amazonaws.serverless.sample.springboot4.model; + +import java.util.Date; + + +public class Pet { + private String id; + private String breed; + private String name; + private Date dateOfBirth; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getBreed() { + return breed; + } + + public void setBreed(String breed) { + this.breed = breed; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public Date getDateOfBirth() { + return dateOfBirth; + } + + public void setDateOfBirth(Date dateOfBirth) { + this.dateOfBirth = dateOfBirth; + } +} diff --git a/samples/springboot4/pet-store/src/main/java/com/amazonaws/serverless/sample/springboot4/model/PetData.java b/samples/springboot4/pet-store/src/main/java/com/amazonaws/serverless/sample/springboot4/model/PetData.java new file mode 100644 index 00000000..66bdd366 --- /dev/null +++ b/samples/springboot4/pet-store/src/main/java/com/amazonaws/serverless/sample/springboot4/model/PetData.java @@ -0,0 +1,117 @@ +/* + * Copyright 2016 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance + * with the License. A copy of the License is located at + * + * http://aws.amazon.com/apache2.0/ + * + * or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES + * OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions + * and limitations under the License. + */ +package com.amazonaws.serverless.sample.springboot4.model; + + +import java.util.ArrayList; +import java.util.Calendar; +import java.util.Date; +import java.util.GregorianCalendar; +import java.util.List; +import java.util.concurrent.ThreadLocalRandom; + + +public class PetData { + private static List breeds = new ArrayList<>(); + static { + breeds.add("Afghan Hound"); + breeds.add("Beagle"); + breeds.add("Bernese Mountain Dog"); + breeds.add("Bloodhound"); + breeds.add("Dalmatian"); + breeds.add("Jack Russell Terrier"); + breeds.add("Norwegian Elkhound"); + } + + private static List names = new ArrayList<>(); + static { + names.add("Bailey"); + names.add("Bella"); + names.add("Max"); + names.add("Lucy"); + names.add("Charlie"); + names.add("Molly"); + names.add("Buddy"); + names.add("Daisy"); + names.add("Rocky"); + names.add("Maggie"); + names.add("Jake"); + names.add("Sophie"); + names.add("Jack"); + names.add("Sadie"); + names.add("Toby"); + names.add("Chloe"); + names.add("Cody"); + names.add("Bailey"); + names.add("Buster"); + names.add("Lola"); + names.add("Duke"); + names.add("Zoe"); + names.add("Cooper"); + names.add("Abby"); + names.add("Riley"); + names.add("Ginger"); + names.add("Harley"); + names.add("Roxy"); + names.add("Bear"); + names.add("Gracie"); + names.add("Tucker"); + names.add("Coco"); + names.add("Murphy"); + names.add("Sasha"); + names.add("Lucky"); + names.add("Lily"); + names.add("Oliver"); + names.add("Angel"); + names.add("Sam"); + names.add("Princess"); + names.add("Oscar"); + names.add("Emma"); + names.add("Teddy"); + names.add("Annie"); + names.add("Winston"); + names.add("Rosie"); + } + + public static List getBreeds() { + return breeds; + } + + public static List getNames() { + return names; + } + + public static String getRandomBreed() { + return breeds.get(ThreadLocalRandom.current().nextInt(0, breeds.size() - 1)); + } + + public static String getRandomName() { + return names.get(ThreadLocalRandom.current().nextInt(0, names.size() - 1)); + } + + public static Date getRandomDoB() { + GregorianCalendar gc = new GregorianCalendar(); + + int year = ThreadLocalRandom.current().nextInt( + Calendar.getInstance().get(Calendar.YEAR) - 15, + Calendar.getInstance().get(Calendar.YEAR) + ); + + gc.set(Calendar.YEAR, year); + + int dayOfYear = ThreadLocalRandom.current().nextInt(1, gc.getActualMaximum(Calendar.DAY_OF_YEAR)); + + gc.set(Calendar.DAY_OF_YEAR, dayOfYear); + return gc.getTime(); + } +} diff --git a/samples/springboot4/pet-store/src/main/resources/logback.xml b/samples/springboot4/pet-store/src/main/resources/logback.xml new file mode 100644 index 00000000..14a3a84f --- /dev/null +++ b/samples/springboot4/pet-store/src/main/resources/logback.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/samples/springboot4/pet-store/template.yml b/samples/springboot4/pet-store/template.yml new file mode 100644 index 00000000..789057a5 --- /dev/null +++ b/samples/springboot4/pet-store/template.yml @@ -0,0 +1,35 @@ +AWSTemplateFormatVersion: '2010-09-09' +Transform: AWS::Serverless-2016-10-31 +Description: Example Pet Store API written with SpringBoot4 with the aws-serverless-java-container library + +Globals: + Api: + # API Gateway regional endpoints + EndpointConfiguration: REGIONAL + +Resources: + PetStoreFunction: + Type: AWS::Serverless::Function + Properties: + Handler: com.amazonaws.serverless.proxy.spring.SpringDelegatingLambdaContainerHandler::handleRequest + Runtime: java25 + CodeUri: . + MemorySize: 1512 + Policies: AWSLambdaBasicExecutionRole + Timeout: 60 + Environment: + Variables: + MAIN_CLASS: com.amazonaws.serverless.sample.springboot4.Application + Events: + HttpApiEvent: + Type: HttpApi + Properties: + TimeoutInMillis: 20000 + PayloadFormatVersion: '1.0' + +Outputs: + SpringBootPetStoreApi: + Description: URL for application + Value: !Sub 'https://${ServerlessHttpApi}.execute-api.${AWS::Region}.amazonaws.com/pets' + Export: + Name: SpringBootPetStoreApi diff --git a/spotbugs-excludeFilter.xml b/spotbugs-excludeFilter.xml new file mode 100644 index 00000000..b26b1ad2 --- /dev/null +++ b/spotbugs-excludeFilter.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + \ No newline at end of file