From 346ceb4744749da95668fca0d2de4974faa0d751 Mon Sep 17 00:00:00 2001 From: Oleg Zhurakousky Date: Mon, 6 Mar 2023 18:43:33 +0100 Subject: [PATCH] Add spring-cloud-function-serverless-web module --- .../.jdk8 | 0 .../README.md | 14 + .../pom.xml | 44 + .../sample/pet-store/.aws-sam/build.toml | 13 + .../sample/pet-store/README.md | 38 + .../sample/pet-store/pom.xml | 192 +++ .../sample/pet-store/src/assembly/bin.xml | 24 + .../petstore/PetStoreSpringAppConfig.java | 71 ++ .../oz/spring/petstore/PetsController.java | 80 ++ .../java/oz/spring/petstore/model/Error.java | 33 + .../java/oz/spring/petstore/model/Pet.java | 58 + .../oz/spring/petstore/model/PetData.java | 115 ++ .../sample/pet-store/template.yml | 37 + .../serverless/web/HeaderValueHolder.java | 78 ++ .../web/ProxyHttpServletRequest.java | 1116 +++++++++++++++++ .../web/ProxyHttpServletResponse.java | 601 +++++++++ .../function/serverless/web/ProxyMvc.java | 241 ++++ .../serverless/web/ProxyServletContext.java | 353 ++++++ .../cloud/function/adapter/aws/web/Pet.java | 58 + .../function/adapter/aws/web/PetData.java | 118 ++ .../aws/web/PetStoreSpringAppConfig.java | 69 + .../adapter/aws/web/PetsController.java | 75 ++ .../adapter/aws/web/RequestResponseTests.java | 88 ++ 23 files changed, 3516 insertions(+) create mode 100644 spring-cloud-function-adapters/spring-cloud-function-serverless-web/.jdk8 create mode 100644 spring-cloud-function-adapters/spring-cloud-function-serverless-web/README.md create mode 100644 spring-cloud-function-adapters/spring-cloud-function-serverless-web/pom.xml create mode 100644 spring-cloud-function-adapters/spring-cloud-function-serverless-web/sample/pet-store/.aws-sam/build.toml create mode 100644 spring-cloud-function-adapters/spring-cloud-function-serverless-web/sample/pet-store/README.md create mode 100644 spring-cloud-function-adapters/spring-cloud-function-serverless-web/sample/pet-store/pom.xml create mode 100644 spring-cloud-function-adapters/spring-cloud-function-serverless-web/sample/pet-store/src/assembly/bin.xml create mode 100644 spring-cloud-function-adapters/spring-cloud-function-serverless-web/sample/pet-store/src/main/java/oz/spring/petstore/PetStoreSpringAppConfig.java create mode 100644 spring-cloud-function-adapters/spring-cloud-function-serverless-web/sample/pet-store/src/main/java/oz/spring/petstore/PetsController.java create mode 100644 spring-cloud-function-adapters/spring-cloud-function-serverless-web/sample/pet-store/src/main/java/oz/spring/petstore/model/Error.java create mode 100644 spring-cloud-function-adapters/spring-cloud-function-serverless-web/sample/pet-store/src/main/java/oz/spring/petstore/model/Pet.java create mode 100644 spring-cloud-function-adapters/spring-cloud-function-serverless-web/sample/pet-store/src/main/java/oz/spring/petstore/model/PetData.java create mode 100644 spring-cloud-function-adapters/spring-cloud-function-serverless-web/sample/pet-store/template.yml create mode 100644 spring-cloud-function-adapters/spring-cloud-function-serverless-web/src/main/java/org/springframework/cloud/function/serverless/web/HeaderValueHolder.java create mode 100644 spring-cloud-function-adapters/spring-cloud-function-serverless-web/src/main/java/org/springframework/cloud/function/serverless/web/ProxyHttpServletRequest.java create mode 100644 spring-cloud-function-adapters/spring-cloud-function-serverless-web/src/main/java/org/springframework/cloud/function/serverless/web/ProxyHttpServletResponse.java create mode 100644 spring-cloud-function-adapters/spring-cloud-function-serverless-web/src/main/java/org/springframework/cloud/function/serverless/web/ProxyMvc.java create mode 100644 spring-cloud-function-adapters/spring-cloud-function-serverless-web/src/main/java/org/springframework/cloud/function/serverless/web/ProxyServletContext.java create mode 100644 spring-cloud-function-adapters/spring-cloud-function-serverless-web/src/test/java/org/springframework/cloud/function/adapter/aws/web/Pet.java create mode 100644 spring-cloud-function-adapters/spring-cloud-function-serverless-web/src/test/java/org/springframework/cloud/function/adapter/aws/web/PetData.java create mode 100644 spring-cloud-function-adapters/spring-cloud-function-serverless-web/src/test/java/org/springframework/cloud/function/adapter/aws/web/PetStoreSpringAppConfig.java create mode 100644 spring-cloud-function-adapters/spring-cloud-function-serverless-web/src/test/java/org/springframework/cloud/function/adapter/aws/web/PetsController.java create mode 100644 spring-cloud-function-adapters/spring-cloud-function-serverless-web/src/test/java/org/springframework/cloud/function/adapter/aws/web/RequestResponseTests.java diff --git a/spring-cloud-function-adapters/spring-cloud-function-serverless-web/.jdk8 b/spring-cloud-function-adapters/spring-cloud-function-serverless-web/.jdk8 new file mode 100644 index 000000000..e69de29bb diff --git a/spring-cloud-function-adapters/spring-cloud-function-serverless-web/README.md b/spring-cloud-function-adapters/spring-cloud-function-serverless-web/README.md new file mode 100644 index 000000000..b5f51b0ec --- /dev/null +++ b/spring-cloud-function-adapters/spring-cloud-function-serverless-web/README.md @@ -0,0 +1,14 @@ +#### Introduction + +This module represents a concept of a light weight AWS forwarding proxy which deploys and interacts with existing +Spring Boot web application deployed as AWS Lambda. + + +A sample is provided in [sample](https://github.com/spring-cloud/spring-cloud-function/tree/serverless-web/spring-cloud-function-adapters/spring-cloud-function-adapter-aws-web/sample/pet-store) directory. It contain README and SAM template file to simplify the deployment. This module is identified as the only additional dependnecy to the existing web-app. + +_NOTE: Although this module is AWS specific, this dependency is protocol only (not binary), therefore there is no AWS dependnecies._ + +The aformentioned proxy is identified as AWS Lambda [handler](https://github.com/spring-cloud/spring-cloud-function/blob/serverless-web/spring-cloud-function-adapters/spring-cloud-function-adapter-aws-web/sample/pet-store/template.yml#L14) + +The main Spring Boot configuration file is identified as [MAIN_CLASS](https://github.com/spring-cloud/spring-cloud-function/blob/serverless-web/spring-cloud-function-adapters/spring-cloud-function-adapter-aws-web/sample/pet-store/template.yml#L22) + diff --git a/spring-cloud-function-adapters/spring-cloud-function-serverless-web/pom.xml b/spring-cloud-function-adapters/spring-cloud-function-serverless-web/pom.xml new file mode 100644 index 000000000..9fdb644a0 --- /dev/null +++ b/spring-cloud-function-adapters/spring-cloud-function-serverless-web/pom.xml @@ -0,0 +1,44 @@ + + + 4.0.0 + spring-cloud-function-serverless-web + jar + spring-cloud-function-serverless-web + Base serverless web adapter + + org.springframework.cloud + spring-cloud-function-adapter-parent + 3.2.9-SNAPSHOT + + + UTF-8 + UTF-8 + 1.8 + + + + com.fasterxml.jackson.core + jackson-databind + + + org.springframework + spring-webmvc + + + javax.servlet + javax.servlet-api + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.boot + spring-boot-starter-web + test + + + diff --git a/spring-cloud-function-adapters/spring-cloud-function-serverless-web/sample/pet-store/.aws-sam/build.toml b/spring-cloud-function-adapters/spring-cloud-function-serverless-web/sample/pet-store/.aws-sam/build.toml new file mode 100644 index 000000000..05c08b5fa --- /dev/null +++ b/spring-cloud-function-adapters/spring-cloud-function-serverless-web/sample/pet-store/.aws-sam/build.toml @@ -0,0 +1,13 @@ +# This file is auto generated by SAM CLI build command + +[function_build_definitions] +[function_build_definitions.9341c1d5-9265-48ef-836e-25df000b0c59] +codeuri = "/Users/ozhurakousky/Documents/dev/repo/spring-cloud-function/spring-cloud-function-adapters/spring-cloud-function-adapter-aws-web/sample/pet-store" +runtime = "java11" +architecture = "x86_64" +handler = "org.springframework.cloud.function.adapter.aws.web.WebProxyInvoker::handleRequest" +manifest_hash = "" +packagetype = "Zip" +functions = ["PetStoreFunction"] + +[layer_build_definitions] diff --git a/spring-cloud-function-adapters/spring-cloud-function-serverless-web/sample/pet-store/README.md b/spring-cloud-function-adapters/spring-cloud-function-serverless-web/sample/pet-store/README.md new file mode 100644 index 000000000..bbe5db289 --- /dev/null +++ b/spring-cloud-function-adapters/spring-cloud-function-serverless-web/sample/pet-store/README.md @@ -0,0 +1,38 @@ +Copied from https://github.com/awslabs/aws-serverless-java-container/tree/main/samples/spring/pet-store + +# 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 `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/spring-cloud-function-adapters/spring-cloud-function-serverless-web/sample/pet-store/pom.xml b/spring-cloud-function-adapters/spring-cloud-function-serverless-web/sample/pet-store/pom.xml new file mode 100644 index 000000000..59aa050ae --- /dev/null +++ b/spring-cloud-function-adapters/spring-cloud-function-serverless-web/sample/pet-store/pom.xml @@ -0,0 +1,192 @@ + + + 4.0.0 + + oz.spring.petstore + pet-store + 1.0-SNAPSHOT + pet-store + Simple pet store written with the Spring 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 + 5.3.25 + 4.13.2 + 2.19.0 + + + + + org.springframework.cloud + spring-cloud-function-adapter-aws-web + 3.2.9-SNAPSHOT + + + + 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.5.1 + + + + junit + junit + ${junit.version} + test + + + + + + shaded-jar + + + + org.apache.maven.plugins + maven-shade-plugin + 3.2.4 + + + package + + shade + + + false + + + + + + + + + + com.github.edwgiz + maven-shade-plugin.log4j2-cachefile-transformer + 2.8.1 + + + + + + + + assembly-zip + + true + + + + + + org.apache.maven.plugins + maven-jar-plugin + 3.2.0 + + + default-jar + none + + + + + org.apache.maven.plugins + maven-install-plugin + 3.0.0-M1 + + true + + + + + org.apache.maven.plugins + maven-dependency-plugin + 3.2.0 + + + copy-dependencies + package + + copy-dependencies + + + ${project.build.directory}/lib + runtime + + + + + + org.apache.maven.plugins + maven-assembly-plugin + 3.3.0 + + + zip-assembly + package + + single + + + ${project.artifactId}-${project.version} + + src${file.separator}assembly${file.separator}bin.xml + + false + + + + + + + + + diff --git a/spring-cloud-function-adapters/spring-cloud-function-serverless-web/sample/pet-store/src/assembly/bin.xml b/spring-cloud-function-adapters/spring-cloud-function-serverless-web/sample/pet-store/src/assembly/bin.xml new file mode 100644 index 000000000..1ffd82d1c --- /dev/null +++ b/spring-cloud-function-adapters/spring-cloud-function-serverless-web/sample/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/spring-cloud-function-adapters/spring-cloud-function-serverless-web/sample/pet-store/src/main/java/oz/spring/petstore/PetStoreSpringAppConfig.java b/spring-cloud-function-adapters/spring-cloud-function-serverless-web/sample/pet-store/src/main/java/oz/spring/petstore/PetStoreSpringAppConfig.java new file mode 100644 index 000000000..3969ea641 --- /dev/null +++ b/spring-cloud-function-adapters/spring-cloud-function-serverless-web/sample/pet-store/src/main/java/oz/spring/petstore/PetStoreSpringAppConfig.java @@ -0,0 +1,71 @@ +/* + * Copyright 2023-2023 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 oz.spring.petstore; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +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; + + +@Configuration +@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/spring-cloud-function-adapters/spring-cloud-function-serverless-web/sample/pet-store/src/main/java/oz/spring/petstore/PetsController.java b/spring-cloud-function-adapters/spring-cloud-function-serverless-web/sample/pet-store/src/main/java/oz/spring/petstore/PetsController.java new file mode 100644 index 000000000..b04a26d92 --- /dev/null +++ b/spring-cloud-function-adapters/spring-cloud-function-serverless-web/sample/pet-store/src/main/java/oz/spring/petstore/PetsController.java @@ -0,0 +1,80 @@ +/* + * Copyright 2023-2023 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 oz.spring.petstore; + +import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.config.annotation.EnableWebMvc; + +import oz.spring.petstore.model.Pet; +import oz.spring.petstore.model.PetData; + +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; + } + + @GetMapping("favicon.ico") + @ResponseBody + void returnNoFavicon() { + } + + @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/spring-cloud-function-adapters/spring-cloud-function-serverless-web/sample/pet-store/src/main/java/oz/spring/petstore/model/Error.java b/spring-cloud-function-adapters/spring-cloud-function-serverless-web/sample/pet-store/src/main/java/oz/spring/petstore/model/Error.java new file mode 100644 index 000000000..bb19a9027 --- /dev/null +++ b/spring-cloud-function-adapters/spring-cloud-function-serverless-web/sample/pet-store/src/main/java/oz/spring/petstore/model/Error.java @@ -0,0 +1,33 @@ +/* + * Copyright 2023-2023 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 oz.spring.petstore.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/spring-cloud-function-adapters/spring-cloud-function-serverless-web/sample/pet-store/src/main/java/oz/spring/petstore/model/Pet.java b/spring-cloud-function-adapters/spring-cloud-function-serverless-web/sample/pet-store/src/main/java/oz/spring/petstore/model/Pet.java new file mode 100644 index 000000000..20f170a99 --- /dev/null +++ b/spring-cloud-function-adapters/spring-cloud-function-serverless-web/sample/pet-store/src/main/java/oz/spring/petstore/model/Pet.java @@ -0,0 +1,58 @@ +/* + * Copyright 2023-2023 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 oz.spring.petstore.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/spring-cloud-function-adapters/spring-cloud-function-serverless-web/sample/pet-store/src/main/java/oz/spring/petstore/model/PetData.java b/spring-cloud-function-adapters/spring-cloud-function-serverless-web/sample/pet-store/src/main/java/oz/spring/petstore/model/PetData.java new file mode 100644 index 000000000..1df3632cc --- /dev/null +++ b/spring-cloud-function-adapters/spring-cloud-function-serverless-web/sample/pet-store/src/main/java/oz/spring/petstore/model/PetData.java @@ -0,0 +1,115 @@ +/* + * Copyright 2023-2023 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 oz.spring.petstore.model; + +import java.util.*; +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/spring-cloud-function-adapters/spring-cloud-function-serverless-web/sample/pet-store/template.yml b/spring-cloud-function-adapters/spring-cloud-function-serverless-web/sample/pet-store/template.yml new file mode 100644 index 000000000..7c5cea2e3 --- /dev/null +++ b/spring-cloud-function-adapters/spring-cloud-function-serverless-web/sample/pet-store/template.yml @@ -0,0 +1,37 @@ +AWSTemplateFormatVersion: '2010-09-09' +Transform: AWS::Serverless-2016-10-31 +Description: Example Pet Store API written with spring-cloud-function web-proxy support + +Globals: + Api: + # API Gateway regional endpoints + EndpointConfiguration: REGIONAL + +Resources: + PetStoreFunction: + Type: AWS::Serverless::Function + Properties: + Handler: org.springframework.cloud.function.adapter.aws.web.WebProxyInvoker::handleRequest + Runtime: java11 + CodeUri: . + MemorySize: 512 + Policies: AWSLambdaBasicExecutionRole + Timeout: 30 + Environment: + Variables: + MAIN_CLASS: oz.spring.petstore.PetStoreSpringAppConfig + 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: PetStoreLambda + + diff --git a/spring-cloud-function-adapters/spring-cloud-function-serverless-web/src/main/java/org/springframework/cloud/function/serverless/web/HeaderValueHolder.java b/spring-cloud-function-adapters/spring-cloud-function-serverless-web/src/main/java/org/springframework/cloud/function/serverless/web/HeaderValueHolder.java new file mode 100644 index 000000000..b654d2d3c --- /dev/null +++ b/spring-cloud-function-adapters/spring-cloud-function-serverless-web/src/main/java/org/springframework/cloud/function/serverless/web/HeaderValueHolder.java @@ -0,0 +1,78 @@ +/* + * Copyright 2023-2023 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 org.springframework.cloud.function.serverless.web; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedList; +import java.util.List; + +import org.springframework.lang.Nullable; +import org.springframework.util.CollectionUtils; + +class HeaderValueHolder { + + private final List values = new LinkedList<>(); + + void setValue(@Nullable Object value) { + this.values.clear(); + if (value != null) { + this.values.add(value); + } + } + + void addValue(Object value) { + this.values.add(value); + } + + void addValues(Collection values) { + this.values.addAll(values); + } + + void addValueArray(Object values) { + CollectionUtils.mergeArrayIntoCollection(values, this.values); + } + + List getValues() { + return Collections.unmodifiableList(this.values); + } + + List getStringValues() { + List stringList = new ArrayList<>(this.values.size()); + for (Object value : this.values) { + stringList.add(value.toString()); + } + return Collections.unmodifiableList(stringList); + } + + @Nullable + Object getValue() { + return (!this.values.isEmpty() ? this.values.get(0) : null); + } + + @Nullable + String getStringValue() { + return (!this.values.isEmpty() ? String.valueOf(this.values.get(0)) : null); + } + + @Override + public String toString() { + return this.values.toString(); + } + +} diff --git a/spring-cloud-function-adapters/spring-cloud-function-serverless-web/src/main/java/org/springframework/cloud/function/serverless/web/ProxyHttpServletRequest.java b/spring-cloud-function-adapters/spring-cloud-function-serverless-web/src/main/java/org/springframework/cloud/function/serverless/web/ProxyHttpServletRequest.java new file mode 100644 index 000000000..1cf864164 --- /dev/null +++ b/spring-cloud-function-adapters/spring-cloud-function-serverless-web/src/main/java/org/springframework/cloud/function/serverless/web/ProxyHttpServletRequest.java @@ -0,0 +1,1116 @@ +/* + * Copyright 2023-2023 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 org.springframework.cloud.function.serverless.web; + +import java.io.BufferedReader; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.Reader; +import java.io.StringReader; +import java.io.UnsupportedEncodingException; +import java.security.Principal; +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.Collection; +import java.util.Collections; +import java.util.Date; +import java.util.Enumeration; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.LinkedList; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.TimeZone; + +import javax.servlet.AsyncContext; +import javax.servlet.DispatcherType; +import javax.servlet.ReadListener; +import javax.servlet.RequestDispatcher; +import javax.servlet.ServletContext; +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.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.servlet.http.HttpSession; +import javax.servlet.http.HttpUpgradeHandler; +import javax.servlet.http.Part; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; +import org.springframework.util.LinkedCaseInsensitiveMap; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.util.StringUtils; + +public class ProxyHttpServletRequest implements HttpServletRequest { + + private static final String CHARSET_PREFIX = "charset="; + + private static final TimeZone GMT = TimeZone.getTimeZone("GMT"); + + private static final BufferedReader EMPTY_BUFFERED_READER = new BufferedReader(new StringReader("")); + + /** + * Date formats as specified in the HTTP RFC. + * + * @see Section + * 7.1.1.1 of RFC 7231 + */ + private static final String[] DATE_FORMATS = new String[] { "EEE, dd MMM yyyy HH:mm:ss zzz", + "EEE, dd-MMM-yy HH:mm:ss zzz", "EEE MMM dd HH:mm:ss yyyy" }; + + private final ServletContext servletContext; + + // --------------------------------------------------------------------- + // ServletRequest properties + // --------------------------------------------------------------------- + + private final Map attributes = new LinkedHashMap<>(); + + @Nullable + private String characterEncoding; + + @Nullable + private byte[] content; + + @Nullable + private String contentType; + + @Nullable + private ServletInputStream inputStream; + + @Nullable + private BufferedReader reader; + + private final Map parameters = new LinkedHashMap<>(16); + + /** List of locales in descending order. */ + private final LinkedList locales = new LinkedList<>(); + + + private boolean asyncStarted = false; + + private boolean asyncSupported = false; + + private DispatcherType dispatcherType = DispatcherType.REQUEST; + + // --------------------------------------------------------------------- + // HttpServletRequest properties + // --------------------------------------------------------------------- + + @Nullable + private String authType; + + @Nullable + private Cookie[] cookies; + + private final Map headers = new LinkedCaseInsensitiveMap<>(); + + @Nullable + private String method; + + @Nullable + private String pathInfo; + + private String contextPath = ""; + + @Nullable + private String queryString; + + @Nullable + private String remoteUser; + + private final Set userRoles = new HashSet<>(); + + @Nullable + private Principal userPrincipal; + + @Nullable + private String requestedSessionId; + + @Nullable + private String requestURI; + + private String servletPath = ""; + + @Nullable + private HttpSession session; + + private boolean requestedSessionIdValid = true; + + private boolean requestedSessionIdFromCookie = true; + + private boolean requestedSessionIdFromURL = false; + + private final MultiValueMap parts = new LinkedMultiValueMap<>(); + + + public ProxyHttpServletRequest(ServletContext servletContext, String method, String requestURI) { + this.servletContext = servletContext; + this.method = method; + this.requestURI = requestURI; + this.locales.add(Locale.ENGLISH); + } + + /** + * Return the ServletContext that this request is associated with. (Not + * available in the standard HttpServletRequest interface for some reason.) + */ + @Override + public ServletContext getServletContext() { + return this.servletContext; + } + + @Override + public Object getAttribute(String name) { + return this.attributes.get(name); + } + + @Override + public Enumeration getAttributeNames() { + return Collections.enumeration(new LinkedHashSet<>(this.attributes.keySet())); + } + + @Override + @Nullable + public String getCharacterEncoding() { + return this.characterEncoding; + } + + @Override + public void setCharacterEncoding(@Nullable String characterEncoding) { + this.characterEncoding = characterEncoding; + updateContentTypeHeader(); + } + + private void updateContentTypeHeader() { + if (StringUtils.hasLength(this.contentType)) { + String value = this.contentType; + if (StringUtils.hasLength(this.characterEncoding) + && !this.contentType.toLowerCase().contains(CHARSET_PREFIX)) { + value += ';' + CHARSET_PREFIX + this.characterEncoding; + } + doAddHeaderValue(HttpHeaders.CONTENT_TYPE, value, true); + } + } + + /** + * Set the content of the request body as a byte array. + *

+ * If the supplied byte array represents text such as XML or JSON, the + * {@link #setCharacterEncoding character encoding} should typically be set as + * well. + * + * @see #setCharacterEncoding(String) + * @see #getContentAsByteArray() + * @see #getContentAsString() + */ + public void setContent(@Nullable byte[] content) { + this.content = content; + this.inputStream = null; + this.reader = null; + } + + /** + * Get the content of the request body as a byte array. + * + * @return the content as a byte array (potentially {@code null}) + * @since 5.0 + * @see #setContent(byte[]) + * @see #getContentAsString() + */ + @Nullable + public byte[] getContentAsByteArray() { + return this.content; + } + + /** + * Get the content of the request body as a {@code String}, using the configured + * {@linkplain #getCharacterEncoding character encoding}. + * + * @return the content as a {@code String}, potentially {@code null} + * @throws IllegalStateException if the character encoding has not been + * set + * @throws UnsupportedEncodingException if the character encoding is not + * supported + * @since 5.0 + * @see #setContent(byte[]) + * @see #setCharacterEncoding(String) + * @see #getContentAsByteArray() + */ + @Nullable + public String getContentAsString() throws IllegalStateException, UnsupportedEncodingException { + Assert.state(this.characterEncoding != null, "Cannot get content as a String for a null character encoding. " + + "Consider setting the characterEncoding in the request."); + + if (this.content == null) { + return null; + } + return new String(this.content, this.characterEncoding); + } + + @Override + public int getContentLength() { + return (this.content != null ? this.content.length : -1); + } + + @Override + public long getContentLengthLong() { + return getContentLength(); + } + + public void setContentType(@Nullable String contentType) { + this.contentType = contentType; + if (contentType != null) { + try { + MediaType mediaType = MediaType.parseMediaType(contentType); + if (mediaType.getCharset() != null) { + this.characterEncoding = mediaType.getCharset().name(); + } + } + catch (IllegalArgumentException ex) { + // Try to get charset value anyway + int charsetIndex = contentType.toLowerCase().indexOf(CHARSET_PREFIX); + if (charsetIndex != -1) { + this.characterEncoding = contentType.substring(charsetIndex + CHARSET_PREFIX.length()); + } + } + updateContentTypeHeader(); + } + } + + @Override + @Nullable + public String getContentType() { + return this.contentType; + } + + @Override + public ServletInputStream getInputStream() { + InputStream stream = new ByteArrayInputStream(this.content); + return new ServletInputStream() { + + boolean finished = false; + + @Override + public int read() throws IOException { + int readByte = stream.read(); + if (readByte == -1) { + finished = true; + } + return readByte; + } + + @Override + public void setReadListener(ReadListener readListener) { + } + + @Override + public boolean isReady() { + return !finished; + } + + @Override + public boolean isFinished() { + return finished; + } + }; + } + + /** + * Set a single value for the specified HTTP parameter. + *

+ * If there are already one or more values registered for the given parameter + * name, they will be replaced. + */ + public void setParameter(String name, String value) { + setParameter(name, new String[] { value }); + } + + /** + * Set an array of values for the specified HTTP parameter. + *

+ * If there are already one or more values registered for the given parameter + * name, they will be replaced. + */ + public void setParameter(String name, String... values) { + Assert.notNull(name, "Parameter name must not be null"); + this.parameters.put(name, values); + } + + /** + * Set all provided parameters replacing any existing values + * for the provided parameter names. To add without replacing existing values, + * use {@link #addParameters(java.util.Map)}. + */ + public void setParameters(Map params) { + Assert.notNull(params, "Parameter map must not be null"); + params.forEach((key, value) -> { + if (value instanceof String) { + setParameter(key, (String) value); + } + else if (value instanceof String[]) { + setParameter(key, (String[]) value); + } + else { + throw new IllegalArgumentException("Parameter map value must be single value " + " or array of type [" + + String.class.getName() + "]"); + } + }); + } + + /** + * Add a single value for the specified HTTP parameter. + *

+ * If there are already one or more values registered for the given parameter + * name, the given value will be added to the end of the list. + */ + public void addParameter(String name, @Nullable String value) { + addParameter(name, new String[] { value }); + } + + /** + * Add an array of values for the specified HTTP parameter. + *

+ * If there are already one or more values registered for the given parameter + * name, the given values will be added to the end of the list. + */ + public void addParameter(String name, String... values) { + Assert.notNull(name, "Parameter name must not be null"); + String[] oldArr = this.parameters.get(name); + if (oldArr != null) { + String[] newArr = new String[oldArr.length + values.length]; + System.arraycopy(oldArr, 0, newArr, 0, oldArr.length); + System.arraycopy(values, 0, newArr, oldArr.length, values.length); + this.parameters.put(name, newArr); + } + else { + this.parameters.put(name, values); + } + } + + /** + * Add all provided parameters without replacing any existing + * values. To replace existing values, use + * {@link #setParameters(java.util.Map)}. + */ + public void addParameters(Map params) { + Assert.notNull(params, "Parameter map must not be null"); + params.forEach((key, value) -> { + if (value instanceof String) { + addParameter(key, (String) value); + } + else if (value instanceof String[]) { + addParameter(key, (String[]) value); + } + else { + throw new IllegalArgumentException("Parameter map value must be single value " + " or array of type [" + + String.class.getName() + "]"); + } + }); + } + + /** + * Remove already registered values for the specified HTTP parameter, if any. + */ + public void removeParameter(String name) { + Assert.notNull(name, "Parameter name must not be null"); + this.parameters.remove(name); + } + + /** + * Remove all existing parameters. + */ + public void removeAllParameters() { + this.parameters.clear(); + } + + @Override + @Nullable + public String getParameter(String name) { + Assert.notNull(name, "Parameter name must not be null"); + String[] arr = this.parameters.get(name); + return (arr != null && arr.length > 0 ? arr[0] : null); + } + + @Override + public Enumeration getParameterNames() { + return Collections.enumeration(this.parameters.keySet()); + } + + @Override + public String[] getParameterValues(String name) { + Assert.notNull(name, "Parameter name must not be null"); + return this.parameters.get(name); + } + + @Override + public Map getParameterMap() { + return Collections.unmodifiableMap(this.parameters); + } + + @Override + public String getProtocol() { + throw new UnsupportedOperationException(); + } + + @Override + public String getScheme() { + throw new UnsupportedOperationException(); + } + + public void setServerName(String serverName) { + throw new UnsupportedOperationException(); + } + + @Override + public String getServerName() { + throw new UnsupportedOperationException(); + } + + public void setServerPort(int serverPort) { + throw new UnsupportedOperationException(); + } + + @Override + public int getServerPort() { + throw new UnsupportedOperationException(); + } + + @Override + public BufferedReader getReader() throws UnsupportedEncodingException { + if (this.reader != null) { + return this.reader; + } + else if (this.inputStream != null) { + throw new IllegalStateException( + "Cannot call getReader() after getInputStream() has already been called for the current request"); + } + + if (this.content != null) { + InputStream sourceStream = new ByteArrayInputStream(this.content); + Reader sourceReader = (this.characterEncoding != null) + ? new InputStreamReader(sourceStream, this.characterEncoding) + : new InputStreamReader(sourceStream); + this.reader = new BufferedReader(sourceReader); + } + else { + this.reader = EMPTY_BUFFERED_READER; + } + return this.reader; + } + + public void setRemoteAddr(String remoteAddr) { + throw new UnsupportedOperationException(); + } + + @Override + public String getRemoteAddr() { + return "proxy"; + } + + public void setRemoteHost(String remoteHost) { + throw new UnsupportedOperationException(); + } + + @Override + public String getRemoteHost() { + throw new UnsupportedOperationException(); + } + + @Override + public void setAttribute(String name, @Nullable Object value) { + Assert.notNull(name, "Attribute name must not be null"); + if (value != null) { + this.attributes.put(name, value); + } + else { + this.attributes.remove(name); + } + } + + @Override + public void removeAttribute(String name) { + Assert.notNull(name, "Attribute name must not be null"); + this.attributes.remove(name); + } + + /** + * Clear all of this request's attributes. + */ + public void clearAttributes() { + this.attributes.clear(); + } + + /** + * Add a new preferred locale, before any existing locales. + * + * @see #setPreferredLocales + */ + public void addPreferredLocale(Locale locale) { + Assert.notNull(locale, "Locale must not be null"); + this.locales.addFirst(locale); + updateAcceptLanguageHeader(); + } + + /** + * Set the list of preferred locales, in descending order, effectively replacing + * any existing locales. + * + * @since 3.2 + * @see #addPreferredLocale + */ + public void setPreferredLocales(List locales) { + Assert.notEmpty(locales, "Locale list must not be empty"); + this.locales.clear(); + this.locales.addAll(locales); + updateAcceptLanguageHeader(); + } + + private void updateAcceptLanguageHeader() { + HttpHeaders headers = new HttpHeaders(); + headers.setAcceptLanguageAsLocales(this.locales); + doAddHeaderValue(HttpHeaders.ACCEPT_LANGUAGE, headers.getFirst(HttpHeaders.ACCEPT_LANGUAGE), true); + } + + /** + * Return the first preferred {@linkplain Locale locale} configured in this mock + * request. + *

+ * If no locales have been explicitly configured, the default, preferred + * {@link Locale} for the server mocked by this request is + * {@link Locale#ENGLISH}. + *

+ * In contrast to the Servlet specification, this mock implementation does + * not take into consideration any locales specified via the + * {@code Accept-Language} header. + * + * @see javax.servlet.ServletRequest#getLocale() + * @see #addPreferredLocale(Locale) + * @see #setPreferredLocales(List) + */ + @Override + public Locale getLocale() { + return this.locales.getFirst(); + } + + /** + * Return an {@linkplain Enumeration enumeration} of the preferred + * {@linkplain Locale locales} configured in this mock request. + *

+ * If no locales have been explicitly configured, the default, preferred + * {@link Locale} for the server mocked by this request is + * {@link Locale#ENGLISH}. + *

+ * In contrast to the Servlet specification, this mock implementation does + * not take into consideration any locales specified via the + * {@code Accept-Language} header. + * + * @see javax.servlet.ServletRequest#getLocales() + * @see #addPreferredLocale(Locale) + * @see #setPreferredLocales(List) + */ + @Override + public Enumeration getLocales() { + return Collections.enumeration(this.locales); + } + + /** + * Return {@code true} if the {@link #setSecure secure} flag has been set to + * {@code true} or if the {@link #getScheme scheme} is {@code https}. + * + * @see javax.servlet.ServletRequest#isSecure() + */ + @Override + public boolean isSecure() { + throw new UnsupportedOperationException(); + } + + @Override + public RequestDispatcher getRequestDispatcher(String path) { + throw new UnsupportedOperationException(); + } + + @Override + @Deprecated + public String getRealPath(String path) { + return this.servletContext.getRealPath(path); + } + + public void setRemotePort(int remotePort) { + throw new UnsupportedOperationException(); + } + + @Override + public int getRemotePort() { + throw new UnsupportedOperationException(); + } + + public void setLocalName(String localName) { + throw new UnsupportedOperationException(); + } + + @Override + public String getLocalName() { + throw new UnsupportedOperationException(); + } + + public void setLocalAddr(String localAddr) { + throw new UnsupportedOperationException(); + } + + @Override + public String getLocalAddr() { + return "proxy"; + } + + public void setLocalPort(int localPort) { + throw new UnsupportedOperationException(); + } + + @Override + public int getLocalPort() { + throw new UnsupportedOperationException(); + } + + @Override + public AsyncContext startAsync() { + return startAsync(this, null); + } + + @Override + public AsyncContext startAsync(ServletRequest request, @Nullable ServletResponse response) { + throw new UnsupportedOperationException(); + } + + public void setAsyncStarted(boolean asyncStarted) { + this.asyncStarted = asyncStarted; + } + + @Override + public boolean isAsyncStarted() { + return this.asyncStarted; + } + + public void setAsyncSupported(boolean asyncSupported) { + this.asyncSupported = asyncSupported; + } + + @Override + public boolean isAsyncSupported() { + return this.asyncSupported; + } + + public void setAsyncContext(@Nullable AsyncContext asyncContext) { + throw new UnsupportedOperationException(); + } + + @Override + @Nullable + public AsyncContext getAsyncContext() { + return null; + } + + public void setDispatcherType(DispatcherType dispatcherType) { + this.dispatcherType = dispatcherType; + } + + @Override + public javax.servlet.DispatcherType getDispatcherType() { + return this.dispatcherType; + } + + public void setAuthType(@Nullable String authType) { + this.authType = authType; + } + + @Override + @Nullable + public String getAuthType() { + return this.authType; + } + + @Override + @Nullable + public Cookie[] getCookies() { + return this.cookies; + } + + /** + * Add an HTTP header entry for the given name. + *

+ * While this method can take any {@code Object} as a parameter, it is + * recommended to use the following types: + *

    + *
  • String or any Object to be converted using {@code toString()}; see + * {@link #getHeader}.
  • + *
  • String, Number, or Date for date headers; see + * {@link #getDateHeader}.
  • + *
  • String or Number for integer headers; see {@link #getIntHeader}.
  • + *
  • {@code String[]} or {@code Collection} for multiple values; see + * {@link #getHeaders}.
  • + *
+ * + * @see #getHeaderNames + * @see #getHeaders + * @see #getHeader + * @see #getDateHeader + */ + public void addHeader(String name, Object value) { + if (HttpHeaders.CONTENT_TYPE.equalsIgnoreCase(name) && !this.headers.containsKey(HttpHeaders.CONTENT_TYPE)) { + setContentType(value.toString()); + } + else if (HttpHeaders.ACCEPT_LANGUAGE.equalsIgnoreCase(name) + && !this.headers.containsKey(HttpHeaders.ACCEPT_LANGUAGE)) { + try { + HttpHeaders headers = new HttpHeaders(); + headers.add(HttpHeaders.ACCEPT_LANGUAGE, value.toString()); + List locales = headers.getAcceptLanguageAsLocales(); + this.locales.clear(); + this.locales.addAll(locales); + if (this.locales.isEmpty()) { + this.locales.add(Locale.ENGLISH); + } + } + catch (IllegalArgumentException ex) { + // Invalid Accept-Language format -> just store plain header + } + doAddHeaderValue(name, value, true); + } + else { + doAddHeaderValue(name, value, false); + } + } + + private void doAddHeaderValue(String name, @Nullable Object value, boolean replace) { + HeaderValueHolder header = this.headers.get(name); + Assert.notNull(value, "Header value must not be null"); + if (header == null || replace) { + header = new HeaderValueHolder(); + this.headers.put(name, header); + } + if (value instanceof Collection) { + header.addValues((Collection) value); + } + else if (value.getClass().isArray()) { + header.addValueArray(value); + } + else { + header.addValue(value); + } + } + + /** + * Return the long timestamp for the date header with the given {@code name}. + *

+ * If the internal value representation is a String, this method will try to + * parse it as a date using the supported date formats: + *

    + *
  • "EEE, dd MMM yyyy HH:mm:ss zzz"
  • + *
  • "EEE, dd-MMM-yy HH:mm:ss zzz"
  • + *
  • "EEE MMM dd HH:mm:ss yyyy"
  • + *
+ * + * @param name the header name + * @see Section + * 7.1.1.1 of RFC 7231 + */ + @Override + public long getDateHeader(String name) { + HeaderValueHolder header = this.headers.get(name); + Object value = (header != null ? header.getValue() : null); + if (value instanceof Date) { + return ((Date) value).getTime(); + } + else if (value instanceof Number) { + return ((Number) value).longValue(); + } + else if (value instanceof String) { + return parseDateHeader(name, (String) value); + } + else if (value != null) { + throw new IllegalArgumentException( + "Value for header '" + name + "' is not a Date, Number, or String: " + value); + } + else { + return -1L; + } + } + + private long parseDateHeader(String name, String value) { + for (String dateFormat : DATE_FORMATS) { + SimpleDateFormat simpleDateFormat = new SimpleDateFormat(dateFormat, Locale.US); + simpleDateFormat.setTimeZone(GMT); + try { + return simpleDateFormat.parse(value).getTime(); + } + catch (ParseException ex) { + // ignore + } + } + throw new IllegalArgumentException("Cannot parse date value '" + value + "' for '" + name + "' header"); + } + + @Override + @Nullable + public String getHeader(String name) { + HeaderValueHolder header = this.headers.get(name); + return (header != null ? header.getStringValue() : null); + } + + @Override + public Enumeration getHeaders(String name) { + HeaderValueHolder header = this.headers.get(name); + return Collections.enumeration(header != null ? header.getStringValues() : new LinkedList<>()); + } + + @Override + public Enumeration getHeaderNames() { + return Collections.enumeration(this.headers.keySet()); + } + + @Override + public int getIntHeader(String name) { + HeaderValueHolder header = this.headers.get(name); + Object value = (header != null ? header.getValue() : null); + if (value instanceof Number) { + return ((Number) value).intValue(); + } + else if (value instanceof String) { + return Integer.parseInt((String) value); + } + else if (value != null) { + throw new NumberFormatException("Value for header '" + name + "' is not a Number: " + value); + } + else { + return -1; + } + } + + public void setMethod(@Nullable String method) { + this.method = method; + } + + @Override + @Nullable + public String getMethod() { + return this.method; + } + + public void setPathInfo(@Nullable String pathInfo) { + this.pathInfo = pathInfo; + } + + @Override + @Nullable + public String getPathInfo() { + return this.pathInfo; + } + + @Override + @Nullable + public String getPathTranslated() { + return (this.pathInfo != null ? getRealPath(this.pathInfo) : null); + } + + public void setContextPath(String contextPath) { + this.contextPath = contextPath; + } + + @Override + public String getContextPath() { + return this.contextPath; + } + + public void setQueryString(@Nullable String queryString) { + this.queryString = queryString; + } + + @Override + @Nullable + public String getQueryString() { + return this.queryString; + } + + public void setRemoteUser(@Nullable String remoteUser) { + this.remoteUser = remoteUser; + } + + @Override + @Nullable + public String getRemoteUser() { + return this.remoteUser; + } + + public void addUserRole(String role) { + this.userRoles.add(role); + } + + @Override + public boolean isUserInRole(String role) { + throw new UnsupportedOperationException(); + } + + public void setUserPrincipal(@Nullable Principal userPrincipal) { + this.userPrincipal = userPrincipal; + } + + @Override + @Nullable + public Principal getUserPrincipal() { + return this.userPrincipal; + } + + public void setRequestedSessionId(@Nullable String requestedSessionId) { + this.requestedSessionId = requestedSessionId; + } + + @Override + @Nullable + public String getRequestedSessionId() { + return this.requestedSessionId; + } + + public void setRequestURI(@Nullable String requestURI) { + this.requestURI = requestURI; + } + + @Override + @Nullable + public String getRequestURI() { + return this.requestURI; + } + + @Override + public StringBuffer getRequestURL() { + throw new UnsupportedOperationException(); + } + + public void setServletPath(String servletPath) { + this.servletPath = servletPath; + } + + @Override + public String getServletPath() { + return this.servletPath; + } + + public void setSession(HttpSession session) { + throw new UnsupportedOperationException(); + } + + @Override + @Nullable + public HttpSession getSession(boolean create) { + return this.session; + } + + @Override + @Nullable + public HttpSession getSession() { + return getSession(true); + } + + @Override + public String changeSessionId() { + throw new UnsupportedOperationException(); + } + + public void setRequestedSessionIdValid(boolean requestedSessionIdValid) { + this.requestedSessionIdValid = requestedSessionIdValid; + } + + @Override + public boolean isRequestedSessionIdValid() { + return this.requestedSessionIdValid; + } + + public void setRequestedSessionIdFromCookie(boolean requestedSessionIdFromCookie) { + this.requestedSessionIdFromCookie = requestedSessionIdFromCookie; + } + + @Override + public boolean isRequestedSessionIdFromCookie() { + return this.requestedSessionIdFromCookie; + } + + public void setRequestedSessionIdFromURL(boolean requestedSessionIdFromURL) { + this.requestedSessionIdFromURL = requestedSessionIdFromURL; + } + + @Override + public boolean isRequestedSessionIdFromURL() { + return this.requestedSessionIdFromURL; + } + + @Override + @Deprecated + public boolean isRequestedSessionIdFromUrl() { + return isRequestedSessionIdFromURL(); + } + + @Override + public boolean authenticate(HttpServletResponse response) throws IOException, ServletException { + throw new UnsupportedOperationException(); + } + + @Override + public void login(String username, String password) throws ServletException { + throw new UnsupportedOperationException(); + } + + @Override + public void logout() throws ServletException { + this.userPrincipal = null; + this.remoteUser = null; + this.authType = null; + } + + public void addPart(Part part) { + this.parts.add(part.getName(), part); + } + + @Override + @Nullable + public Part getPart(String name) throws IOException, ServletException { + return this.parts.getFirst(name); + } + + @Override + public Collection getParts() throws IOException, ServletException { + List result = new LinkedList<>(); + for (List list : this.parts.values()) { + result.addAll(list); + } + return result; + } + + @Override + public T upgrade(Class handlerClass) throws IOException, ServletException { + throw new UnsupportedOperationException(); + } +} diff --git a/spring-cloud-function-adapters/spring-cloud-function-serverless-web/src/main/java/org/springframework/cloud/function/serverless/web/ProxyHttpServletResponse.java b/spring-cloud-function-adapters/spring-cloud-function-serverless-web/src/main/java/org/springframework/cloud/function/serverless/web/ProxyHttpServletResponse.java new file mode 100644 index 000000000..3531a8330 --- /dev/null +++ b/spring-cloud-function-adapters/spring-cloud-function-serverless-web/src/main/java/org/springframework/cloud/function/serverless/web/ProxyHttpServletResponse.java @@ -0,0 +1,601 @@ +/* + * Copyright 2023-2023 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 org.springframework.cloud.function.serverless.web; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.PrintWriter; +import java.io.UnsupportedEncodingException; +import java.nio.charset.Charset; +import java.text.DateFormat; +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.Date; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.TimeZone; + +import javax.servlet.ServletOutputStream; +import javax.servlet.WriteListener; +import javax.servlet.http.Cookie; +import javax.servlet.http.HttpServletResponse; + +import org.springframework.http.HttpHeaders; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; +import org.springframework.util.LinkedCaseInsensitiveMap; +import org.springframework.web.util.WebUtils; + +public class ProxyHttpServletResponse implements HttpServletResponse { + + private static final String CHARSET_PREFIX = "charset="; + + private static final String DATE_FORMAT = "EEE, dd MMM yyyy HH:mm:ss zzz"; + + private static final TimeZone GMT = TimeZone.getTimeZone("GMT"); + + // --------------------------------------------------------------------- + // ServletResponse properties + // --------------------------------------------------------------------- + + private boolean outputStreamAccessAllowed = true; + + private String defaultCharacterEncoding = WebUtils.DEFAULT_CHARACTER_ENCODING; + + private String characterEncoding = this.defaultCharacterEncoding; + + /** + * {@code true} if the character encoding has been explicitly set through + * {@link HttpServletResponse} methods or through a {@code charset} parameter on + * the {@code Content-Type}. + */ + private boolean characterEncodingSet = false; + + private final ByteArrayOutputStream content = new ByteArrayOutputStream(1024); + + private final ServletOutputStream outputStream = new ResponseServletOutputStream(); + + private long contentLength = 0; + + private String contentType; + + private int bufferSize = 4096; + + private boolean committed; + + private Locale locale = Locale.getDefault(); + + // --------------------------------------------------------------------- + // HttpServletResponse properties + // --------------------------------------------------------------------- + + private final List cookies = new ArrayList<>(); + + private final Map headers = new LinkedCaseInsensitiveMap<>(); + + private int status = HttpServletResponse.SC_OK; + + @Nullable + private String errorMessage; + + // --------------------------------------------------------------------- + // ServletResponse interface + // --------------------------------------------------------------------- + + @Override + public void setCharacterEncoding(String characterEncoding) { + setExplicitCharacterEncoding(characterEncoding); + updateContentTypePropertyAndHeader(); + } + + private void setExplicitCharacterEncoding(String characterEncoding) { + Assert.notNull(characterEncoding, "'characterEncoding' must not be null"); + this.characterEncoding = characterEncoding; + this.characterEncodingSet = true; + } + + private void updateContentTypePropertyAndHeader() { + if (this.contentType != null) { + String value = this.contentType; + if (this.characterEncodingSet && !value.toLowerCase().contains(CHARSET_PREFIX)) { + value += ';' + CHARSET_PREFIX + getCharacterEncoding(); + this.contentType = value; + } + doAddHeaderValue(HttpHeaders.CONTENT_TYPE, value, true); + } + } + + @Override + public String getCharacterEncoding() { + return this.characterEncoding; + } + + @Override + public ServletOutputStream getOutputStream() { + Assert.state(this.outputStreamAccessAllowed, "OutputStream access not allowed"); + return this.outputStream; + } + + @Override + public PrintWriter getWriter() throws UnsupportedEncodingException { + throw new UnsupportedOperationException(); + } + + public byte[] getContentAsByteArray() { + return this.content.toByteArray(); + } + + /** + * Get the content of the response body as a {@code String}, using the charset + * specified for the response by the application, either through + * {@link HttpServletResponse} methods or through a charset parameter on the + * {@code Content-Type}. If no charset has been explicitly defined, the + * {@linkplain #setDefaultCharacterEncoding(String) default character encoding} + * will be used. + * + * @return the content as a {@code String} + * @throws UnsupportedEncodingException if the character encoding is not + * supported + * @see #getContentAsString(Charset) + * @see #setCharacterEncoding(String) + * @see #setContentType(String) + */ + public String getContentAsString() throws UnsupportedEncodingException { + return this.content.toString(getCharacterEncoding()); + } + + /** + * Get the content of the response body as a {@code String}, using the provided + * {@code fallbackCharset} if no charset has been explicitly defined and + * otherwise using the charset specified for the response by the application, + * either through {@link HttpServletResponse} methods or through a charset + * parameter on the {@code Content-Type}. + * + * @return the content as a {@code String} + * @throws UnsupportedEncodingException if the character encoding is not + * supported + * @since 5.2 + * @see #getContentAsString() + * @see #setCharacterEncoding(String) + * @see #setContentType(String) + */ + public String getContentAsString(Charset fallbackCharset) throws UnsupportedEncodingException { + String charsetName = (this.characterEncodingSet ? getCharacterEncoding() : fallbackCharset.name()); + return this.content.toString(charsetName); + } + + @Override + public void setContentLength(int contentLength) { + throw new UnsupportedOperationException(); + } + + @Override + public void setContentLengthLong(long len) { + throw new UnsupportedOperationException(); + } + + @Override + public void setContentType(@Nullable String contentType) { + this.contentType = contentType; + } + + @Override + @Nullable + public String getContentType() { + return this.contentType; + } + + @Override + public void setBufferSize(int bufferSize) { + this.bufferSize = bufferSize; + } + + @Override + public int getBufferSize() { + return this.bufferSize; + } + + @Override + public void flushBuffer() { + + } + + @Override + public void resetBuffer() { + Assert.state(!isCommitted(), "Cannot reset buffer - response is already committed"); + this.content.reset(); + } + + public void setCommitted(boolean committed) { + this.committed = committed; + } + + @Override + public boolean isCommitted() { + return this.committed; + } + + @Override + public void reset() { + resetBuffer(); + this.characterEncoding = this.defaultCharacterEncoding; + this.characterEncodingSet = false; + this.contentLength = 0; + this.contentType = null; + this.locale = Locale.getDefault(); + this.cookies.clear(); + this.headers.clear(); + this.status = HttpServletResponse.SC_OK; + this.errorMessage = null; + } + + @Override + public void setLocale(@Nullable Locale locale) { + // Although the Javadoc for javax.servlet.ServletResponse.setLocale(Locale) does + // not + // state how a null value for the supplied Locale should be handled, both Tomcat + // and + // Jetty simply ignore a null value. So we do the same here. + if (locale == null) { + return; + } + this.locale = locale; + doAddHeaderValue(HttpHeaders.CONTENT_LANGUAGE, locale.toLanguageTag(), true); + } + + @Override + public Locale getLocale() { + return this.locale; + } + + // --------------------------------------------------------------------- + // HttpServletResponse interface + // --------------------------------------------------------------------- + + @Override + public void addCookie(Cookie cookie) { + throw new UnsupportedOperationException(); + } + + @Nullable + public Cookie getCookie(String name) { + throw new UnsupportedOperationException(); + } + + @Override + public boolean containsHeader(String name) { + return this.headers.containsKey(name); + } + + /** + * Return the names of all specified headers as a Set of Strings. + *

+ * As of Servlet 3.0, this method is also defined in + * {@link HttpServletResponse}. + * + * @return the {@code Set} of header name {@code Strings}, or an empty + * {@code Set} if none + */ + @Override + public Collection getHeaderNames() { + return this.headers.keySet(); + } + + /** + * Return the primary value for the given header as a String, if any. Will + * return the first value in case of multiple values. + *

+ * As of Servlet 3.0, this method is also defined in + * {@link HttpServletResponse}. As of Spring 3.1, it returns a stringified value + * for Servlet 3.0 compatibility. Consider using {@link #getHeaderValue(String)} + * for raw Object access. + * + * @param name the name of the header + * @return the associated header value, or {@code null} if none + */ + @Override + @Nullable + public String getHeader(String name) { + HeaderValueHolder header = this.headers.get(name); + return (header != null ? header.getStringValue() : null); + } + + /** + * Return all values for the given header as a List of Strings. + *

+ * As of Servlet 3.0, this method is also defined in + * {@link HttpServletResponse}. As of Spring 3.1, it returns a List of + * stringified values for Servlet 3.0 compatibility. Consider using + * {@link #getHeaderValues(String)} for raw Object access. + * + * @param name the name of the header + * @return the associated header values, or an empty List if none + */ + @Override + public List getHeaders(String name) { + HeaderValueHolder header = this.headers.get(name); + if (header != null) { + return header.getStringValues(); + } + else { + return Collections.emptyList(); + } + } + + /** + * Return the primary value for the given header, if any. + *

+ * Will return the first value in case of multiple values. + * + * @param name the name of the header + * @return the associated header value, or {@code null} if none + */ + @Nullable + public Object getHeaderValue(String name) { + HeaderValueHolder header = this.headers.get(name); + return (header != null ? header.getValue() : null); + } + + /** + * Return all values for the given header as a List of value objects. + * + * @param name the name of the header + * @return the associated header values, or an empty List if none + */ + public List getHeaderValues(String name) { + HeaderValueHolder header = this.headers.get(name); + if (header != null) { + return header.getValues(); + } + else { + return Collections.emptyList(); + } + } + + /** + * The default implementation returns the given URL String as-is. + *

+ * Can be overridden in subclasses, appending a session id or the like. + */ + @Override + public String encodeURL(String url) { + return url; + } + + /** + * The default implementation delegates to {@link #encodeURL}, returning the + * given URL String as-is. + *

+ * Can be overridden in subclasses, appending a session id or the like in a + * redirect-specific fashion. For general URL encoding rules, override the + * common {@link #encodeURL} method instead, applying to redirect URLs as well + * as to general URLs. + */ + @Override + public String encodeRedirectURL(String url) { + return encodeURL(url); + } + + @Override + @Deprecated + public String encodeUrl(String url) { + return encodeURL(url); + } + + @Override + @Deprecated + public String encodeRedirectUrl(String url) { + return encodeRedirectURL(url); + } + + @Override + public void sendError(int status, String errorMessage) throws IOException { + Assert.state(!isCommitted(), "Cannot set error status - response is already committed"); + this.status = status; + this.errorMessage = errorMessage; + setCommitted(true); + } + + @Override + public void sendError(int status) throws IOException { + Assert.state(!isCommitted(), "Cannot set error status - response is already committed"); + this.status = status; + setCommitted(true); + } + + @Override + public void sendRedirect(String url) throws IOException { + Assert.state(!isCommitted(), "Cannot send redirect - response is already committed"); + Assert.notNull(url, "Redirect URL must not be null"); + setHeader(HttpHeaders.LOCATION, url); + setStatus(HttpServletResponse.SC_MOVED_TEMPORARILY); + setCommitted(true); + } + + @Nullable + public String getRedirectedUrl() { + return getHeader(HttpHeaders.LOCATION); + } + + @Override + public void setDateHeader(String name, long value) { + setHeaderValue(name, formatDate(value)); + } + + @Override + public void addDateHeader(String name, long value) { + addHeaderValue(name, formatDate(value)); + } + + public long getDateHeader(String name) { + String headerValue = getHeader(name); + if (headerValue == null) { + return -1; + } + try { + return newDateFormat().parse(getHeader(name)).getTime(); + } + catch (ParseException ex) { + throw new IllegalArgumentException("Value for header '" + name + "' is not a valid Date: " + headerValue); + } + } + + private String formatDate(long date) { + return newDateFormat().format(new Date(date)); + } + + private DateFormat newDateFormat() { + SimpleDateFormat dateFormat = new SimpleDateFormat(DATE_FORMAT, Locale.US); + dateFormat.setTimeZone(GMT); + return dateFormat; + } + + @Override + public void setHeader(String name, @Nullable String value) { + setHeaderValue(name, value); + } + + @Override + public void addHeader(String name, @Nullable String value) { + addHeaderValue(name, value); + } + + @Override + public void setIntHeader(String name, int value) { + setHeaderValue(name, value); + } + + @Override + public void addIntHeader(String name, int value) { + addHeaderValue(name, value); + } + + private void setHeaderValue(String name, @Nullable Object value) { + if (value == null) { + return; + } + boolean replaceHeader = true; + doAddHeaderValue(name, value, replaceHeader); + } + + private void addHeaderValue(String name, @Nullable Object value) { + if (value == null) { + return; + } + boolean replaceHeader = false; + doAddHeaderValue(name, value, replaceHeader); + } + + private void doAddHeaderValue(String name, Object value, boolean replace) { + Assert.notNull(value, "Header value must not be null"); + HeaderValueHolder header = this.headers.computeIfAbsent(name, key -> new HeaderValueHolder()); + if (replace) { + header.setValue(value); + } + else { + header.addValue(value); + } + } + + @Override + public void setStatus(int status) { + if (!this.isCommitted()) { + this.status = status; + } + } + + @Override + @Deprecated + public void setStatus(int status, String errorMessage) { + throw new UnsupportedOperationException(); + } + + @Override + public int getStatus() { + return this.status; + } + + @Nullable + public String getErrorMessage() { + return this.errorMessage; + } + + // --------------------------------------------------------------------- + // Methods for MockRequestDispatcher + // --------------------------------------------------------------------- + + @Nullable + public String getForwardedUrl() { + throw new UnsupportedOperationException(); + } + + @Nullable + public String getIncludedUrl() { + throw new UnsupportedOperationException(); + } + + /** + * Inner class that adapts the ServletOutputStream to mark the response as + * committed once the buffer size is exceeded. + */ + private class ResponseServletOutputStream extends ServletOutputStream { + + private WriteListener listener; + + @Override + public boolean isReady() { + return true; + } + + @Override + public void setWriteListener(WriteListener writeListener) { + if (writeListener != null) { + try { + writeListener.onWritePossible(); + } + catch (IOException e) { + // log.error("Output stream is not writable", e); + } + + listener = writeListener; + } + } + + @Override + public void write(int b) throws IOException { + try { + content.write(b); + } + catch (Exception e) { + if (listener != null) { + listener.onError(e); + } + } + } + + @Override + public void close() throws IOException { + super.close(); + flushBuffer(); + } + } + +} diff --git a/spring-cloud-function-adapters/spring-cloud-function-serverless-web/src/main/java/org/springframework/cloud/function/serverless/web/ProxyMvc.java b/spring-cloud-function-adapters/spring-cloud-function-serverless-web/src/main/java/org/springframework/cloud/function/serverless/web/ProxyMvc.java new file mode 100644 index 000000000..3e1eff91f --- /dev/null +++ b/spring-cloud-function-adapters/spring-cloud-function-serverless-web/src/main/java/org/springframework/cloud/function/serverless/web/ProxyMvc.java @@ -0,0 +1,241 @@ +/* + * Copyright 2023-2023 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 org.springframework.cloud.function.serverless.web; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Enumeration; +import java.util.Iterator; +import java.util.List; + +import javax.servlet.Filter; +import javax.servlet.FilterChain; +import javax.servlet.FilterConfig; +import javax.servlet.Servlet; +import javax.servlet.ServletConfig; +import javax.servlet.ServletContext; +import javax.servlet.ServletException; +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.springframework.context.annotation.AnnotatedBeanDefinitionReader; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; +import org.springframework.util.ObjectUtils; +import org.springframework.web.context.ConfigurableWebApplicationContext; +import org.springframework.web.context.WebApplicationContext; +import org.springframework.web.context.support.AnnotationConfigWebApplicationContext; +import org.springframework.web.context.support.GenericWebApplicationContext; +import org.springframework.web.servlet.DispatcherServlet; + +public class ProxyMvc { + + static final String MVC_RESULT_ATTRIBUTE = ProxyMvc.class.getName().concat(".MVC_RESULT_ATTRIBUTE"); + + private final DispatcherServlet servlet; + + private final Filter[] filters; + + private final ConfigurableWebApplicationContext applicationContext; + + public static ProxyMvc INSTANCE(Class... componentClasses) { + Assert.notEmpty(componentClasses, "'componentClasses' must not be null or empty"); + + ProxyServletContext servletContext = new ProxyServletContext(); + GenericWebApplicationContext applpicationContext = new GenericWebApplicationContext(servletContext); + AnnotatedBeanDefinitionReader reader = new AnnotatedBeanDefinitionReader(applpicationContext); + reader.register(componentClasses); + + try { + DispatcherServlet servlet = new DispatcherServlet(applpicationContext); + servlet.init(new ProxyServletConfig(servletContext)); + applpicationContext.registerBean(DispatcherServlet.class, servlet); + + return new ProxyMvc(servlet, applpicationContext); + } + catch (Exception e) { + throw new IllegalStateException("Failed to create MVC Proxy", e); + } + } + + /** + * Private constructor, not for direct instantiation. + */ + ProxyMvc(DispatcherServlet servlet, ConfigurableWebApplicationContext applicationContext) { + this.applicationContext = applicationContext; + this.servlet = servlet; + this.filters = applicationContext.getBeansOfType(Filter.class).values().toArray(new Filter[0]); + } + + public void stop() { + this.applicationContext.stop(); + } + + /** + * Perform a request and return a type that allows chaining further actions, + * such as asserting expectations, on the result. + * + * @param requestBuilder used to prepare the request to execute; see static + * factory methods in + * {@link org.springframework.test.web.servlet.request.MockMvcRequestBuilders} + * @return an instance of {@link ResultActions} (never {@code null}) + * @see org.springframework.test.web.servlet.request.MockMvcRequestBuilders + * @see org.springframework.test.web.servlet.result.MockMvcResultMatchers + */ + public void service(HttpServletRequest request, HttpServletResponse response) throws Exception { + ProxyFilterChain filterChain = new ProxyFilterChain(this.servlet, this.filters); + filterChain.doFilter(request, response); + } + + private static class ProxyFilterChain implements FilterChain { + + @Nullable + private ServletRequest request; + + @Nullable + private ServletResponse response; + + private final List filters; + + @Nullable + private Iterator iterator; + + + /** + * Create a {@code FilterChain} with Filter's and a Servlet. + * + * @param servlet the {@link Servlet} to invoke in this {@link FilterChain} + * @param filters the {@link Filter}'s to invoke in this {@link FilterChain} + * @since 3.2 + */ + ProxyFilterChain(Servlet servlet, Filter... filters) { + Assert.notNull(filters, "filters cannot be null"); + Assert.noNullElements(filters, "filters cannot contain null values"); + this.filters = initFilterList(servlet, filters); + } + + private static List initFilterList(Servlet servlet, Filter... filters) { + Filter[] allFilters = ObjectUtils.addObjectToArray(filters, new ServletFilterProxy(servlet)); + return Arrays.asList(allFilters); + } + + /** + * Return the request that {@link #doFilter} has been called with. + */ + @Nullable + public ServletRequest getRequest() { + return this.request; + } + + /** + * Return the response that {@link #doFilter} has been called with. + */ + @Nullable + public ServletResponse getResponse() { + return this.response; + } + + /** + * Invoke registered {@link Filter Filters} and/or {@link Servlet} also saving + * the request and response. + */ + @Override + public void doFilter(ServletRequest request, ServletResponse response) throws IOException, ServletException { + Assert.notNull(request, "Request must not be null"); + Assert.notNull(response, "Response must not be null"); + Assert.state(this.request == null, "This FilterChain has already been called!"); + + if (this.iterator == null) { + this.iterator = this.filters.iterator(); + } + + if (this.iterator.hasNext()) { + Filter nextFilter = this.iterator.next(); + nextFilter.doFilter(request, response, this); + } + + this.request = request; + this.response = response; + } + + /** + * A filter that simply delegates to a Servlet. + */ + private static final class ServletFilterProxy implements Filter { + + private final Servlet delegateServlet; + + private ServletFilterProxy(Servlet servlet) { + Assert.notNull(servlet, "servlet cannot be null"); + this.delegateServlet = servlet; + } + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) + throws IOException, ServletException { + + this.delegateServlet.service(request, response); + } + + @Override + public void init(FilterConfig filterConfig) throws ServletException { + } + + @Override + public void destroy() { + } + + @Override + public String toString() { + return this.delegateServlet.toString(); + } + } + } + + private static class ProxyServletConfig implements ServletConfig { + + private final ServletContext servletContext; + + ProxyServletConfig(ServletContext servletContext) { + this.servletContext = servletContext; + } + + @Override + public String getServletName() { + return "serverless-proxy"; + } + + @Override + public ServletContext getServletContext() { + return this.servletContext; + } + + @Override + public Enumeration getInitParameterNames() { + return Collections.enumeration(new ArrayList()); + } + + @Override + public String getInitParameter(String name) { + return null; + } + } +} diff --git a/spring-cloud-function-adapters/spring-cloud-function-serverless-web/src/main/java/org/springframework/cloud/function/serverless/web/ProxyServletContext.java b/spring-cloud-function-adapters/spring-cloud-function-serverless-web/src/main/java/org/springframework/cloud/function/serverless/web/ProxyServletContext.java new file mode 100644 index 000000000..e9fa8e4e4 --- /dev/null +++ b/spring-cloud-function-adapters/spring-cloud-function-serverless-web/src/main/java/org/springframework/cloud/function/serverless/web/ProxyServletContext.java @@ -0,0 +1,353 @@ +/* + * Copyright 2023-2023 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 org.springframework.cloud.function.serverless.web; + +import java.io.InputStream; +import java.net.MalformedURLException; +import java.net.URL; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Enumeration; +import java.util.EventListener; +import java.util.Map; +import java.util.Set; + +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.ServletRegistration.Dynamic; +import javax.servlet.SessionCookieConfig; +import javax.servlet.SessionTrackingMode; +import javax.servlet.descriptor.JspConfigDescriptor; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +/** + * Empty no-op representation of {@link ServletContext} to satisfy required dependencies to + * successfully proxy incoming web requests to target web application. + * Most methods are not implemented. + * + * @author Oleg Zhurakousky + * + */ +public class ProxyServletContext implements ServletContext { + + private Log logger = LogFactory.getLog(ProxyServletContext.class); + + private static Enumeration EMPTY_ENUM = Collections.enumeration(new ArrayList()); + + @Override + public Enumeration getInitParameterNames() { + return EMPTY_ENUM; + } + + @Override + public Enumeration getAttributeNames() { + return EMPTY_ENUM; + } + + @Override + public String getContextPath() { + return ""; + } + + @Override + public ServletContext getContext(String uripath) { + throw new UnsupportedOperationException("This ServletContext does not represent a running web container"); + } + + @Override + public int getMajorVersion() { + throw new UnsupportedOperationException("This ServletContext does not represent a running web container"); + } + + @Override + public int getMinorVersion() { + throw new UnsupportedOperationException("This ServletContext does not represent a running web container"); + } + + @Override + public int getEffectiveMajorVersion() { + throw new UnsupportedOperationException("This ServletContext does not represent a running web container"); + } + + @Override + public int getEffectiveMinorVersion() { + throw new UnsupportedOperationException("This ServletContext does not represent a running web container"); + } + + @Override + public String getMimeType(String file) { + throw new UnsupportedOperationException("This ServletContext does not represent a running web container"); + } + + @Override + public Set getResourcePaths(String path) { + throw new UnsupportedOperationException("This ServletContext does not represent a running web container"); + } + + @Override + public URL getResource(String path) throws MalformedURLException { + throw new UnsupportedOperationException("This ServletContext does not represent a running web container"); + } + + @Override + public InputStream getResourceAsStream(String path) { + throw new UnsupportedOperationException("This ServletContext does not represent a running web container"); + } + + @Override + public RequestDispatcher getRequestDispatcher(String path) { + throw new UnsupportedOperationException("This ServletContext does not represent a running web container"); + } + + @Override + public RequestDispatcher getNamedDispatcher(String name) { + throw new UnsupportedOperationException("This ServletContext does not represent a running web container"); + } + + @Override + public Servlet getServlet(String name) throws ServletException { + throw new UnsupportedOperationException("This ServletContext does not represent a running web container"); + } + + @Override + public Enumeration getServlets() { + throw new UnsupportedOperationException("This ServletContext does not represent a running web container"); + } + + @Override + public Enumeration getServletNames() { + throw new UnsupportedOperationException("This ServletContext does not represent a running web container"); + } + + @Override + public void log(String msg) { + this.logger.info(msg); + } + + @Override + public void log(Exception exception, String msg) { + this.logger.error(msg, exception); + } + + @Override + public void log(String message, Throwable throwable) { + this.logger.error(message, throwable); + } + + @Override + public String getRealPath(String path) { + throw new UnsupportedOperationException("This ServletContext does not represent a running web container"); + } + + @Override + public String getServerInfo() { + return "serverless-web-proxy"; + } + + @Override + public String getInitParameter(String name) { + return null; + + } + + @Override + public boolean setInitParameter(String name, String value) { + throw new UnsupportedOperationException("This ServletContext does not represent a running web container"); + } + + @Override + public Object getAttribute(String name) { + return null; + } + + @Override + public void setAttribute(String name, Object object) { + } + + @Override + public void removeAttribute(String name) { + throw new UnsupportedOperationException("This ServletContext does not represent a running web container"); + } + + @Override + public String getServletContextName() { + throw new UnsupportedOperationException("This ServletContext does not represent a running web container"); + } + + @Override + public Dynamic addServlet(String servletName, String className) { + throw new UnsupportedOperationException("This ServletContext does not represent a running web container"); + } + + @Override + public Dynamic addServlet(String servletName, Servlet servlet) { + throw new UnsupportedOperationException("This ServletContext does not represent a running web container"); + } + + @Override + public Dynamic addServlet(String servletName, Class servletClass) { + throw new UnsupportedOperationException("This ServletContext does not represent a running web container"); + } + + @Override + public Dynamic addJspFile(String jspName, String jspFile) { + throw new UnsupportedOperationException("This ServletContext does not represent a running web container"); + } + + @Override + public T createServlet(Class c) throws ServletException { + throw new UnsupportedOperationException("This ServletContext does not represent a running web container"); + } + + @Override + public ServletRegistration getServletRegistration(String servletName) { + throw new UnsupportedOperationException("This ServletContext does not represent a running web container"); + } + + @Override + public Map getServletRegistrations() { + throw new UnsupportedOperationException("This ServletContext does not represent a running web container"); + } + + @Override + public javax.servlet.FilterRegistration.Dynamic addFilter(String filterName, String className) { + throw new UnsupportedOperationException("This ServletContext does not represent a running web container"); + } + + @Override + public javax.servlet.FilterRegistration.Dynamic addFilter(String filterName, Filter filter) { + throw new UnsupportedOperationException("This ServletContext does not represent a running web container"); + } + + @Override + public javax.servlet.FilterRegistration.Dynamic addFilter(String filterName, Class filterClass) { + throw new UnsupportedOperationException("This ServletContext does not represent a running web container"); + } + + @Override + public T createFilter(Class c) throws ServletException { + throw new UnsupportedOperationException("This ServletContext does not represent a running web container"); + } + + @Override + public FilterRegistration getFilterRegistration(String filterName) { + throw new UnsupportedOperationException("This ServletContext does not represent a running web container"); + } + + @Override + public Map getFilterRegistrations() { + throw new UnsupportedOperationException("This ServletContext does not represent a running web container"); + } + + @Override + public SessionCookieConfig getSessionCookieConfig() { + throw new UnsupportedOperationException("This ServletContext does not represent a running web container"); + } + + @Override + public void setSessionTrackingModes(Set sessionTrackingModes) { + throw new UnsupportedOperationException("This ServletContext does not represent a running web container"); + } + + @Override + public Set getDefaultSessionTrackingModes() { + throw new UnsupportedOperationException("This ServletContext does not represent a running web container"); + } + + @Override + public Set getEffectiveSessionTrackingModes() { + throw new UnsupportedOperationException("This ServletContext does not represent a running web container"); + } + + @Override + public void addListener(String className) { + throw new UnsupportedOperationException("This ServletContext does not represent a running web container"); + } + + @Override + public void addListener(T t) { + // TODO Auto-generated method stub + + } + + @Override + public void addListener(Class listenerClass) { + throw new UnsupportedOperationException("This ServletContext does not represent a running web container"); + } + + @Override + public T createListener(Class c) throws ServletException { + throw new UnsupportedOperationException("This ServletContext does not represent a running web container"); + } + + @Override + public JspConfigDescriptor getJspConfigDescriptor() { + throw new UnsupportedOperationException("This ServletContext does not represent a running web container"); + } + + @Override + public ClassLoader getClassLoader() { + throw new UnsupportedOperationException("This ServletContext does not represent a running web container"); + } + + @Override + public void declareRoles(String... roleNames) { + throw new UnsupportedOperationException("This ServletContext does not represent a running web container"); + } + + @Override + public String getVirtualServerName() { + throw new UnsupportedOperationException("This ServletContext does not represent a running web container"); + } + + @Override + public int getSessionTimeout() { + throw new UnsupportedOperationException("This ServletContext does not represent a running web container"); + } + + @Override + public void setSessionTimeout(int sessionTimeout) { + throw new UnsupportedOperationException("This ServletContext does not represent a running web container"); + } + + @Override + public String getRequestCharacterEncoding() { + throw new UnsupportedOperationException("This ServletContext does not represent a running web container"); + } + + @Override + public void setRequestCharacterEncoding(String encoding) { + throw new UnsupportedOperationException("This ServletContext does not represent a running web container"); + } + + @Override + public String getResponseCharacterEncoding() { + throw new UnsupportedOperationException("This ServletContext does not represent a running web container"); + } + + @Override + public void setResponseCharacterEncoding(String encoding) { + throw new UnsupportedOperationException("This ServletContext does not represent a running web container"); + } +} diff --git a/spring-cloud-function-adapters/spring-cloud-function-serverless-web/src/test/java/org/springframework/cloud/function/adapter/aws/web/Pet.java b/spring-cloud-function-adapters/spring-cloud-function-serverless-web/src/test/java/org/springframework/cloud/function/adapter/aws/web/Pet.java new file mode 100644 index 000000000..846faca8e --- /dev/null +++ b/spring-cloud-function-adapters/spring-cloud-function-serverless-web/src/test/java/org/springframework/cloud/function/adapter/aws/web/Pet.java @@ -0,0 +1,58 @@ +/* + * Copyright 2023-2023 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 org.springframework.cloud.function.adapter.aws.web; + +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/spring-cloud-function-adapters/spring-cloud-function-serverless-web/src/test/java/org/springframework/cloud/function/adapter/aws/web/PetData.java b/spring-cloud-function-adapters/spring-cloud-function-serverless-web/src/test/java/org/springframework/cloud/function/adapter/aws/web/PetData.java new file mode 100644 index 000000000..c0fda4d4a --- /dev/null +++ b/spring-cloud-function-adapters/spring-cloud-function-serverless-web/src/test/java/org/springframework/cloud/function/adapter/aws/web/PetData.java @@ -0,0 +1,118 @@ +/* + * Copyright 2023-2023 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 org.springframework.cloud.function.adapter.aws.web; + + +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/spring-cloud-function-adapters/spring-cloud-function-serverless-web/src/test/java/org/springframework/cloud/function/adapter/aws/web/PetStoreSpringAppConfig.java b/spring-cloud-function-adapters/spring-cloud-function-serverless-web/src/test/java/org/springframework/cloud/function/adapter/aws/web/PetStoreSpringAppConfig.java new file mode 100644 index 000000000..93888ccdd --- /dev/null +++ b/spring-cloud-function-adapters/spring-cloud-function-serverless-web/src/test/java/org/springframework/cloud/function/adapter/aws/web/PetStoreSpringAppConfig.java @@ -0,0 +1,69 @@ +/* + * Copyright 2023-2023 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 org.springframework.cloud.function.adapter.aws.web; + +import java.io.IOException; + +import javax.servlet.Filter; +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; + +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.HandlerMapping; +import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter; +import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping; + +//@SpringBootApplication +@Configuration +@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(); + } + + @Bean + public Filter filter() { + return new Filter() { + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) + throws IOException, ServletException { + System.out.println("FILTER ===> Hello from: " + request.getLocalAddr()); + chain.doFilter(request, response); + } + }; + } + +} diff --git a/spring-cloud-function-adapters/spring-cloud-function-serverless-web/src/test/java/org/springframework/cloud/function/adapter/aws/web/PetsController.java b/spring-cloud-function-adapters/spring-cloud-function-serverless-web/src/test/java/org/springframework/cloud/function/adapter/aws/web/PetsController.java new file mode 100644 index 000000000..544e2f806 --- /dev/null +++ b/spring-cloud-function-adapters/spring-cloud-function-serverless-web/src/test/java/org/springframework/cloud/function/adapter/aws/web/PetsController.java @@ -0,0 +1,75 @@ +/* + * Copyright 2023-2023 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 org.springframework.cloud.function.adapter.aws.web; + +import java.security.Principal; +import java.util.Optional; +import java.util.UUID; + +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; + +@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() { + System.out.println("=====> Getting pet by id"); + 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/spring-cloud-function-adapters/spring-cloud-function-serverless-web/src/test/java/org/springframework/cloud/function/adapter/aws/web/RequestResponseTests.java b/spring-cloud-function-adapters/spring-cloud-function-serverless-web/src/test/java/org/springframework/cloud/function/adapter/aws/web/RequestResponseTests.java new file mode 100644 index 000000000..26e0c735e --- /dev/null +++ b/spring-cloud-function-adapters/spring-cloud-function-serverless-web/src/test/java/org/springframework/cloud/function/adapter/aws/web/RequestResponseTests.java @@ -0,0 +1,88 @@ +package org.springframework.cloud.function.adapter.aws.web; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; + +import javax.servlet.http.HttpServletRequest; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.cloud.function.serverless.web.ProxyHttpServletRequest; +import org.springframework.cloud.function.serverless.web.ProxyHttpServletResponse; +import org.springframework.cloud.function.serverless.web.ProxyMvc; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; + +public class RequestResponseTests { + + private ObjectMapper mapper = new ObjectMapper(); + + private ProxyMvc mvc; + + @BeforeEach + public void before() { + this.mvc = ProxyMvc.INSTANCE(PetStoreSpringAppConfig.class); + } + + @AfterEach + public void after() { + this.mvc.stop(); + } + + @Test + public void validateGetListOfPojos() throws Exception { + HttpServletRequest request = new ProxyHttpServletRequest(null, "GET", "/pets"); + ProxyHttpServletResponse response = new ProxyHttpServletResponse(); + mvc.service(request, response); + TypeReference> tr = new TypeReference>() { + }; + List pets = mapper.readValue(response.getContentAsByteArray(), tr); + assertThat(pets.size()).isEqualTo(10); + assertThat(pets.get(0)).isInstanceOf(Pet.class); + } + + @Test + public void validateGetListOfPojosWithParam() throws Exception { + ProxyHttpServletRequest request = new ProxyHttpServletRequest(null, "GET", "/pets"); + request.setParameter("limit", "5"); + ProxyHttpServletResponse response = new ProxyHttpServletResponse(); + mvc.service(request, response); + TypeReference> tr = new TypeReference>() { + }; + List pets = mapper.readValue(response.getContentAsByteArray(), tr); + assertThat(pets.size()).isEqualTo(5); + assertThat(pets.get(0)).isInstanceOf(Pet.class); + } + + @Test + public void validateGetPojo() throws Exception { + HttpServletRequest request = new ProxyHttpServletRequest(null, "GET", "/pets/6e3cc370-892f-4efe-a9eb-82926ff8cc5b"); + ProxyHttpServletResponse response = new ProxyHttpServletResponse(); + mvc.service(request, response); + Pet pet = mapper.readValue(response.getContentAsByteArray(), Pet.class); + assertThat(pet).isNotNull(); + assertThat(pet.getName()).isNotEmpty(); + } + + @Test + public void validatePostWithBody() throws Exception { + ProxyHttpServletRequest request = new ProxyHttpServletRequest(null, "POST", "/pets/"); + String jsonPet = "{\n" + + " \"id\":\"1234\",\n" + + " \"breed\":\"Canish\",\n" + + " \"name\":\"Foo\",\n" + + " \"date\":\"2012-04-23T18:25:43.511Z\"\n" + + "}"; + request.setContent(jsonPet.getBytes()); + request.setContentType("application/json"); + ProxyHttpServletResponse response = new ProxyHttpServletResponse(); + mvc.service(request, response); + Pet pet = mapper.readValue(response.getContentAsByteArray(), Pet.class); + assertThat(pet).isNotNull(); + assertThat(pet.getName()).isNotEmpty(); + } + +}