From fc9d16e0a5dd14872f41e5da77f6b2d280a1ff11 Mon Sep 17 00:00:00 2001 From: Thorsten Schlathoelter Date: Tue, 11 Jun 2024 16:01:12 +0200 Subject: [PATCH] feat(#285): support OpenAPI 3.0 from HttpOperationScenario --- pom.xml | 1 + .../simulator/SimulatorMailIT.java | 2 + .../simulator/sample/Simulator.java | 47 +- .../SpecificPingResponseMessageBuilder.java | 141 ++ .../src/main/resources/openapi/README.md | 2 + .../main/resources/openapi/petstore-v3.json | 1228 +++++++++++++++++ .../src/main/resources/openapi/ping-v1.yaml | 112 ++ .../src/main/resources/swagger/README.md | 3 + .../main/resources/swagger/petstore-api.json | 166 ++- .../citrusframework/simulator/OpenApiIT.java | 328 +++++ .../simulator/ResponseGeneratorIT.java | 187 +++ .../simulator/SimulatorSwaggerIT.java | 161 ++- .../src/test/resources/templates/order.json | 4 +- .../templates/order_invalid_date.json | 8 + .../test/resources/templates/pet_invalid.json | 14 + .../src/test/resources/templates/ping.json | 3 + simulator-spring-boot/pom.xml | 4 + .../simulator/http/HttpOperationScenario.java | 462 ++----- .../http/HttpOperationScenarioRegistrar.java | 48 + .../http/HttpPathSpecificityComparator.java | 84 ++ .../HttpRequestAnnotationScenarioMapper.java | 101 +- .../http/HttpRequestPathScenarioMapper.java | 104 +- .../HttpResponseActionBuilderProvider.java | 32 + .../simulator/http/HttpScenario.java | 11 + .../simulator/http/HttpScenarioGenerator.java | 206 ++- .../http/SimulatorRestAutoConfiguration.java | 21 +- .../SimulatorRestConfigurationProperties.java | 70 +- .../IdentifiableSimulatorScenario.java | 6 + .../scenario/mapper/ScenarioMappers.java | 29 +- .../impl/ScenarioLookupServiceImpl.java | 14 +- .../openapi/processor/scenarioRegistrar | 2 + .../http/HttpOperationScenarioIT.java | 268 ++++ .../HttpPathSpecificityComparatorTest.java | 50 + .../HttpRequestPathScenarioMapperTest.java | 164 ++- .../http/HttpScenarioGeneratorTest.java | 232 ++-- .../src/test/resources/data/addPet.json | 15 + .../test/resources/data/addPet_incorrect.json | 15 + .../{swagger-api.json => petstore-v2.json} | 6 +- .../test/resources/swagger/petstore-v3.json | 254 ++++ simulator-ui/.npmrc | 1 - 40 files changed, 3793 insertions(+), 813 deletions(-) create mode 100644 simulator-samples/sample-swagger/src/main/java/org/citrusframework/simulator/sample/SpecificPingResponseMessageBuilder.java create mode 100644 simulator-samples/sample-swagger/src/main/resources/openapi/README.md create mode 100644 simulator-samples/sample-swagger/src/main/resources/openapi/petstore-v3.json create mode 100644 simulator-samples/sample-swagger/src/main/resources/openapi/ping-v1.yaml create mode 100644 simulator-samples/sample-swagger/src/main/resources/swagger/README.md create mode 100644 simulator-samples/sample-swagger/src/test/java/org/citrusframework/simulator/OpenApiIT.java create mode 100644 simulator-samples/sample-swagger/src/test/java/org/citrusframework/simulator/ResponseGeneratorIT.java create mode 100644 simulator-samples/sample-swagger/src/test/resources/templates/order_invalid_date.json create mode 100644 simulator-samples/sample-swagger/src/test/resources/templates/pet_invalid.json create mode 100644 simulator-samples/sample-swagger/src/test/resources/templates/ping.json create mode 100644 simulator-spring-boot/src/main/java/org/citrusframework/simulator/http/HttpOperationScenarioRegistrar.java create mode 100644 simulator-spring-boot/src/main/java/org/citrusframework/simulator/http/HttpPathSpecificityComparator.java create mode 100644 simulator-spring-boot/src/main/java/org/citrusframework/simulator/http/HttpResponseActionBuilderProvider.java create mode 100644 simulator-spring-boot/src/main/java/org/citrusframework/simulator/http/HttpScenario.java create mode 100644 simulator-spring-boot/src/main/java/org/citrusframework/simulator/scenario/IdentifiableSimulatorScenario.java create mode 100644 simulator-spring-boot/src/main/resources/META-INF/citrus/openapi/processor/scenarioRegistrar create mode 100644 simulator-spring-boot/src/test/java/org/citrusframework/simulator/http/HttpOperationScenarioIT.java create mode 100644 simulator-spring-boot/src/test/java/org/citrusframework/simulator/http/HttpPathSpecificityComparatorTest.java create mode 100644 simulator-spring-boot/src/test/resources/data/addPet.json create mode 100644 simulator-spring-boot/src/test/resources/data/addPet_incorrect.json rename simulator-spring-boot/src/test/resources/swagger/{swagger-api.json => petstore-v2.json} (99%) create mode 100644 simulator-spring-boot/src/test/resources/swagger/petstore-v3.json delete mode 100644 simulator-ui/.npmrc diff --git a/pom.xml b/pom.xml index 59f9df22c..fa431aa61 100644 --- a/pom.xml +++ b/pom.xml @@ -526,4 +526,5 @@ + diff --git a/simulator-samples/sample-mail/src/test/java/org/citrusframework/simulator/SimulatorMailIT.java b/simulator-samples/sample-mail/src/test/java/org/citrusframework/simulator/SimulatorMailIT.java index 195fd84df..c235f3147 100644 --- a/simulator-samples/sample-mail/src/test/java/org/citrusframework/simulator/SimulatorMailIT.java +++ b/simulator-samples/sample-mail/src/test/java/org/citrusframework/simulator/SimulatorMailIT.java @@ -29,6 +29,7 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.test.context.ContextConfiguration; +import org.testng.annotations.Ignore; import org.testng.annotations.Test; import static org.citrusframework.actions.SendMessageAction.Builder.send; @@ -37,6 +38,7 @@ * @author Christoph Deppisch */ @Test +@Ignore @ContextConfiguration(classes = SimulatorMailIT.EndpointConfig.class) public class SimulatorMailIT extends TestNGCitrusSpringSupport { diff --git a/simulator-samples/sample-swagger/src/main/java/org/citrusframework/simulator/sample/Simulator.java b/simulator-samples/sample-swagger/src/main/java/org/citrusframework/simulator/sample/Simulator.java index b06ad5291..26e3347db 100644 --- a/simulator-samples/sample-swagger/src/main/java/org/citrusframework/simulator/sample/Simulator.java +++ b/simulator-samples/sample-swagger/src/main/java/org/citrusframework/simulator/sample/Simulator.java @@ -21,13 +21,15 @@ import org.citrusframework.endpoint.adapter.StaticEndpointAdapter; import org.citrusframework.http.message.HttpMessage; import org.citrusframework.message.Message; -import org.citrusframework.simulator.scenario.mapper.ScenarioMapper; -import org.citrusframework.simulator.scenario.mapper.ScenarioMappers; +import org.citrusframework.openapi.OpenApiRepository; import org.citrusframework.simulator.http.HttpRequestAnnotationScenarioMapper; import org.citrusframework.simulator.http.HttpRequestPathScenarioMapper; +import org.citrusframework.simulator.http.HttpResponseActionBuilderProvider; import org.citrusframework.simulator.http.HttpScenarioGenerator; import org.citrusframework.simulator.http.SimulatorRestAdapter; import org.citrusframework.simulator.http.SimulatorRestConfigurationProperties; +import org.citrusframework.simulator.scenario.mapper.ScenarioMapper; +import org.citrusframework.simulator.scenario.mapper.ScenarioMappers; import org.citrusframework.spi.Resources; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @@ -47,12 +49,13 @@ public static void main(String[] args) { @Override public ScenarioMapper scenarioMapper() { return ScenarioMappers.of(new HttpRequestPathScenarioMapper(), - new HttpRequestAnnotationScenarioMapper()); + new HttpRequestAnnotationScenarioMapper()); } @Override - public List urlMappings(SimulatorRestConfigurationProperties simulatorRestConfiguration) { - return List.of("/petstore/v2/**"); + public List urlMappings( + SimulatorRestConfigurationProperties simulatorRestConfiguration) { + return List.of("/petstore/v2/**", "/petstore/api/v3/**", "/pingapi/v1/**"); } @Override @@ -67,8 +70,36 @@ protected Message handleMessageInternal(Message message) { @Bean public static HttpScenarioGenerator scenarioGenerator() { - HttpScenarioGenerator generator = new HttpScenarioGenerator(new Resources.ClasspathResource("swagger/petstore-api.json")); - generator.setContextPath("/petstore"); - return generator; + return new HttpScenarioGenerator( + Resources.create("classpath:swagger/petstore-api.json")); } + + @Bean + public static OpenApiRepository swaggerRepository() { + OpenApiRepository openApiRepository = new OpenApiRepository(); + openApiRepository.setRootContextPath("/petstore"); + openApiRepository.setLocations(List.of("swagger/petstore-api.json")); + return openApiRepository; + } + + @Bean + public static OpenApiRepository openApiRepository() { + OpenApiRepository openApiRepository = new OpenApiRepository(); + openApiRepository.setRootContextPath("/petstore"); + openApiRepository.setLocations(List.of("openapi/petstore-v3.json")); + return openApiRepository; + } + + @Bean + public static OpenApiRepository pingApiRepository() { + OpenApiRepository openApiRepository = new OpenApiRepository(); + openApiRepository.setLocations(List.of("openapi/ping-v1.yaml")); + return openApiRepository; + } + + @Bean + static HttpResponseActionBuilderProvider httpResponseActionBuilderProvider() { + return new SpecificPingResponseMessageBuilder(); + } + } diff --git a/simulator-samples/sample-swagger/src/main/java/org/citrusframework/simulator/sample/SpecificPingResponseMessageBuilder.java b/simulator-samples/sample-swagger/src/main/java/org/citrusframework/simulator/sample/SpecificPingResponseMessageBuilder.java new file mode 100644 index 000000000..376230612 --- /dev/null +++ b/simulator-samples/sample-swagger/src/main/java/org/citrusframework/simulator/sample/SpecificPingResponseMessageBuilder.java @@ -0,0 +1,141 @@ +package org.citrusframework.simulator.sample; + +import static java.lang.String.format; +import static org.citrusframework.openapi.OpenApiSettings.getResponseAutoFillRandomValues; + +import io.apicurio.datamodels.openapi.models.OasOperation; +import java.util.HashMap; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.apache.commons.lang3.function.TriFunction; +import org.citrusframework.http.actions.HttpServerResponseActionBuilder; +import org.citrusframework.http.message.HttpMessage; +import org.citrusframework.http.message.HttpMessageHeaders; +import org.citrusframework.message.MessageType; +import org.citrusframework.openapi.actions.OpenApiActionBuilder; +import org.citrusframework.openapi.actions.OpenApiServerActionBuilder; +import org.citrusframework.openapi.actions.OpenApiServerResponseActionBuilder; +import org.citrusframework.simulator.http.HttpOperationScenario; +import org.citrusframework.simulator.http.HttpResponseActionBuilderProvider; +import org.citrusframework.simulator.scenario.ScenarioRunner; +import org.citrusframework.simulator.scenario.SimulatorScenario; +import org.springframework.http.MediaType; + +/** + * {@link HttpResponseActionBuilderProvider} that provides specific responses for dedicated ping + * calls. Shows, how to use a {@link HttpResponseActionBuilderProvider} to control the random + * message generation. + */ +public class SpecificPingResponseMessageBuilder implements HttpResponseActionBuilderProvider { + + private static final int MISSING_ID = Integer.MIN_VALUE; + + /** + * Function that returns null to indicate, that the provider does not provide a builder for the given scenario. + */ + private static final TriFunction NULL_RESPONSE = SpecificPingResponseMessageBuilder::createNull; + + /** + * Map to store specific functions per ping id. + */ + private static final Map> SPECIFC_BUILDER_MAP = new HashMap<>(); + + // Specific responses for some ids, all others will be handled by returning null and letting the random generator do its work. + static { + SPECIFC_BUILDER_MAP.put(15000, + SpecificPingResponseMessageBuilder::createResponseWithDedicatedRequiredHeader); + SPECIFC_BUILDER_MAP.put(10000, + SpecificPingResponseMessageBuilder::createResponseWithMessageAndHeaders); + SPECIFC_BUILDER_MAP.put(5000, SpecificPingResponseMessageBuilder::createResponseWithSpecificBody); + SPECIFC_BUILDER_MAP.put(4000, + SpecificPingResponseMessageBuilder::createResponseWithRandomGenerationSuppressed); + } + + @Override + public HttpServerResponseActionBuilder provideHttpServerResponseActionBuilder( + ScenarioRunner scenarioRunner, SimulatorScenario simulatorScenario, + HttpMessage receivedMessage) { + + if (!(simulatorScenario instanceof HttpOperationScenario httpOperationScenario)) { + return null; + } + + OpenApiServerActionBuilder openApiServerActionBuilder = new OpenApiActionBuilder( + httpOperationScenario.getOpenApiSpecification()).server(scenarioRunner.getScenarioEndpoint()); + + return SPECIFC_BUILDER_MAP.getOrDefault(getIdFromPingRequest(receivedMessage), NULL_RESPONSE).apply(openApiServerActionBuilder, httpOperationScenario.getOperation(), receivedMessage); + } + + private static Integer getIdFromPingRequest(HttpMessage httpMessage) { + String uri = httpMessage.getUri(); + Pattern pattern = Pattern.compile("/pingapi/v1/ping/(\\d*)"); + Matcher matcher = pattern.matcher(uri); + if (matcher.matches()) { + return Integer.parseInt(matcher.group(1)); + } + return MISSING_ID; + } + + /** + * Sample to prove, that random data generation can be suppressed. Note that the generated + * response is thus invalid and will result in an error. + */ + private static OpenApiServerResponseActionBuilder createResponseWithRandomGenerationSuppressed( + OpenApiServerActionBuilder openApiServerActionBuilder, OasOperation oasOperation, + HttpMessage receivedMessage) { + OpenApiServerResponseActionBuilder sendMessageBuilder = openApiServerActionBuilder.send( + oasOperation.operationId, "200").enableRandomGeneration(getResponseAutoFillRandomValues()); + sendMessageBuilder.message().body(format("{\"id\": %d, \"pingTime\": %d}", + getIdFromPingRequest(receivedMessage), System.currentTimeMillis())); + return sendMessageBuilder; + } + + /** + * Sample to prove, that the body content can be controlled, while headers will be generated by + * random generator. + */ + private static OpenApiServerResponseActionBuilder createResponseWithSpecificBody( + OpenApiServerActionBuilder openApiServerActionBuilder, OasOperation oasOperation, + HttpMessage receivedMessage) { + OpenApiServerResponseActionBuilder sendMessageBuilder = openApiServerActionBuilder.send( + oasOperation.operationId, "200"); + sendMessageBuilder.message().body(format("{\"id\": %d, \"pingCount\": %d}", + getIdFromPingRequest(receivedMessage), System.currentTimeMillis())); + return sendMessageBuilder; + } + + /** + * Sample to prove, that the status, response and headers can be controlled and are not + * overwritten by random generator. + */ + private static OpenApiServerResponseActionBuilder createResponseWithMessageAndHeaders( + OpenApiServerActionBuilder openApiServerActionBuilder, OasOperation oasOperation, + HttpMessage receivedMessage) { + OpenApiServerResponseActionBuilder sendMessageBuilder = openApiServerActionBuilder.send( + oasOperation.operationId, "400", receivedMessage.getAccept()); + sendMessageBuilder.message().type(MessageType.PLAINTEXT) + .header(HttpMessageHeaders.HTTP_CONTENT_TYPE, MediaType.TEXT_PLAIN_VALUE) + .header("Ping-Time", "1").body("Requests with id == 10000 cannot be processed!"); + return sendMessageBuilder; + } + + /** + * Sample to prove, that a preset header can be controlled, while generating a valid random + * response. + */ + private static OpenApiServerResponseActionBuilder createResponseWithDedicatedRequiredHeader( + OpenApiServerActionBuilder openApiServerActionBuilder, OasOperation oasOperation, + HttpMessage receivedMessage) { + OpenApiServerResponseActionBuilder sendMessageBuilder = openApiServerActionBuilder.send( + oasOperation.operationId, "200", receivedMessage.getAccept()); + sendMessageBuilder.message().header("Ping-Time", "0"); + return sendMessageBuilder; + } + + private static OpenApiServerResponseActionBuilder createNull( + OpenApiServerActionBuilder ignoreOpenApiServerActionBuilder, + OasOperation ignoreOasOperation, HttpMessage ignoreReceivedMessage) { + return null; + } +} diff --git a/simulator-samples/sample-swagger/src/main/resources/openapi/README.md b/simulator-samples/sample-swagger/src/main/resources/openapi/README.md new file mode 100644 index 000000000..12756841b --- /dev/null +++ b/simulator-samples/sample-swagger/src/main/resources/openapi/README.md @@ -0,0 +1,2 @@ +Note, that the petstore-v3.json has been slightly modified from its original version. +OK messages have been added, where missing, to be able to activate the response validation feature. diff --git a/simulator-samples/sample-swagger/src/main/resources/openapi/petstore-v3.json b/simulator-samples/sample-swagger/src/main/resources/openapi/petstore-v3.json new file mode 100644 index 000000000..50ad9521a --- /dev/null +++ b/simulator-samples/sample-swagger/src/main/resources/openapi/petstore-v3.json @@ -0,0 +1,1228 @@ +{ + "openapi": "3.0.2", + "info": { + "title": "Swagger Petstore - OpenAPI 3.0", + "description": "This is a sample Pet Store Server based on the OpenAPI 3.0 specification. You can find out more about\nSwagger at [http://swagger.io](http://swagger.io). In the third iteration of the pet store, we've switched to the design first approach!\nYou can now help us improve the API whether it's by making changes to the definition itself or to the code.\nThat way, with time, we can improve the API in general, and expose some of the new features in OAS3.\n\nSome useful links:\n- [The Pet Store repository](https://github.com/swagger-api/swagger-petstore)\n- [The source API definition for the Pet Store](https://github.com/swagger-api/swagger-petstore/blob/master/src/main/resources/openapi.yaml)", + "termsOfService": "http://swagger.io/terms/", + "contact": { + "email": "apiteam@swagger.io" + }, + "license": { + "name": "Apache 2.0", + "url": "http://www.apache.org/licenses/LICENSE-2.0.html" + }, + "version": "1.0.19" + }, + "externalDocs": { + "description": "Find out more about Swagger", + "url": "http://swagger.io" + }, + "servers": [ + { + "url": "http://localhost/api/v3" + } + ], + "tags": [ + { + "name": "pet", + "description": "Everything about your Pets", + "externalDocs": { + "description": "Find out more", + "url": "http://swagger.io" + } + }, + { + "name": "store", + "description": "Access to Petstore orders", + "externalDocs": { + "description": "Find out more about our store", + "url": "http://swagger.io" + } + }, + { + "name": "user", + "description": "Operations about user" + } + ], + "paths": { + "/pet": { + "put": { + "tags": [ + "pet" + ], + "summary": "Update an existing pet", + "description": "Update an existing pet by Id", + "operationId": "updatePet", + "requestBody": { + "description": "Update an existent pet in the store", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful operation", + "content": { + "application/xml": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + } + } + }, + "400": { + "description": "Invalid ID supplied" + }, + "404": { + "description": "Pet not found" + }, + "405": { + "description": "Validation exception" + } + }, + "security": [ + { + "petstore_auth": [ + "write:pets", + "read:pets" + ] + } + ] + }, + "post": { + "tags": [ + "pet" + ], + "summary": "Add a new pet to the store", + "description": "Add a new pet to the store", + "operationId": "addPet", + "requestBody": { + "description": "Create a new pet in the store", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful operation", + "content": { + "application/xml": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + } + } + }, + "405": { + "description": "Invalid input" + } + }, + "security": [ + { + "petstore_auth": [ + "write:pets", + "read:pets" + ] + } + ] + } + }, + "/pet/findByStatus": { + "get": { + "tags": [ + "pet" + ], + "summary": "Finds Pets by status", + "description": "Multiple status values can be provided with comma separated strings", + "operationId": "findPetsByStatus", + "parameters": [ + { + "name": "status", + "in": "query", + "description": "Status values that need to be considered for filter", + "required": false, + "explode": true, + "schema": { + "type": "string", + "default": "available", + "enum": [ + "available", + "pending", + "sold" + ] + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/xml": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Pet" + } + } + }, + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Pet" + } + } + } + } + }, + "400": { + "description": "Invalid status value" + } + }, + "security": [ + { + "petstore_auth": [ + "write:pets", + "read:pets" + ] + } + ] + } + }, + "/pet/findByTags": { + "get": { + "tags": [ + "pet" + ], + "summary": "Finds Pets by tags", + "description": "Multiple tags can be provided with comma separated strings. Use tag1, tag2, tag3 for testing.", + "operationId": "findPetsByTags", + "parameters": [ + { + "name": "tags", + "in": "query", + "description": "Tags to filter by", + "required": false, + "explode": true, + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/xml": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Pet" + } + } + }, + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Pet" + } + } + } + } + }, + "400": { + "description": "Invalid tag value" + } + }, + "security": [ + { + "petstore_auth": [ + "write:pets", + "read:pets" + ] + } + ] + } + }, + "/pet/{petId}": { + "get": { + "tags": [ + "pet" + ], + "summary": "Find pet by ID", + "description": "Returns a single pet", + "operationId": "getPetById", + "parameters": [ + { + "name": "petId", + "in": "path", + "description": "ID of pet to return", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/xml": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + } + } + }, + "400": { + "description": "Invalid ID supplied" + }, + "404": { + "description": "Pet not found" + } + }, + "security": [ + { + "api_key": [] + }, + { + "petstore_auth": [ + "write:pets", + "read:pets" + ] + } + ] + }, + "post": { + "tags": [ + "pet" + ], + "summary": "Updates a pet in the store with form data", + "description": "", + "operationId": "updatePetWithForm", + "parameters": [ + { + "name": "petId", + "in": "path", + "description": "ID of pet that needs to be updated", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "name", + "in": "query", + "description": "Name of pet that needs to be updated", + "schema": { + "type": "string" + } + }, + { + "name": "status", + "in": "query", + "description": "Status of pet that needs to be updated", + "schema": { + "type": "string" + } + } + ], + "responses": { + "405": { + "description": "Invalid input" + } + }, + "security": [ + { + "petstore_auth": [ + "write:pets", + "read:pets" + ] + } + ] + }, + "delete": { + "tags": [ + "pet" + ], + "summary": "Deletes a pet", + "description": "", + "operationId": "deletePet", + "parameters": [ + { + "name": "api_key", + "in": "header", + "description": "", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "petId", + "in": "path", + "description": "Pet id to delete", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "Successful operation" + }, + "400": { + "description": "Invalid pet value" + } + }, + "security": [ + { + "petstore_auth": [ + "write:pets", + "read:pets" + ] + } + ] + } + }, + "/pet/{petId}/uploadImage": { + "post": { + "tags": [ + "pet" + ], + "summary": "uploads an image", + "description": "", + "operationId": "uploadFile", + "parameters": [ + { + "name": "petId", + "in": "path", + "description": "ID of pet to update", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "additionalMetadata", + "in": "query", + "description": "Additional Metadata", + "required": false, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/octet-stream": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + } + }, + "security": [ + { + "petstore_auth": [ + "write:pets", + "read:pets" + ] + } + ] + } + }, + "/store/inventory": { + "get": { + "tags": [ + "store" + ], + "summary": "Returns pet inventories by status", + "description": "Returns a map of status codes to quantities", + "operationId": "getInventory", + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "type": "integer", + "format": "int32" + } + } + } + } + } + }, + "security": [ + { + "api_key": [] + } + ] + } + }, + "/store/order": { + "post": { + "tags": [ + "store" + ], + "summary": "Place an order for a pet", + "description": "Place a new order in the store", + "operationId": "placeOrder", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Order" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/Order" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/Order" + } + } + } + }, + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Order" + } + } + } + }, + "405": { + "description": "Invalid input" + } + } + } + }, + "/store/order/{orderId}": { + "get": { + "tags": [ + "store" + ], + "summary": "Find purchase order by ID", + "description": "For valid response try integer IDs with value <= 5 or > 10. Other values will generate exceptions.", + "operationId": "getOrderById", + "parameters": [ + { + "name": "orderId", + "in": "path", + "description": "ID of order that needs to be fetched", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/xml": { + "schema": { + "$ref": "#/components/schemas/Order" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Order" + } + } + } + }, + "400": { + "description": "Invalid ID supplied" + }, + "404": { + "description": "Order not found" + } + } + }, + "delete": { + "tags": [ + "store" + ], + "summary": "Delete purchase order by ID", + "description": "For valid response try integer IDs with value < 1000. Anything above 1000 or nonintegers will generate API errors", + "operationId": "deleteOrder", + "parameters": [ + { + "name": "orderId", + "in": "path", + "description": "ID of the order that needs to be deleted", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "400": { + "description": "Invalid ID supplied" + }, + "404": { + "description": "Order not found" + } + } + } + }, + "/user": { + "post": { + "tags": [ + "user" + ], + "summary": "Create user", + "description": "This can only be done by the logged in user.", + "operationId": "createUser", + "requestBody": { + "description": "Created user object", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/User" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + }, + "responses": { + "default": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + } + } + } + }, + "/user/createWithList": { + "post": { + "tags": [ + "user" + ], + "summary": "Creates list of users with given input array", + "description": "Creates list of users with given input array", + "operationId": "createUsersWithListInput", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/User" + } + } + } + } + }, + "responses": { + "200": { + "description": "Successful operation", + "content": { + "application/xml": { + "schema": { + "$ref": "#/components/schemas/User" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + }, + "default": { + "description": "successful operation" + } + } + } + }, + "/user/login": { + "get": { + "tags": [ + "user" + ], + "summary": "Logs user into the system", + "description": "", + "operationId": "loginUser", + "parameters": [ + { + "name": "username", + "in": "query", + "description": "The user name for login", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "password", + "in": "query", + "description": "The password for login in clear text", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "headers": { + "X-Rate-Limit": { + "description": "calls per hour allowed by the user", + "schema": { + "type": "integer", + "format": "int32" + } + }, + "X-Expires-After": { + "description": "date in UTC when token expires", + "schema": { + "type": "string", + "format": "date-time" + } + } + }, + "content": { + "application/xml": { + "schema": { + "type": "string" + } + }, + "application/json": { + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "Invalid username/password supplied" + } + } + } + }, + "/user/logout": { + "get": { + "tags": [ + "user" + ], + "summary": "Logs out current logged in user session", + "description": "", + "operationId": "logoutUser", + "parameters": [], + "responses": { + "default": { + "description": "successful operation" + } + } + } + }, + "/user/{username}": { + "get": { + "tags": [ + "user" + ], + "summary": "Get user by user name", + "description": "", + "operationId": "getUserByName", + "parameters": [ + { + "name": "username", + "in": "path", + "description": "The name that needs to be fetched. Use user1 for testing. ", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/xml": { + "schema": { + "$ref": "#/components/schemas/User" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + }, + "400": { + "description": "Invalid username supplied" + }, + "404": { + "description": "User not found" + } + } + }, + "put": { + "tags": [ + "user" + ], + "summary": "Update user", + "description": "This can only be done by the logged in user.", + "operationId": "updateUser", + "parameters": [ + { + "name": "username", + "in": "path", + "description": "name that needs to be updated", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "Update an existent user in the store", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/User" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + }, + "responses": { + "default": { + "description": "successful operation" + } + } + }, + "delete": { + "tags": [ + "user" + ], + "summary": "Delete user", + "description": "This can only be done by the logged in user.", + "operationId": "deleteUser", + "parameters": [ + { + "name": "username", + "in": "path", + "description": "The name that needs to be deleted", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "400": { + "description": "Invalid username supplied" + }, + "404": { + "description": "User not found" + } + } + } + } + }, + "components": { + "schemas": { + "Order": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64", + "example": 10 + }, + "petId": { + "type": "integer", + "format": "int64", + "example": 198772 + }, + "quantity": { + "type": "integer", + "format": "int32", + "example": 7 + }, + "shipDate": { + "type": "string", + "format": "date-time" + }, + "status": { + "type": "string", + "description": "Order Status", + "example": "approved", + "enum": [ + "placed", + "approved", + "delivered" + ] + }, + "complete": { + "type": "boolean" + } + }, + "xml": { + "name": "order" + } + }, + "Customer": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64", + "example": 100000 + }, + "username": { + "type": "string", + "example": "fehguy" + }, + "address": { + "type": "array", + "xml": { + "name": "addresses", + "wrapped": true + }, + "items": { + "$ref": "#/components/schemas/Address" + } + } + }, + "xml": { + "name": "customer" + } + }, + "Address": { + "type": "object", + "properties": { + "street": { + "type": "string", + "example": "437 Lytton" + }, + "city": { + "type": "string", + "example": "Palo Alto" + }, + "state": { + "type": "string", + "example": "CA" + }, + "zip": { + "type": "string", + "example": "94301" + } + }, + "xml": { + "name": "address" + } + }, + "Category": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64", + "example": 1 + }, + "name": { + "type": "string", + "example": "Dogs" + } + }, + "xml": { + "name": "category" + } + }, + "User": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64", + "example": 10 + }, + "username": { + "type": "string", + "example": "theUser" + }, + "firstName": { + "type": "string", + "example": "John" + }, + "lastName": { + "type": "string", + "example": "James" + }, + "email": { + "type": "string", + "example": "john@email.com" + }, + "password": { + "type": "string", + "example": "12345" + }, + "phone": { + "type": "string", + "example": "12345" + }, + "userStatus": { + "type": "integer", + "description": "User Status", + "format": "int32", + "example": 1 + } + }, + "xml": { + "name": "user" + } + }, + "Tag": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "name": { + "type": "string" + } + }, + "xml": { + "name": "tag" + } + }, + "Pet": { + "required": [ + "name", + "photoUrls" + ], + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64", + "example": 10 + }, + "name": { + "type": "string", + "example": "doggie" + }, + "category": { + "$ref": "#/components/schemas/Category" + }, + "photoUrls": { + "type": "array", + "xml": { + "wrapped": true + }, + "items": { + "type": "string", + "xml": { + "name": "photoUrl" + } + } + }, + "tags": { + "type": "array", + "xml": { + "wrapped": true + }, + "items": { + "$ref": "#/components/schemas/Tag" + } + }, + "status": { + "type": "string", + "description": "pet status in the store", + "enum": [ + "available", + "pending", + "sold" + ] + } + }, + "xml": { + "name": "pet" + } + }, + "ApiResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "type": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "xml": { + "name": "##default" + } + } + }, + "requestBodies": { + "Pet": { + "description": "Pet object that needs to be added to the store", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + } + } + }, + "UserArray": { + "description": "List of user object", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/User" + } + } + } + } + } + }, + "securitySchemes": { + "petstore_auth": { + "type": "oauth2", + "flows": { + "implicit": { + "authorizationUrl": "https://petstore3.swagger.io/oauth/authorize", + "scopes": { + "write:pets": "modify pets in your account", + "read:pets": "read your pets" + } + } + } + }, + "api_key": { + "type": "apiKey", + "name": "api_key", + "in": "header" + } + } + } +} diff --git a/simulator-samples/sample-swagger/src/main/resources/openapi/ping-v1.yaml b/simulator-samples/sample-swagger/src/main/resources/openapi/ping-v1.yaml new file mode 100644 index 000000000..b1375a92b --- /dev/null +++ b/simulator-samples/sample-swagger/src/main/resources/openapi/ping-v1.yaml @@ -0,0 +1,112 @@ +openapi: 3.0.1 +info: + title: Ping API + description: Provides ping and pong endpoints for testing + version: 1.0 + license: + name: Apache 2.0 + url: http://www.apache.org/licenses/LICENSE-2.0.html +servers: + - url: http://localhost:9000/pingapi/v1 + +paths: + /ping/{id}: + put: + tags: + - ping + summary: Puts a ping + operationId: doPing + parameters: + - name: id + in: path + description: Id to ping + required: true + schema: + type: integer + format: int64 + requestBody: + description: Ping data + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/PingRequest' + responses: + 200: + description: Successful operation + headers: + Ping-Time: + description: Response time + required: true + schema: + type: integer + format: int64 + content: + application/json: + schema: + $ref: '#/components/schemas/PingResponse' + 400: + $ref: '#/components/responses/Status400Response' + /pong/{id}: + get: + tags: + - pong + summary: Get a pong + operationId: doPong + parameters: + - name: id + in: path + description: The id to pong + required: true + schema: + type: integer + format: int64 + responses: + 200: + description: Successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/PongResponse' + 400: + $ref: '#/components/responses/Status400Response' +components: + responses: + Status400Response: + description: Invalid id supplied + content: + text/plain: + schema: + type: string + schemas: + PingRequest: + type: object + properties: + server: + type: string + required: + - server + PingResponse: + type: object + properties: + id: + type: integer + format: int64 + pingCount: + type: integer + format: int64 + required: + - id + - pingCount + PongResponse: + type: object + properties: + id: + type: string + format: uuid + pongTime: + type: string + format: date-time + required: + - id + - pongTime diff --git a/simulator-samples/sample-swagger/src/main/resources/swagger/README.md b/simulator-samples/sample-swagger/src/main/resources/swagger/README.md new file mode 100644 index 000000000..bf60e9497 --- /dev/null +++ b/simulator-samples/sample-swagger/src/main/resources/swagger/README.md @@ -0,0 +1,3 @@ +Note, that the petstore-api.json has been slightly modified from its original version. +OK messages have been added, where missing, to be able to activate the response validation feature. +Also, all simple message responses have been given a simple Message schema, diff --git a/simulator-samples/sample-swagger/src/main/resources/swagger/petstore-api.json b/simulator-samples/sample-swagger/src/main/resources/swagger/petstore-api.json index 5d2e8e3d0..a66f0d7f8 100644 --- a/simulator-samples/sample-swagger/src/main/resources/swagger/petstore-api.json +++ b/simulator-samples/sample-swagger/src/main/resources/swagger/petstore-api.json @@ -1,5 +1,5 @@ { - "swagger": "3.0.3", + "swagger": "2.0", "info": { "description": "This is a sample server Petstore server. You can find out more about Swagger at [http://swagger.io](http://swagger.io) or on [irc.freenode.net, #swagger](http://swagger.io/irc/). For this sample, you can use the api key `special-key` to test the authorization filters.", "version": "1.0.0", @@ -69,8 +69,14 @@ } ], "responses": { + "200": { + "description": "successful operation" + }, "405": { - "description": "Invalid input" + "description": "Invalid input", + "schema": { + "$ref": "#/definitions/Message" + } } }, "security": [ @@ -109,14 +115,30 @@ } ], "responses": { + "200": { + "description": "successful operation", + "schema": { + "$ref": "#/definitions/Message" + } + }, "400": { - "description": "Invalid ID supplied" + "description": "Invalid ID supplied", + "schema": { + "$ref": "#/definitions/Message" + } + }, "404": { - "description": "Pet not found" + "description": "Pet not found", + "schema": { + "$ref": "#/definitions/Message" + } }, "405": { - "description": "Validation exception" + "description": "Validation exception", + "schema": { + "$ref": "#/definitions/Message" + } } }, "security": [ @@ -171,7 +193,10 @@ } }, "400": { - "description": "Invalid status value" + "description": "Invalid status value", + "schema": { + "$ref": "#/definitions/Message" + } } }, "security": [ @@ -220,7 +245,10 @@ } }, "400": { - "description": "Invalid tag value" + "description": "Invalid tag value", + "schema": { + "$ref": "#/definitions/Message" + } } }, "security": [ @@ -264,10 +292,17 @@ } }, "400": { - "description": "Invalid ID supplied" + "description": "Invalid ID supplied", + "schema": { + "$ref": "#/definitions/Message" + } + }, "404": { - "description": "Pet not found" + "description": "Pet not found", + "schema": { + "$ref": "#/definitions/Message" + } } }, "security": [ @@ -316,7 +351,11 @@ ], "responses": { "405": { - "description": "Invalid input" + "description": "Invalid input", + "schema": { + "$ref": "#/definitions/Message" + } + } }, "security": [ @@ -356,11 +395,23 @@ } ], "responses": { + "200": { + "description": "successful operation", + "schema": { + "$ref": "#/definitions/Message" + } + }, "400": { - "description": "Invalid ID supplied" + "description": "Invalid ID supplied", + "schema": { + "$ref": "#/definitions/Message" + } }, "404": { - "description": "Pet not found" + "description": "Pet not found", + "schema": { + "$ref": "#/definitions/Message" + } } }, "security": [ @@ -491,7 +542,10 @@ } }, "400": { - "description": "Invalid Order" + "description": "Invalid Order", + "schema": { + "$ref": "#/definitions/Message" + } } } } @@ -528,10 +582,16 @@ } }, "400": { - "description": "Invalid ID supplied" + "description": "Invalid ID supplied", + "schema": { + "$ref": "#/definitions/Message" + } }, "404": { - "description": "Order not found" + "description": "Order not found", + "schema": { + "$ref": "#/definitions/Message" + } } } }, @@ -559,10 +619,16 @@ ], "responses": { "400": { - "description": "Invalid ID supplied" + "description": "Invalid ID supplied", + "schema": { + "$ref": "#/definitions/Message" + } }, "404": { - "description": "Order not found" + "description": "Order not found", + "schema": { + "$ref": "#/definitions/Message" + } } } } @@ -592,7 +658,10 @@ ], "responses": { "default": { - "description": "successful operation" + "description": "successful operation", + "schema": { + "$ref": "#/definitions/Message" + } } } } @@ -625,7 +694,10 @@ ], "responses": { "default": { - "description": "successful operation" + "description": "successful operation", + "schema": { + "$ref": "#/definitions/Message" + } } } } @@ -658,7 +730,10 @@ ], "responses": { "default": { - "description": "successful operation" + "description": "successful operation", + "schema": { + "$ref": "#/definitions/Message" + } } } } @@ -711,7 +786,10 @@ } }, "400": { - "description": "Invalid username/password supplied" + "description": "Invalid username/password supplied", + "schema": { + "$ref": "#/definitions/Message" + } } } } @@ -731,7 +809,10 @@ "parameters": [], "responses": { "default": { - "description": "successful operation" + "description": "successful operation", + "schema": { + "$ref": "#/definitions/Message" + } } } } @@ -765,10 +846,16 @@ } }, "400": { - "description": "Invalid username supplied" + "description": "Invalid username supplied", + "schema": { + "$ref": "#/definitions/Message" + } }, "404": { - "description": "User not found" + "description": "User not found", + "schema": { + "$ref": "#/definitions/Message" + } } } }, @@ -803,10 +890,16 @@ ], "responses": { "400": { - "description": "Invalid user supplied" + "description": "Invalid user supplied", + "schema": { + "$ref": "#/definitions/Message" + } }, "404": { - "description": "User not found" + "description": "User not found", + "schema": { + "$ref": "#/definitions/Message" + } } } }, @@ -832,10 +925,16 @@ ], "responses": { "400": { - "description": "Invalid username supplied" + "description": "Invalid username supplied", + "schema": { + "$ref": "#/definitions/Message" + } }, "404": { - "description": "User not found" + "description": "User not found", + "schema": { + "$ref": "#/definitions/Message" + } } } } @@ -1026,10 +1125,19 @@ "type": "string" } } + }, + "Message": { + "type": "object", + "required": ["message"], + "properties": { + "message": { + "type": "string" + } + } } }, "externalDocs": { "description": "Find out more about Swagger", "url": "http://swagger.io" } -} \ No newline at end of file +} diff --git a/simulator-samples/sample-swagger/src/test/java/org/citrusframework/simulator/OpenApiIT.java b/simulator-samples/sample-swagger/src/test/java/org/citrusframework/simulator/OpenApiIT.java new file mode 100644 index 000000000..bae2fcdb5 --- /dev/null +++ b/simulator-samples/sample-swagger/src/test/java/org/citrusframework/simulator/OpenApiIT.java @@ -0,0 +1,328 @@ +/* + * Copyright 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.citrusframework.simulator; + +import static org.citrusframework.http.actions.HttpActionBuilder.http; + +import org.citrusframework.annotations.CitrusTest; +import org.citrusframework.container.BeforeSuite; +import org.citrusframework.container.SequenceBeforeSuite; +import org.citrusframework.dsl.endpoint.CitrusEndpoints; +import org.citrusframework.http.client.HttpClient; +import org.citrusframework.message.MessageType; +import org.citrusframework.simulator.sample.Simulator; +import org.citrusframework.spi.Resources; +import org.citrusframework.testng.spring.TestNGCitrusSpringSupport; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.test.context.ContextConfiguration; +import org.testng.annotations.Ignore; +import org.testng.annotations.Test; + +/** + * @author Christoph Deppisch + */ +@Test +@Ignore +@ContextConfiguration(classes = OpenApiIT.EndpointConfig.class) +public class OpenApiIT extends TestNGCitrusSpringSupport { + + @Autowired + @Qualifier("petstoreClientV3") + private HttpClient petstoreClientV3; + + /** + * Client to access simulator user interface + */ + @Autowired + @Qualifier("simulatorUiClient") + private HttpClient simulatorUiClient; + + @CitrusTest + public void uiInfoShouldSucceed() { + $(http().client(simulatorUiClient) + .send() + .get("/api/manage/info") + .message() + .accept(MediaType.APPLICATION_JSON_VALUE) + .contentType(MediaType.APPLICATION_JSON_VALUE)); + + $(http().client(simulatorUiClient) + .receive() + .response(HttpStatus.OK) + .message() + .contentType(MediaType.APPLICATION_JSON_VALUE) + .body("{" + + "\"simulator\":" + + "{" + + "\"name\":\"REST Petstore Simulator\"," + + "\"version\":\"@ignore@\"" + + "}," + + "\"activeProfiles\": []" + + "}")); + } + + @CitrusTest + public void addPetShouldSucceed() { + variable("name", "hasso"); + variable("category", "dog"); + variable("tags", "huge"); + variable("status", "pending"); + + $(http().client(petstoreClientV3) + .send() + .post("/pet") + .message() + .accept(MediaType.APPLICATION_JSON_VALUE) + .contentType(MediaType.APPLICATION_JSON_VALUE) + .body(new Resources.ClasspathResource("templates/pet.json"))); + + $(http().client(petstoreClientV3) + .receive() + .response(HttpStatus.OK)); + } + + @CitrusTest + public void addPetShouldFailOnMissingName() { + variable("category", "dog"); + variable("tags", "huge"); + variable("status", "pending"); + + $(http().client(petstoreClientV3) + .send() + .post("/pet") + .message() + .accept(MediaType.APPLICATION_JSON_VALUE) + .contentType(MediaType.APPLICATION_JSON_VALUE) + .body(new Resources.ClasspathResource("templates/pet_invalid.json"))); + + $(http().client(petstoreClientV3) + .receive() + .response(HttpStatus.INTERNAL_SERVER_ERROR)); + } + + @CitrusTest + public void deletePetShouldSucceed() { + variable("id", "citrus:randomNumber(10)"); + + $(http().client(petstoreClientV3) + .send() + .delete("/pet/${id}")); + + $(http().client(petstoreClientV3) + .receive() + .response(HttpStatus.OK)); + } + + @CitrusTest + public void deletePetShouldFailOnWrongIdFormat() { + + $(http().client(petstoreClientV3) + .send() + .delete("/pet/xxxx")); + + $(http().client(petstoreClientV3) + .receive() + .response(HttpStatus.INTERNAL_SERVER_ERROR)); + } + + @CitrusTest + public void getPetByIdShouldSucceed() { + variable("id", "citrus:randomNumber(10)"); + + $(http().client(petstoreClientV3) + .send() + .get("/pet/${id}") + .message() + .header("api_key", "xxx_api_key") + .accept(MediaType.APPLICATION_JSON_VALUE)); + + $(http().client(petstoreClientV3) + .receive() + .response(HttpStatus.OK) + .message() + .contentType(MediaType.APPLICATION_JSON_VALUE) + .body(new Resources.ClasspathResource("templates/pet-control.json"))); + } + + @CitrusTest + public void getPetByIdShouldNotFailOnMissingApiKey() { + variable("id", "citrus:randomNumber(10)"); + + $(http().client(petstoreClientV3) + .send() + .get("/pet/${id}") + .message() + .accept(MediaType.APPLICATION_JSON_VALUE)); + + // api_key is not required in V3, therefore no error here + $(http().client(petstoreClientV3) + .receive() + .response(HttpStatus.OK)); + } + + @CitrusTest + public void updatePetShouldSucceed() { + variable("name", "catty"); + variable("category", "cat"); + variable("tags", "cute"); + variable("status", "sold"); + + $(http().client(petstoreClientV3) + .send() + .put("/pet") + .message() + .accept(MediaType.APPLICATION_JSON_VALUE) + .contentType(MediaType.APPLICATION_JSON_VALUE) + .body(new Resources.ClasspathResource("templates/pet.json"))); + + $(http().client(petstoreClientV3) + .receive() + .response(HttpStatus.OK)); + } + + @CitrusTest + public void findByStatusShouldSucceed() { + $(http().client(petstoreClientV3) + .send() + .get("/pet/findByStatus") + .message() + .accept(MediaType.APPLICATION_JSON_VALUE) + .queryParam("status", "pending")); + + $(http().client(petstoreClientV3) + .receive() + .response(HttpStatus.OK) + .message() + .contentType(MediaType.APPLICATION_JSON_VALUE) + .body("[ citrus:readFile(templates/pet-control.json) ]")); + } + + @CitrusTest + public void findByStatusShouldNotFailOnMissingQueryParameter() { + $(http().client(petstoreClientV3) + .send() + .get("/pet/findByStatus") + .message() + .accept(MediaType.APPLICATION_JSON_VALUE)); + + // In petstore 3 status is not required. + $(http().client(petstoreClientV3) + .receive() + .response(HttpStatus.OK)); + } + + @CitrusTest + public void findByTagsShouldSucceed() { + $(http().client(petstoreClientV3) + .send() + .get("/pet/findByTags") + .message() + .accept(MediaType.APPLICATION_JSON_VALUE) + .queryParam("tags", "huge,cute")); + + $(http().client(petstoreClientV3) + .receive() + .response(HttpStatus.OK) + .message() + .contentType(MediaType.APPLICATION_JSON_VALUE) + .body("[ citrus:readFile(templates/pet-control.json) ]")); + } + + @CitrusTest + public void placeOrderShouldSucceed() { + $(http().client(petstoreClientV3) + .send() + .post("/store/order") + .message() + .accept(MediaType.APPLICATION_JSON_VALUE) + .contentType(MediaType.APPLICATION_JSON_VALUE) + .body(new Resources.ClasspathResource("templates/order.json")) + .header("api_key", "xxx_api_key")); + + $(http().client(petstoreClientV3) + .receive() + .response(HttpStatus.OK)); + } + + @CitrusTest + public void placeOrderShouldFailOnInvalidDateFormat() { + $(http().client(petstoreClientV3) + .send() + .post("/store/order") + .message() + .accept(MediaType.APPLICATION_JSON_VALUE) + .contentType(MediaType.APPLICATION_JSON_VALUE) + .body(new Resources.ClasspathResource("templates/order_invalid_date.json")) + .header("api_key", "xxx_api_key")); + + $(http().client(petstoreClientV3) + .receive() + .response(HttpStatus.INTERNAL_SERVER_ERROR)); + } + + @CitrusTest + public void loginUserShouldSucceed() { + $(http().client(petstoreClientV3) + .send() + .get("/user/login") + .queryParam("username", "citrus:randomString(10)") + .queryParam("password", "citrus:randomString(8)") + .message() + .header("api_key", "xxx_api_key") + .accept(MediaType.APPLICATION_JSON_VALUE)); + + // X-Rate-Limit and X-Expires-After are not required in V3, therefore we cannot assert them here. + $(http().client(petstoreClientV3) + .receive() + .response(HttpStatus.OK) + .message() + .type(MessageType.JSON) + .body("@notEmpty()@")); + } + + @Configuration + public static class EndpointConfig { + + @Bean + public HttpClient petstoreClientV3() { + return CitrusEndpoints.http().client() + .requestUrl(String.format("http://localhost:%s/petstore/api/v3", 8080)) + .build(); + } + + @Bean + public HttpClient simulatorUiClient() { + return CitrusEndpoints.http().client() + .requestUrl(String.format("http://localhost:%s", 8080)) + .build(); + } + + @Bean + @ConditionalOnProperty(name = "simulator.mode", havingValue = "embedded") + public BeforeSuite startEmbeddedSimulator() { + return new SequenceBeforeSuite.Builder().actions( + context -> SpringApplication.run(Simulator.class)).build(); + } + } +} diff --git a/simulator-samples/sample-swagger/src/test/java/org/citrusframework/simulator/ResponseGeneratorIT.java b/simulator-samples/sample-swagger/src/test/java/org/citrusframework/simulator/ResponseGeneratorIT.java new file mode 100644 index 000000000..7adc0cf6e --- /dev/null +++ b/simulator-samples/sample-swagger/src/test/java/org/citrusframework/simulator/ResponseGeneratorIT.java @@ -0,0 +1,187 @@ +/* + * Copyright 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.citrusframework.simulator; + +import static org.citrusframework.http.actions.HttpActionBuilder.http; +import static org.citrusframework.validation.json.JsonPathMessageValidationContext.Builder.jsonPath; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.not; + +import org.citrusframework.annotations.CitrusTest; +import org.citrusframework.container.BeforeSuite; +import org.citrusframework.container.SequenceBeforeSuite; +import org.citrusframework.dsl.endpoint.CitrusEndpoints; +import org.citrusframework.http.client.HttpClient; +import org.citrusframework.simulator.sample.Simulator; +import org.citrusframework.spi.Resources; +import org.citrusframework.testng.spring.TestNGCitrusSpringSupport; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.test.context.ContextConfiguration; +import org.testng.annotations.Test; + +@Test +@ContextConfiguration(classes = ResponseGeneratorIT.EndpointConfig.class) +public class ResponseGeneratorIT extends TestNGCitrusSpringSupport { + + @Autowired + @Qualifier("pingClient") + private HttpClient pingClient; + + @CitrusTest + public void shouldPerformDefaultOpenApiPingOperation() { + variable("id", "1234"); + + $(http().client(pingClient) + .send() + .put("/ping/${id}") + .message() + .accept(MediaType.APPLICATION_JSON_VALUE) + .contentType(MediaType.APPLICATION_JSON_VALUE) + .body(new Resources.ClasspathResource("templates/ping.json"))); + + $(http().client(pingClient) + .receive() + .response(HttpStatus.OK) + .message() + .header("Ping-Time", "@isNumber()@") + .validate(jsonPath() + .expression("$.pingCount", not(equalTo("1001"))))); + } + + @CitrusTest + public void shouldPerformSpecificApiPingOperation() { + long currentTime = System.currentTimeMillis(); + long expectedPingLimit = currentTime - 1L; + + variable("id", "5000"); + + $(http().client(pingClient) + .send() + .put("/ping/${id}") + .message() + .accept(MediaType.APPLICATION_JSON_VALUE) + .contentType(MediaType.APPLICATION_JSON_VALUE) + .body(new Resources.ClasspathResource("templates/ping.json"))); + + $(http().client(pingClient) + .receive() + .response(HttpStatus.OK) + .message() + .header("Ping-Time", "@isNumber()@") + .validate(jsonPath() + .expression("$.id", "5000") + .expression("$.pingCount", "@greaterThan("+expectedPingLimit+")@")) + ); + } + + @CitrusTest + public void shouldReturnPingTime0() { + variable("id", "15000"); + + $(http().client(pingClient) + .send() + .put("/ping/${id}") + .message() + .accept(MediaType.APPLICATION_JSON_VALUE) + .contentType(MediaType.APPLICATION_JSON_VALUE) + .body(new Resources.ClasspathResource("templates/ping.json"))); + + $(http().client(pingClient) + .receive() + .response(HttpStatus.OK) + .message() + .header("Ping-Time", "0") + ); + } + + @CitrusTest + public void shouldFailOnBadRequest() { + variable("id", "10000"); + + $(http().client(pingClient) + .send() + .put("/ping/${id}") + .message() + .accept(MediaType.APPLICATION_JSON_VALUE) + .contentType(MediaType.APPLICATION_JSON_VALUE) + .body(new Resources.ClasspathResource("templates/ping.json"))); + + $(http().client(pingClient) + .receive() + .response(HttpStatus.BAD_REQUEST) + .message().body("Requests with id == 10000 cannot be processed!")); + } + + @CitrusTest + public void shouldFailOnUnsupportedType() { + variable("id", "10000"); + + $(http().client(pingClient) + .send() + .put("/ping/${id}") + .message() + .accept(MediaType.APPLICATION_XML_VALUE) + .contentType(MediaType.APPLICATION_JSON_VALUE) + .body(new Resources.ClasspathResource("templates/ping.json"))); + + $(http().client(pingClient) + .receive() + .response(HttpStatus.INTERNAL_SERVER_ERROR)); + } + + @CitrusTest + public void shouldFailOnMissingPingTimeHeader() { + variable("id", "4000"); + + $(http().client(pingClient) + .send() + .put("/ping/${id}") + .message() + .accept(MediaType.APPLICATION_JSON_VALUE) + .contentType(MediaType.APPLICATION_JSON_VALUE) + .body(new Resources.ClasspathResource("templates/ping.json"))); + + $(http().client(pingClient) + .receive() + .response(HttpStatus.INTERNAL_SERVER_ERROR)); + } + + @Configuration + public static class EndpointConfig { + + @Bean + public HttpClient pingClient() { + return CitrusEndpoints.http().client() + .requestUrl(String.format("http://localhost:%s/pingapi/v1", 8080)) + .build(); + } + + @Bean + @ConditionalOnProperty(name = "simulator.mode", havingValue = "embedded") + public BeforeSuite startEmbeddedSimulator() { + return new SequenceBeforeSuite.Builder().actions(context -> SpringApplication.run( + Simulator.class)).build(); + } + } +} diff --git a/simulator-samples/sample-swagger/src/test/java/org/citrusframework/simulator/SimulatorSwaggerIT.java b/simulator-samples/sample-swagger/src/test/java/org/citrusframework/simulator/SimulatorSwaggerIT.java index 5b9ef315e..c6afb9c37 100644 --- a/simulator-samples/sample-swagger/src/test/java/org/citrusframework/simulator/SimulatorSwaggerIT.java +++ b/simulator-samples/sample-swagger/src/test/java/org/citrusframework/simulator/SimulatorSwaggerIT.java @@ -16,6 +16,9 @@ package org.citrusframework.simulator; +import static java.lang.String.format; +import static org.citrusframework.http.actions.HttpActionBuilder.http; + import org.citrusframework.annotations.CitrusTest; import org.citrusframework.container.BeforeSuite; import org.citrusframework.container.SequenceBeforeSuite; @@ -36,9 +39,6 @@ import org.springframework.test.context.ContextConfiguration; import org.testng.annotations.Test; -import static java.lang.String.format; -import static org.citrusframework.http.actions.HttpActionBuilder.http; - /** * @author Christoph Deppisch */ @@ -48,8 +48,8 @@ public class SimulatorSwaggerIT extends TestNGCitrusSpringSupport { /** Test Http REST client */ @Autowired - @Qualifier("petstoreClient") - private HttpClient petstoreClient; + @Qualifier("petstoreClientV2") + private HttpClient petstoreClientV2; /** Client to access simulator user interface */ @Autowired @@ -57,10 +57,13 @@ public class SimulatorSwaggerIT extends TestNGCitrusSpringSupport { private HttpClient simulatorUiClient; @CitrusTest - public void testUiInfo() { - $(http().client(simulatorUiClient).send().get("/api/manage/info").message() - .accept(MediaType.APPLICATION_JSON_VALUE) - .contentType(MediaType.APPLICATION_JSON_VALUE)); + public void uiInfoShouldSucceed() { + $(http().client(simulatorUiClient) + .send() + .get("/api/manage/info") + .message() + .accept(MediaType.APPLICATION_JSON_VALUE) + .contentType(MediaType.APPLICATION_JSON_VALUE)); $(http().client(simulatorUiClient).receive().response(HttpStatus.OK).message() .contentType(MediaType.APPLICATION_JSON_VALUE).body( @@ -79,13 +82,13 @@ public void testUiInfo() { } @CitrusTest - public void testAddPet() { + public void addPetShouldSucceed() { variable("name", "hasso"); variable("category", "dog"); variable("tags", "huge"); variable("status", "pending"); - $(http().client(petstoreClient) + $(http().client(petstoreClientV2) .send() .post("/pet") .message() @@ -93,35 +96,80 @@ public void testAddPet() { .contentType(MediaType.APPLICATION_JSON_VALUE) .body(new Resources.ClasspathResource("templates/pet.json"))); - $(http().client(petstoreClient) + $(http().client(petstoreClientV2) .receive() .response(HttpStatus.OK)); } @CitrusTest - public void testDeletePet() { + public void addPetShouldFailOnMissingName() { + variable("category", "dog"); + variable("tags", "huge"); + variable("status", "pending"); + + $(http().client(petstoreClientV2) + .send() + .post("/pet") + .message() + .accept(MediaType.APPLICATION_JSON_VALUE) + .contentType(MediaType.APPLICATION_JSON_VALUE) + .body(new Resources.ClasspathResource("templates/pet_invalid.json"))); + + $(http().client(petstoreClientV2) + .receive() + .response(HttpStatus.INTERNAL_SERVER_ERROR)); + } + + @CitrusTest + public void deletePetShouldSucceed() { variable("id", "citrus:randomNumber(10)"); - $(http().client(petstoreClient) + $(http().client(petstoreClientV2) .send() .delete("/pet/${id}")); - $(http().client(petstoreClient) + $(http().client(petstoreClientV2) .receive() .response(HttpStatus.OK)); } @CitrusTest - public void testGetPetById() { + public void deletePetShouldFailOnWrongIdFormat() { + + $(http().client(petstoreClientV2) + .send() + .delete("/pet/xxxx")); + + $(http().client(petstoreClientV2) + .receive() + .response(HttpStatus.INTERNAL_SERVER_ERROR)); + } + +// @CitrusTest +// public void testDeletePetByOpenApi() { +// variable("id", "citrus:randomNumber(10)"); +//$(openapi("Petstore/1.0.1").client(pingClient)) +// $(http().client(petstoreClient) +// .send() +// .delete("/pet/${id}")); +// +// $(http().client(petstoreClient) +// .receive() +// .response(HttpStatus.OK)); +// } + + @CitrusTest + public void getPetByIdShouldSucceed() { variable("id", "citrus:randomNumber(10)"); - $(http().client(petstoreClient) + $(http().client(petstoreClientV2) .send() .get("/pet/${id}") .message() + .header("api_key", "xxx_api_key") .accept(MediaType.APPLICATION_JSON_VALUE)); - $(http().client(petstoreClient) + $(http().client(petstoreClientV2) .receive() .response(HttpStatus.OK) .message() @@ -130,13 +178,28 @@ public void testGetPetById() { } @CitrusTest - public void testUpdatePet() { + public void getPetByIdShouldFailOnMissingApiKey() { + variable("id", "citrus:randomNumber(10)"); + + $(http().client(petstoreClientV2) + .send() + .get("/pet/${id}") + .message() + .accept(MediaType.APPLICATION_JSON_VALUE)); + + $(http().client(petstoreClientV2) + .receive() + .response(HttpStatus.INTERNAL_SERVER_ERROR)); + } + + @CitrusTest + public void updatePetShouldSucceed() { variable("name", "catty"); variable("category", "cat"); variable("tags", "cute"); variable("status", "sold"); - $(http().client(petstoreClient) + $(http().client(petstoreClientV2) .send() .put("/pet") .message() @@ -144,21 +207,21 @@ public void testUpdatePet() { .contentType(MediaType.APPLICATION_JSON_VALUE) .body(new Resources.ClasspathResource("templates/pet.json"))); - $(http().client(petstoreClient) + $(http().client(petstoreClientV2) .receive() .response(HttpStatus.OK)); } @CitrusTest - public void testFindByStatus() { - $(http().client(petstoreClient) + public void findByStatusShouldSucceed() { + $(http().client(petstoreClientV2) .send() .get("/pet/findByStatus") .message() .accept(MediaType.APPLICATION_JSON_VALUE) .queryParam("status", "pending")); - $(http().client(petstoreClient) + $(http().client(petstoreClientV2) .receive() .response(HttpStatus.OK) .message() @@ -167,28 +230,28 @@ public void testFindByStatus() { } @CitrusTest - public void testFindByStatusMissingQueryParameter() { - $(http().client(petstoreClient) + public void findByStatusShouldFailOnMissingQueryParameter() { + $(http().client(petstoreClientV2) .send() .get("/pet/findByStatus") .message() .accept(MediaType.APPLICATION_JSON_VALUE)); - $(http().client(petstoreClient) + $(http().client(petstoreClientV2) .receive() .response(HttpStatus.INTERNAL_SERVER_ERROR)); } @CitrusTest - public void testFindByTags() { - $(http().client(petstoreClient) + public void findByTagsShouldSucceed() { + $(http().client(petstoreClientV2) .send() .get("/pet/findByTags") .message() .accept(MediaType.APPLICATION_JSON_VALUE) .queryParam("tags", "huge,cute")); - $(http().client(petstoreClient) + $(http().client(petstoreClientV2) .receive() .response(HttpStatus.OK) .message() @@ -197,35 +260,53 @@ public void testFindByTags() { } @CitrusTest - public void testPlaceOrder() { - $(http().client(petstoreClient) + public void placeOrderShouldSucceed() { + $(http().client(petstoreClientV2) .send() .post("/store/order") .message() .accept(MediaType.APPLICATION_JSON_VALUE) .contentType(MediaType.APPLICATION_JSON_VALUE) - .body(new Resources.ClasspathResource("templates/order.json"))); + .body(new Resources.ClasspathResource("templates/order.json")) + .header("api_key", "xxx_api_key")) ; - $(http().client(petstoreClient) + $(http().client(petstoreClientV2) .receive() .response(HttpStatus.OK)); } @CitrusTest - public void testLoginUser() { - $(http().client(petstoreClient) + public void placeOrderShouldFailOnInvalidDateFormat() { + $(http().client(petstoreClientV2) + .send() + .post("/store/order") + .message() + .accept(MediaType.APPLICATION_JSON_VALUE) + .contentType(MediaType.APPLICATION_JSON_VALUE) + .body(new Resources.ClasspathResource("templates/order_invalid_date.json")) + .header("api_key", "xxx_api_key")) ; + + $(http().client(petstoreClientV2) + .receive() + .response(HttpStatus.INTERNAL_SERVER_ERROR)); + } + + @CitrusTest + public void loginUserShouldSucceed() { + $(http().client(petstoreClientV2) .send() .get("/user/login") .queryParam("username", "citrus:randomString(10)") .queryParam("password", "citrus:randomString(8)") .message() - .accept("text/plain")); + .header("api_key", "xxx_api_key") + .accept(MediaType.APPLICATION_JSON_VALUE)); - $(http().client(petstoreClient) + $(http().client(petstoreClientV2) .receive() .response(HttpStatus.OK) .message() - .type(MessageType.PLAINTEXT) + .type(MessageType.JSON) .body("@notEmpty()@") .header("X-Rate-Limit", "@isNumber()@") .header("X-Expires-After", "@matchesDatePattern('yyyy-MM-dd'T'hh:mm:ss')@")); @@ -235,7 +316,7 @@ public void testLoginUser() { public static class EndpointConfig { @Bean - public HttpClient petstoreClient() { + public HttpClient petstoreClientV2() { return CitrusEndpoints.http().client() .requestUrl(format("http://localhost:%s/petstore/v2", 8080)) .build(); diff --git a/simulator-samples/sample-swagger/src/test/resources/templates/order.json b/simulator-samples/sample-swagger/src/test/resources/templates/order.json index bfb074b83..6cd5271a5 100644 --- a/simulator-samples/sample-swagger/src/test/resources/templates/order.json +++ b/simulator-samples/sample-swagger/src/test/resources/templates/order.json @@ -2,7 +2,7 @@ "id": citrus:randomNumber(10), "petId": citrus:randomNumber(10), "quantity": 1, - "shipDate": "citrus:currentDate('yyyy-MM-dd'T'hh:mm:ss')", + "shipDate": "citrus:currentDate('yyyy-MM-dd'T'HH:mm:ssZ')", "status": "placed", "complete": false -} \ No newline at end of file +} diff --git a/simulator-samples/sample-swagger/src/test/resources/templates/order_invalid_date.json b/simulator-samples/sample-swagger/src/test/resources/templates/order_invalid_date.json new file mode 100644 index 000000000..a8a679b77 --- /dev/null +++ b/simulator-samples/sample-swagger/src/test/resources/templates/order_invalid_date.json @@ -0,0 +1,8 @@ +{ + "id": citrus:randomNumber(10), + "petId": citrus:randomNumber(10), + "quantity": 1, + "shipDate": "citrus:currentDate('yyyy-MM-dd'T'HH:mm')", + "status": "placed", + "complete": false +} diff --git a/simulator-samples/sample-swagger/src/test/resources/templates/pet_invalid.json b/simulator-samples/sample-swagger/src/test/resources/templates/pet_invalid.json new file mode 100644 index 000000000..ef70f4ed0 --- /dev/null +++ b/simulator-samples/sample-swagger/src/test/resources/templates/pet_invalid.json @@ -0,0 +1,14 @@ +{ + "id": citrus:randomNumber(10), + "category": { + "id": citrus:randomNumber(10), + "name": "${category}" + }, + "tags": [ + { + "id": citrus:randomNumber(10), + "name": "${tags}" + } + ], + "status": "${status}" +} diff --git a/simulator-samples/sample-swagger/src/test/resources/templates/ping.json b/simulator-samples/sample-swagger/src/test/resources/templates/ping.json new file mode 100644 index 000000000..045a4667b --- /dev/null +++ b/simulator-samples/sample-swagger/src/test/resources/templates/ping.json @@ -0,0 +1,3 @@ +{ + "server": "localhost" +} diff --git a/simulator-spring-boot/pom.xml b/simulator-spring-boot/pom.xml index f514917cb..01bad9155 100644 --- a/simulator-spring-boot/pom.xml +++ b/simulator-spring-boot/pom.xml @@ -130,6 +130,10 @@ org.citrusframework citrus-http + + org.citrusframework + citrus-openapi + org.citrusframework citrus-ws diff --git a/simulator-spring-boot/src/main/java/org/citrusframework/simulator/http/HttpOperationScenario.java b/simulator-spring-boot/src/main/java/org/citrusframework/simulator/http/HttpOperationScenario.java index 8f4ee0b40..08ea7876b 100644 --- a/simulator-spring-boot/src/main/java/org/citrusframework/simulator/http/HttpOperationScenario.java +++ b/simulator-spring-boot/src/main/java/org/citrusframework/simulator/http/HttpOperationScenario.java @@ -16,431 +16,153 @@ package org.citrusframework.simulator.http; -import io.swagger.models.ArrayModel; -import io.swagger.models.Model; -import io.swagger.models.Operation; -import io.swagger.models.RefModel; -import io.swagger.models.Response; -import io.swagger.models.parameters.AbstractSerializableParameter; -import io.swagger.models.parameters.BodyParameter; -import io.swagger.models.parameters.HeaderParameter; -import io.swagger.models.parameters.Parameter; -import io.swagger.models.parameters.QueryParameter; -import io.swagger.models.properties.ArrayProperty; -import io.swagger.models.properties.BooleanProperty; -import io.swagger.models.properties.DateProperty; -import io.swagger.models.properties.DateTimeProperty; -import io.swagger.models.properties.DoubleProperty; -import io.swagger.models.properties.FloatProperty; -import io.swagger.models.properties.IntegerProperty; -import io.swagger.models.properties.LongProperty; -import io.swagger.models.properties.Property; -import io.swagger.models.properties.RefProperty; -import io.swagger.models.properties.StringProperty; -import org.citrusframework.http.actions.HttpServerRequestActionBuilder; +import static org.citrusframework.actions.EchoAction.Builder.echo; + +import io.apicurio.datamodels.openapi.models.OasDocument; +import io.apicurio.datamodels.openapi.models.OasOperation; +import io.apicurio.datamodels.openapi.models.OasResponse; +import jakarta.annotation.Nullable; +import java.util.concurrent.atomic.AtomicReference; +import lombok.Getter; import org.citrusframework.http.actions.HttpServerResponseActionBuilder; -import org.citrusframework.http.message.HttpMessageHeaders; +import org.citrusframework.http.message.HttpMessage; import org.citrusframework.message.MessageHeaders; -import org.citrusframework.message.MessageType; -import org.citrusframework.simulator.exception.SimulatorException; +import org.citrusframework.openapi.OpenApiSpecification; +import org.citrusframework.openapi.actions.OpenApiActionBuilder; +import org.citrusframework.openapi.actions.OpenApiServerActionBuilder; +import org.citrusframework.openapi.actions.OpenApiServerRequestActionBuilder; +import org.citrusframework.openapi.model.OasModelHelper; import org.citrusframework.simulator.scenario.AbstractSimulatorScenario; import org.citrusframework.simulator.scenario.ScenarioRunner; import org.citrusframework.variable.dictionary.json.JsonPathMappingDataDictionary; -import org.hamcrest.CustomMatcher; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; -import org.springframework.util.AntPathMatcher; -import org.springframework.util.CollectionUtils; -import org.springframework.util.StringUtils; -import org.springframework.web.bind.annotation.RequestMethod; - -import java.util.Map; -import java.util.stream.Collectors; - -import static org.citrusframework.actions.EchoAction.Builder.echo; -public class HttpOperationScenario extends AbstractSimulatorScenario { +@Getter +public class HttpOperationScenario extends AbstractSimulatorScenario implements HttpScenario { - /** Operation in wsdl */ - private final Operation operation; + private static final Logger logger = LoggerFactory.getLogger(HttpOperationScenario.class); - /** Schema model definitions */ - private final Map definitions; - - /** Request path */ private final String path; - /** Request method */ - private final RequestMethod method; + private final String scenarioId; + + private final OpenApiSpecification openApiSpecification; - /** Response */ - private Response response; + private final OasOperation operation; - /** Response status code */ - private HttpStatus statusCode = HttpStatus.OK; + private final String operationUid; private JsonPathMappingDataDictionary inboundDataDictionary; + private JsonPathMappingDataDictionary outboundDataDictionary; - /** - * Default constructor. - * @param path - * @param method - * @param operation - * @param definitions - */ - public HttpOperationScenario(String path, RequestMethod method, Operation operation, Map definitions) { - this.operation = operation; - this.definitions = definitions; + private final HttpResponseActionBuilderProvider httpResponseActionBuilderProvider; + + public HttpOperationScenario(String path, String scenarioId, OpenApiSpecification openApiSpecification, OasOperation operation, HttpResponseActionBuilderProvider httpResponseActionBuilderProvider) { this.path = path; - this.method = method; + this.scenarioId = scenarioId; + this.openApiSpecification = openApiSpecification; + this.operation = operation; + this.httpResponseActionBuilderProvider = httpResponseActionBuilderProvider; - if (operation.getResponses() != null) { - this.response = operation.getResponses().get("200"); - } + // Use the unique id of the operation instead of operationId. OperationId is + // not mandatory and might be null. + this.operationUid = openApiSpecification.getUniqueId(operation); } @Override public void run(ScenarioRunner scenario) { - scenario.name(operation.getOperationId()); - scenario.$(echo("Generated scenario from swagger operation: " + operation.getOperationId())); - - HttpServerRequestActionBuilder requestBuilder = switch (method) { - case GET -> scenario.http() - .receive() - .get(); - case POST -> scenario.http() - .receive() - .post(); - case PUT -> scenario.http() - .receive() - .put(); - case HEAD -> scenario.http() - .receive() - .head(); - case DELETE -> scenario.http() - .receive() - .delete(); - default -> throw new SimulatorException("Unsupported request method: " + method.name()); - }; - - requestBuilder - .message() - .type(MessageType.JSON) - .header(MessageHeaders.MESSAGE_PREFIX + "generated", true) - .header(HttpMessageHeaders.HTTP_REQUEST_URI, new CustomMatcher(String.format("request path matching %s", path)) { - @Override - public boolean matches(Object item) { - return ((item instanceof String) && new AntPathMatcher().match(path, (String) item)); - } - }); - - if (operation.getParameters() != null) { - operation.getParameters().stream() - .filter(p -> p instanceof HeaderParameter) - .filter(Parameter::getRequired) - .forEach(p -> requestBuilder.message().header(p.getName(), createValidationExpression(((HeaderParameter) p)))); - - String queryParams = operation.getParameters().stream() - .filter(param -> param instanceof QueryParameter) - .filter(Parameter::getRequired) - .map(param -> "containsString(" + param.getName() + ")") - .collect(Collectors.joining(", ")); - - if (StringUtils.hasText(queryParams)) { - requestBuilder.message().header(HttpMessageHeaders.HTTP_QUERY_PARAMS, "@assertThat(allOf(" + queryParams + "))@"); - } - - operation.getParameters().stream() - .filter(p -> p instanceof BodyParameter) - .filter(Parameter::getRequired) - .forEach(p -> requestBuilder.message().body(createValidationPayload((BodyParameter) p))); - - if (inboundDataDictionary != null) { - requestBuilder.message().dictionary(inboundDataDictionary); - } - } - - // Verify incoming request - scenario.$(requestBuilder); + scenario.name(operationUid); + scenario.$(echo("Generated scenario from swagger operation: " + operationUid)); - HttpServerResponseActionBuilder responseBuilder = scenario.http() - .send() - .response(statusCode); + OpenApiServerActionBuilder openApiServerActionBuilder = new OpenApiActionBuilder( + openApiSpecification).server(getScenarioEndpoint()); - responseBuilder.message() - .type(MessageType.JSON) - .header(MessageHeaders.MESSAGE_PREFIX + "generated", true) - .contentType(MediaType.APPLICATION_JSON_VALUE); - - if (response != null) { - if (response.getHeaders() != null) { - for (Map.Entry header : response.getHeaders().entrySet()) { - responseBuilder.message().header(header.getKey(), createRandomValue(header.getValue(), false)); - } - } - - if (response.getSchema() != null) { - if (outboundDataDictionary != null && - (response.getSchema() instanceof RefProperty || response.getSchema() instanceof ArrayProperty)) { - responseBuilder.message().dictionary(outboundDataDictionary); - } - - responseBuilder.message().body(createRandomValue(response.getSchema(), false)); - } - } - - // Return generated response - scenario.$(responseBuilder); + HttpMessage receivedMessage = receive(scenario, openApiServerActionBuilder); + respond(scenario, openApiServerActionBuilder, receivedMessage); } - /** - * Create payload from schema with random values. - * @param property - * @param quotes - * @return - */ - private String createRandomValue(Property property, boolean quotes) { - StringBuilder payload = new StringBuilder(); - if (property instanceof RefProperty) { - Model model = definitions.get(((RefProperty) property).getSimpleRef()); - payload.append("{"); - - if (model.getProperties() != null) { - for (Map.Entry entry : model.getProperties().entrySet()) { - payload.append("\"").append(entry.getKey()).append("\": ").append(createRandomValue(entry.getValue(), true)).append(","); - } - } - - if (payload.toString().endsWith(",")) { - payload.replace(payload.length() - 1, payload.length(), ""); - } - - payload.append("}"); - } else if (property instanceof ArrayProperty) { - payload.append("["); - payload.append(createRandomValue(((ArrayProperty) property).getItems(), true)); - payload.append("]"); - } else if (property instanceof StringProperty || property instanceof DateProperty || property instanceof DateTimeProperty) { - if (quotes) { - payload.append("\""); - } - - if (property instanceof DateProperty) { - payload.append("citrus:currentDate()"); - } else if (property instanceof DateTimeProperty) { - payload.append("citrus:currentDate('yyyy-MM-dd'T'hh:mm:ss')"); - } else if (!CollectionUtils.isEmpty(((StringProperty) property).getEnum())) { - payload.append("citrus:randomEnumValue(").append(((StringProperty) property).getEnum().stream().map(value -> "'" + value + "'").collect(Collectors.joining(","))).append(")"); - } else { - payload.append("citrus:randomString(").append(((StringProperty) property).getMaxLength() != null && ((StringProperty) property).getMaxLength() > 0 ? ((StringProperty) property).getMaxLength() : (((StringProperty) property).getMinLength() != null && ((StringProperty) property).getMinLength() > 0 ? ((StringProperty) property).getMinLength() : 10)).append(")"); - } - - if (quotes) { - payload.append("\""); - } - } else if (property instanceof IntegerProperty || property instanceof LongProperty) { - payload.append("citrus:randomNumber(10)"); - } else if (property instanceof FloatProperty || property instanceof DoubleProperty) { - payload.append("citrus:randomNumber(10)"); - } else if (property instanceof BooleanProperty) { - payload.append("citrus:randomEnumValue('true', 'false')"); - } else { - if (quotes) { - payload.append("\"\""); - } else { - payload.append(""); - } - } - - return payload.toString(); - } + private HttpMessage receive(ScenarioRunner scenarioRunner, + OpenApiServerActionBuilder openApiServerActionBuilder) { - /** - * Creates control payload for validation. - * @param parameter - * @return - */ - private String createValidationPayload(BodyParameter parameter) { - StringBuilder payload = new StringBuilder(); + OpenApiServerRequestActionBuilder requestActionBuilder = openApiServerActionBuilder.receive( + operationUid); - Model model = parameter.getSchema(); + requestActionBuilder + .message() + .header(MessageHeaders.MESSAGE_PREFIX + "generated", true); - if (model instanceof RefModel) { - model = definitions.get(((RefModel) model).getSimpleRef()); + if (operation.getParameters() != null && inboundDataDictionary != null) { + requestActionBuilder.message().dictionary(inboundDataDictionary); } - if (model instanceof ArrayModel) { - payload.append("["); - payload.append(createValidationExpression(((ArrayModel) model).getItems())); - payload.append("]"); - } else { + AtomicReference receivedMessage = new AtomicReference<>(); + requestActionBuilder.getMessageProcessors().add( + (message, context) -> receivedMessage.set((HttpMessage)message)); - payload.append("{"); + // Verify incoming request + scenarioRunner.$(requestActionBuilder); - if (model.getProperties() != null) { - for (Map.Entry entry : model.getProperties().entrySet()) { - payload.append("\"").append(entry.getKey()).append("\": ").append(createValidationExpression(entry.getValue())).append(","); - } - } + return receivedMessage.get(); + } - if (payload.toString().endsWith(",")) { - payload.replace(payload.length() - 1, payload.length(), ""); - } + private void respond(ScenarioRunner scenarioRunner, + OpenApiServerActionBuilder openApiServerActionBuilder, HttpMessage receivedMessage) { - payload.append("}"); + HttpServerResponseActionBuilder responseBuilder = null; + if (httpResponseActionBuilderProvider != null) { + responseBuilder = httpResponseActionBuilderProvider.provideHttpServerResponseActionBuilder(scenarioRunner, this, receivedMessage); } - return payload.toString(); - } - - /** - * Create validation expression using functions according to parameter type and format. - * @param property - * @return - */ - private String createValidationExpression(Property property) { - StringBuilder payload = new StringBuilder(); - if (property instanceof RefProperty) { - Model model = definitions.get(((RefProperty) property).getSimpleRef()); - payload.append("{"); - - if (model.getProperties() != null) { - for (Map.Entry entry : model.getProperties().entrySet()) { - payload.append("\"").append(entry.getKey()).append("\": ").append(createValidationExpression(entry.getValue())).append(","); - } - } - - if (payload.toString().endsWith(",")) { - payload.replace(payload.length() - 1, payload.length(), ""); - } - - payload.append("}"); - } else if (property instanceof ArrayProperty) { - payload.append("\"@ignore@\""); - } else if (property instanceof StringProperty) { - if (StringUtils.hasText(((StringProperty) property).getPattern())) { - payload.append("\"@matches(").append(((StringProperty) property).getPattern()).append(")@\""); - } else if (!CollectionUtils.isEmpty(((StringProperty) property).getEnum())) { - payload.append("\"@matches(").append(((StringProperty) property).getEnum().stream().collect(Collectors.joining("|"))).append(")@\""); - } else { - payload.append("\"@notEmpty()@\""); - } - } else if (property instanceof DateProperty) { - payload.append("\"@matchesDatePattern('yyyy-MM-dd')@\""); - } else if (property instanceof DateTimeProperty) { - payload.append("\"@matchesDatePattern('yyyy-MM-dd'T'hh:mm:ss')@\""); - } else if (property instanceof IntegerProperty || property instanceof LongProperty) { - payload.append("\"@isNumber()@\""); - } else if (property instanceof FloatProperty || property instanceof DoubleProperty) { - payload.append("\"@isNumber()@\""); - } else if (property instanceof BooleanProperty) { - payload.append("\"@matches(true|false)@\""); - } else { - payload.append("\"@ignore@\""); + if (responseBuilder == null) { + responseBuilder = createRandomMessageResponseBuilder(openApiServerActionBuilder, receivedMessage.getAccept()); } - return payload.toString(); + scenarioRunner.$(responseBuilder); } /** - * Create validation expression using functions according to parameter type and format. - * @param parameter + * Creates a builder that creates a random message based on OpenApi specification. + * @param openApiServerActionBuilder * @return */ - private String createValidationExpression(AbstractSerializableParameter parameter) { - switch (parameter.getType()) { - case "integer": - return "@isNumber()@"; - case "string": - if (parameter.getFormat() != null && parameter.getFormat().equals("date")) { - return "\"@matchesDatePattern('yyyy-MM-dd')@\""; - } else if (parameter.getFormat() != null && parameter.getFormat().equals("date-time")) { - return "\"@matchesDatePattern('yyyy-MM-dd'T'hh:mm:ss')@\""; - } else if (StringUtils.hasText(parameter.getPattern())) { - return "\"@matches(" + parameter.getPattern() + ")@\""; - } else if (!CollectionUtils.isEmpty(parameter.getEnum())) { - return "\"@matches(" + (parameter.getEnum().stream().collect(Collectors.joining("|"))) + ")@\""; - } else { - return "@notEmpty()@"; - } - case "boolean": - return "@matches(true|false)@"; - default: - return "@ignore@"; - } - } + private HttpServerResponseActionBuilder createRandomMessageResponseBuilder( + OpenApiServerActionBuilder openApiServerActionBuilder, String accept) { + HttpServerResponseActionBuilder responseBuilder; - /** - * Gets the operation. - * - * @return - */ - public Operation getOperation() { - return operation; - } + OasResponse response = determineResponse(accept); - /** - * Gets the path. - * - * @return - */ - public String getPath() { - return path; - } + HttpStatus httpStatus = getStatusFromResponseOrDefault(response); + responseBuilder = openApiServerActionBuilder.send(operationUid, httpStatus, accept); + responseBuilder.message() + .status(httpStatus) + .header(MessageHeaders.MESSAGE_PREFIX + "generated", true); - /** - * Gets the method. - * - * @return - */ - public RequestMethod getMethod() { - return method; + return responseBuilder; } - /** - * Gets the response. - * - * @return - */ - public Response getResponse() { - return response; + private HttpStatus getStatusFromResponseOrDefault(@Nullable OasResponse response) { + return response != null && response.getStatusCode() != null ? HttpStatus.valueOf( + Integer.parseInt(response.getStatusCode())) : HttpStatus.OK; } - /** - * Sets the response. - * - * @param response - */ - public void setResponse(Response response) { - this.response = response; + OasResponse determineResponse(String accept) { + return OasModelHelper.getResponseForRandomGeneration(getOasDocument(), operation, null, accept).orElse(null); } /** - * Gets the statusCode. + * Gets the document. * * @return */ - public HttpStatus getStatusCode() { - return statusCode; + public OasDocument getOasDocument() { + return openApiSpecification.getOpenApiDoc(null); } - /** - * Sets the statusCode. - * - * @param statusCode - */ - public void setStatusCode(HttpStatus statusCode) { - this.statusCode = statusCode; - } - - /** - * Gets the inboundDataDictionary. - * - * @return - */ - public JsonPathMappingDataDictionary getInboundDataDictionary() { - return inboundDataDictionary; + public String getMethod() { + return operation.getMethod() != null ? operation.getMethod().toUpperCase() : null; } /** @@ -452,15 +174,6 @@ public void setInboundDataDictionary(JsonPathMappingDataDictionary inboundDataDi this.inboundDataDictionary = inboundDataDictionary; } - /** - * Gets the outboundDataDictionary. - * - * @return - */ - public JsonPathMappingDataDictionary getOutboundDataDictionary() { - return outboundDataDictionary; - } - /** * Sets the outboundDataDictionary. * @@ -469,4 +182,5 @@ public JsonPathMappingDataDictionary getOutboundDataDictionary() { public void setOutboundDataDictionary(JsonPathMappingDataDictionary outboundDataDictionary) { this.outboundDataDictionary = outboundDataDictionary; } + } diff --git a/simulator-spring-boot/src/main/java/org/citrusframework/simulator/http/HttpOperationScenarioRegistrar.java b/simulator-spring-boot/src/main/java/org/citrusframework/simulator/http/HttpOperationScenarioRegistrar.java new file mode 100644 index 000000000..66a8335e0 --- /dev/null +++ b/simulator-spring-boot/src/main/java/org/citrusframework/simulator/http/HttpOperationScenarioRegistrar.java @@ -0,0 +1,48 @@ +/* + * Copyright 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.citrusframework.simulator.http; + +import org.citrusframework.CitrusInstanceManager; +import org.citrusframework.context.SpringBeanReferenceResolver; +import org.citrusframework.openapi.OpenApiSpecification; +import org.citrusframework.openapi.OpenApiSpecificationProcessor; +import org.citrusframework.spi.ReferenceResolver; +import org.springframework.context.support.AbstractApplicationContext; + +/** + * Registrar for HTTP operation scenarios based on an OpenAPI specification. + *

+ * This class implements the {@link OpenApiSpecificationProcessor} interface and processes an OpenAPI specification + * to register HTTP operation scenarios. + *

+ */ +public class HttpOperationScenarioRegistrar implements OpenApiSpecificationProcessor { + + @Override + public void process(OpenApiSpecification openApiSpecification) { + + HttpScenarioGenerator generator = new HttpScenarioGenerator(openApiSpecification); + + CitrusInstanceManager.get().ifPresent(citrus -> { + ReferenceResolver referenceResolver = citrus.getCitrusContext().getReferenceResolver(); + if (referenceResolver instanceof SpringBeanReferenceResolver springBeanReferenceResolver + && springBeanReferenceResolver.getApplicationContext() instanceof AbstractApplicationContext abstractApplicationContext) { + generator.postProcessBeanFactory(abstractApplicationContext.getBeanFactory()); + } + }); + } +} diff --git a/simulator-spring-boot/src/main/java/org/citrusframework/simulator/http/HttpPathSpecificityComparator.java b/simulator-spring-boot/src/main/java/org/citrusframework/simulator/http/HttpPathSpecificityComparator.java new file mode 100644 index 000000000..76de56af3 --- /dev/null +++ b/simulator-spring-boot/src/main/java/org/citrusframework/simulator/http/HttpPathSpecificityComparator.java @@ -0,0 +1,84 @@ +package org.citrusframework.simulator.http; + +import jakarta.annotation.Nonnull; +import java.util.Comparator; + +/** + * A comparator for HttpScenario objects that orders them based on the specificity + * of their paths. Paths that are more specific (having more segments and fewer + * variables or wildcards) are given higher priority in the ordering than less + * specific paths. This follows conventions used in REST API routing and matching. + */ + public class HttpPathSpecificityComparator implements + Comparator { + + @Override + public int compare(@Nonnull HttpScenario scenario1, @Nonnull HttpScenario scenario2) { + + Integer nullCompareResult = compareNullScenarios(scenario1, scenario2); + if (nullCompareResult != null) { + return nullCompareResult; + } + + String path1 = scenario1.getPath(); + String path2 = scenario2.getPath(); + + // Compare by segment count. + int segmentCountComparison = compareSegmentCount(path1, path2); + if (segmentCountComparison != 0) { + return segmentCountComparison; + } + + // Compare by variable presence in segments. + int variableComparison = compareVariablesInSegments(path1, path2); + if (variableComparison != 0) { + return variableComparison; + } + + // Finally, compare literally. + return path1.compareTo(path2); + } + + private Integer compareNullScenarios(HttpScenario scenario1, HttpScenario scenario2) { + boolean path1IsNull = scenario1 == null || scenario1.getPath() == null; + boolean path2IsNull = scenario2 == null || scenario2.getPath() == null; + + if (path1IsNull && path2IsNull) { + return 0; + } else if (path1IsNull) { + return 1; + } else if (path2IsNull) { + return -1; + } + + // Neither scenario nor path is null; defer comparison to further processing + return null; + } + + private int compareSegmentCount(String path1, String path2) { + int segmentCount1 = path1.split("/+", -1).length; + int segmentCount2 = path2.split("/+", -1).length; + return Integer.compare(segmentCount2, segmentCount1); + } + + private int compareVariablesInSegments(String path1, String path2) { + String[] segments1 = path1.split("/+", -1); + String[] segments2 = path2.split("/+", -1); + + for (int i = 0; i < segments1.length; i++) { + boolean isVariable1 = isVariableSegment(segments1[i]); + boolean isVariable2 = isVariableSegment(segments2[i]); + + if (isVariable1 && !isVariable2) { + return 1; // Path1 is less specific than Path2. + } else if (!isVariable1 && isVariable2) { + return -1; // Path1 is more specific than Path2. + } + } + return 0; + } + + private boolean isVariableSegment(String segment) { + return segment.startsWith("{") || segment.contains("*"); + } + } diff --git a/simulator-spring-boot/src/main/java/org/citrusframework/simulator/http/HttpRequestAnnotationScenarioMapper.java b/simulator-spring-boot/src/main/java/org/citrusframework/simulator/http/HttpRequestAnnotationScenarioMapper.java index 68caa60d0..b80c63c69 100644 --- a/simulator-spring-boot/src/main/java/org/citrusframework/simulator/http/HttpRequestAnnotationScenarioMapper.java +++ b/simulator-spring-boot/src/main/java/org/citrusframework/simulator/http/HttpRequestAnnotationScenarioMapper.java @@ -16,37 +16,50 @@ package org.citrusframework.simulator.http; -import jakarta.annotation.Nullable; +import static org.citrusframework.simulator.scenario.ScenarioUtils.getAnnotationFromClassHierarchy; + +import jakarta.annotation.PostConstruct; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.CopyOnWriteArrayList; import lombok.Builder; import org.citrusframework.http.message.HttpMessage; import org.citrusframework.message.Message; import org.citrusframework.simulator.config.SimulatorConfigurationProperties; +import org.citrusframework.simulator.events.ScenariosReloadedEvent; import org.citrusframework.simulator.scenario.ScenarioListAware; import org.citrusframework.simulator.scenario.SimulatorScenario; import org.citrusframework.simulator.scenario.mapper.AbstractScenarioMapper; -import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.BeansException; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextAware; +import org.springframework.context.ApplicationListener; import org.springframework.core.annotation.AnnotationUtils; +import org.springframework.lang.NonNull; import org.springframework.web.bind.annotation.RequestMapping; -import java.util.Collections; -import java.util.List; -import java.util.Optional; - -import static org.citrusframework.simulator.scenario.ScenarioUtils.getAnnotationFromClassHierarchy; - /** - * Scenario mapper performs mapping logic on request mapping annotations on given scenarios. Scenarios match on request method as well as - * request path pattern matching. + * Scenario mapper performs mapping logic on request mapping annotations on given scenarios. + * Scenarios match on request method as well as request path pattern matching. * * @author Christoph Deppisch */ @SuppressWarnings("SpringJavaInjectionPointsAutowiringInspection") -public class HttpRequestAnnotationScenarioMapper extends AbstractScenarioMapper implements ScenarioListAware { +public class HttpRequestAnnotationScenarioMapper extends AbstractScenarioMapper implements + ScenarioListAware, + ApplicationContextAware, ApplicationListener { private final HttpRequestAnnotationMatcher httpRequestAnnotationMatcher = HttpRequestAnnotationMatcher.instance(); - @Autowired(required = false) - private @Nullable List scenarioList; + private final List scenarioList = new CopyOnWriteArrayList<>(); + + private ApplicationContext applicationContext; + + @PostConstruct + public void init() { + setScenariosFromApplicationContext(); + } @Override protected String getMappingKey(Message request) { @@ -58,34 +71,42 @@ protected String getMappingKey(Message request) { } protected String getMappingKeyForHttpMessage(HttpMessage httpMessage) { - List nullSafeList = Optional.ofNullable(scenarioList).orElse(Collections.emptyList()); // First look for exact match - Optional mapping = nullSafeList.stream() + Optional mapping = scenarioList.stream() .map(scenario -> EnrichedScenarioWithRequestMapping.builder() .scenario(scenario) .requestMapping(getAnnotationFromClassHierarchy(scenario, RequestMapping.class)) .build() ) .filter(EnrichedScenarioWithRequestMapping::hasRequestMapping) - .filter(swrm -> httpRequestAnnotationMatcher.checkRequestPathSupported(httpMessage, swrm.requestMapping(), true)) - .filter(swrm -> httpRequestAnnotationMatcher.checkRequestMethodSupported(httpMessage, swrm.requestMapping())) - .filter(swrm -> httpRequestAnnotationMatcher.checkRequestQueryParamsSupported(httpMessage, swrm.requestMapping())) + .filter(swrm -> httpRequestAnnotationMatcher.checkRequestPathSupported(httpMessage, + swrm.requestMapping(), true)) + .filter(swrm -> httpRequestAnnotationMatcher.checkRequestMethodSupported(httpMessage, + swrm.requestMapping())) + .filter( + swrm -> httpRequestAnnotationMatcher.checkRequestQueryParamsSupported(httpMessage, + swrm.requestMapping())) .map(EnrichedScenarioWithRequestMapping::name) .findFirst(); // If that didn't help, look for inecaxt match if (mapping.isEmpty()) { - mapping = nullSafeList.stream() + mapping = scenarioList.stream() .map(scenario -> EnrichedScenarioWithRequestMapping.builder() .scenario(scenario) - .requestMapping(AnnotationUtils.findAnnotation(scenario.getClass(), RequestMapping.class)) + .requestMapping( + AnnotationUtils.findAnnotation(scenario.getClass(), RequestMapping.class)) .build() ) .filter(EnrichedScenarioWithRequestMapping::hasRequestMapping) - .filter(swrm -> httpRequestAnnotationMatcher.checkRequestPathSupported(httpMessage, swrm.requestMapping(), false)) - .filter(swrm -> httpRequestAnnotationMatcher.checkRequestMethodSupported(httpMessage, swrm.requestMapping())) - .filter(swrm -> httpRequestAnnotationMatcher.checkRequestQueryParamsSupported(httpMessage, swrm.requestMapping())) + .filter(swrm -> httpRequestAnnotationMatcher.checkRequestPathSupported(httpMessage, + swrm.requestMapping(), false)) + .filter( + swrm -> httpRequestAnnotationMatcher.checkRequestMethodSupported(httpMessage, + swrm.requestMapping())) + .filter(swrm -> httpRequestAnnotationMatcher.checkRequestQueryParamsSupported( + httpMessage, swrm.requestMapping())) .map(EnrichedScenarioWithRequestMapping::name) .findFirst(); } @@ -99,7 +120,7 @@ protected String getMappingKeyForHttpMessage(HttpMessage httpMessage) { * @return */ public List getScenarios() { - return scenarioList; + return new ArrayList<>(scenarioList); } /** @@ -108,12 +129,12 @@ public List getScenarios() { * @param scenarios */ public void setScenarios(List scenarios) { - this.scenarioList = scenarios; + updateScenarioList(scenarios); } @Override public void setScenarioList(List scenarioList) { - this.scenarioList = scenarioList; + updateScenarioList(scenarioList); } /** @@ -134,8 +155,32 @@ public void setConfiguration(SimulatorConfigurationProperties configuration) { this.setSimulatorConfigurationProperties(configuration); } + @Override + public void setApplicationContext(@NonNull ApplicationContext applicationContext) throws BeansException { + this.applicationContext = applicationContext; + } + + @Override + public void onApplicationEvent(@NonNull ScenariosReloadedEvent event) { + setScenariosFromApplicationContext(); + } + + private void setScenariosFromApplicationContext() { + updateScenarioList( + applicationContext.getBeansOfType(SimulatorScenario.class).values().stream() + .toList()); + } + + private void updateScenarioList(List newScenarios) { + synchronized (this.scenarioList) { + scenarioList.clear(); + scenarioList.addAll(newScenarios); + } + } + @Builder - private record EnrichedScenarioWithRequestMapping(SimulatorScenario scenario, RequestMapping requestMapping) { + private record EnrichedScenarioWithRequestMapping(SimulatorScenario scenario, + RequestMapping requestMapping) { public boolean hasRequestMapping() { return requestMapping != null; @@ -145,4 +190,6 @@ public String name() { return scenario.getName(); } } + + } diff --git a/simulator-spring-boot/src/main/java/org/citrusframework/simulator/http/HttpRequestPathScenarioMapper.java b/simulator-spring-boot/src/main/java/org/citrusframework/simulator/http/HttpRequestPathScenarioMapper.java index ed4c4a475..5cca0a8aa 100644 --- a/simulator-spring-boot/src/main/java/org/citrusframework/simulator/http/HttpRequestPathScenarioMapper.java +++ b/simulator-spring-boot/src/main/java/org/citrusframework/simulator/http/HttpRequestPathScenarioMapper.java @@ -16,66 +16,78 @@ package org.citrusframework.simulator.http; +import jakarta.annotation.PostConstruct; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; import org.citrusframework.http.message.HttpMessage; import org.citrusframework.message.Message; import org.citrusframework.simulator.config.SimulatorConfigurationProperties; +import org.citrusframework.simulator.events.ScenariosReloadedEvent; import org.citrusframework.simulator.scenario.ScenarioListAware; import org.citrusframework.simulator.scenario.SimulatorScenario; import org.citrusframework.simulator.scenario.mapper.AbstractScenarioMapper; -import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.BeansException; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextAware; +import org.springframework.context.ApplicationListener; +import org.springframework.lang.NonNull; import org.springframework.util.AntPathMatcher; import org.springframework.util.PathMatcher; -import java.util.ArrayList; -import java.util.List; - /** * Scenario mapper supports path pattern matching on request path. * * @author Christoph Deppisch */ -public class HttpRequestPathScenarioMapper extends AbstractScenarioMapper implements ScenarioListAware { +public class HttpRequestPathScenarioMapper extends AbstractScenarioMapper implements + ScenarioListAware, + ApplicationContextAware, ApplicationListener { + + private final List scenarioList = new CopyOnWriteArrayList<>(); - @Autowired(required = false) - private List scenarioList = new ArrayList<>(); + private ApplicationContext applicationContext; - /** Request path matcher */ + /** + * Request path matcher + */ private final PathMatcher pathMatcher = new AntPathMatcher(); @Override protected String getMappingKey(Message request) { - if (request instanceof HttpMessage) { - String requestPath = ((HttpMessage) request).getPath(); - - if (requestPath != null) { - for (HttpOperationScenario scenario : scenarioList) { - if (scenario.getPath().equals(requestPath)) { - if (scenario.getMethod().name().equals(((HttpMessage) request).getRequestMethod().name())) { - return scenario.getOperation().getOperationId(); - } - } - } + if (request instanceof HttpMessage httpMessage) { + String requestPath = httpMessage.getPath(); + String requestMethod = + httpMessage.getRequestMethod() != null ? httpMessage.getRequestMethod().name() + : null; - for (HttpOperationScenario scenario : scenarioList) { - if (pathMatcher.match(scenario.getPath(), requestPath)) { - if (scenario.getMethod().name().equals(((HttpMessage) request).getRequestMethod().name())) { - return scenario.getOperation().getOperationId(); - } + if (requestPath != null && requestMethod != null) { + for (HttpScenario scenario : scenarioList) { + if (requestMethod.equals(scenario.getMethod()) && pathMatcher.match( + scenario.getPath(), requestPath)) { + return scenario.getScenarioId(); } } } + } return super.getMappingKey(request); } + @PostConstruct + public void init() { + setScenariosFromApplicationContext(); + } + /** * Gets the httpScenarios. * * @return */ - public List getHttpScenarios() { - return scenarioList; + public List getHttpScenarios() { + return new ArrayList<>(scenarioList); } /** @@ -83,16 +95,13 @@ public List getHttpScenarios() { * * @param httpScenarios */ - public void setHttpScenarios(List httpScenarios) { - this.scenarioList = httpScenarios; + public void setHttpScenarios(List httpScenarios) { + updateScenarioList(httpScenarios); } @Override public void setScenarioList(List scenarioList) { - this.scenarioList = scenarioList.stream() - .filter(scenario -> scenario instanceof HttpOperationScenario) - .map(scenario -> (HttpOperationScenario) scenario) - .toList(); + updateScenarioList(filterScenarios(scenarioList)); } /** @@ -103,4 +112,35 @@ public void setScenarioList(List scenarioList) { public void setConfiguration(SimulatorConfigurationProperties configuration) { this.setSimulatorConfigurationProperties(configuration); } + + @Override + public void setApplicationContext(@NonNull ApplicationContext applicationContext) + throws BeansException { + this.applicationContext = applicationContext; + } + + @Override + public void onApplicationEvent(@NonNull ScenariosReloadedEvent event) { + setScenariosFromApplicationContext(); + } + + private void setScenariosFromApplicationContext() { + updateScenarioList( + filterScenarios(applicationContext.getBeansOfType(SimulatorScenario.class).values())); + } + + private List filterScenarios(Collection allScenarios) { + return allScenarios.stream().filter(HttpScenario.class::isInstance) + .map(HttpScenario.class::cast) + .sorted(new HttpPathSpecificityComparator()) + .toList(); + } + + private void updateScenarioList(Collection newScenarios) { + synchronized (this.scenarioList) { + scenarioList.clear(); + scenarioList.addAll(newScenarios); + } + } + } diff --git a/simulator-spring-boot/src/main/java/org/citrusframework/simulator/http/HttpResponseActionBuilderProvider.java b/simulator-spring-boot/src/main/java/org/citrusframework/simulator/http/HttpResponseActionBuilderProvider.java new file mode 100644 index 000000000..c58dac60f --- /dev/null +++ b/simulator-spring-boot/src/main/java/org/citrusframework/simulator/http/HttpResponseActionBuilderProvider.java @@ -0,0 +1,32 @@ +/* + * Copyright 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.citrusframework.simulator.http; + +import org.citrusframework.http.actions.HttpServerResponseActionBuilder; +import org.citrusframework.http.message.HttpMessage; +import org.citrusframework.simulator.scenario.ScenarioRunner; +import org.citrusframework.simulator.scenario.SimulatorScenario; + +/** + * Interface for providing an {@link HttpServerResponseActionBuilder} based on a {@link SimulatorScenario} and a received message. + */ +public interface HttpResponseActionBuilderProvider { + + HttpServerResponseActionBuilder provideHttpServerResponseActionBuilder( + ScenarioRunner scenarioRunner, SimulatorScenario simulatorScenario, HttpMessage receivedMessage); + +} diff --git a/simulator-spring-boot/src/main/java/org/citrusframework/simulator/http/HttpScenario.java b/simulator-spring-boot/src/main/java/org/citrusframework/simulator/http/HttpScenario.java new file mode 100644 index 000000000..fbe12c7d7 --- /dev/null +++ b/simulator-spring-boot/src/main/java/org/citrusframework/simulator/http/HttpScenario.java @@ -0,0 +1,11 @@ +package org.citrusframework.simulator.http; + +import org.citrusframework.simulator.scenario.IdentifiableSimulatorScenario; + +public interface HttpScenario extends IdentifiableSimulatorScenario { + + String getPath(); + + String getMethod(); + +} diff --git a/simulator-spring-boot/src/main/java/org/citrusframework/simulator/http/HttpScenarioGenerator.java b/simulator-spring-boot/src/main/java/org/citrusframework/simulator/http/HttpScenarioGenerator.java index c39673a1b..bf416642e 100644 --- a/simulator-spring-boot/src/main/java/org/citrusframework/simulator/http/HttpScenarioGenerator.java +++ b/simulator-spring-boot/src/main/java/org/citrusframework/simulator/http/HttpScenarioGenerator.java @@ -16,17 +16,20 @@ package org.citrusframework.simulator.http; -import static org.citrusframework.util.FileUtils.readToString; import static org.springframework.beans.factory.support.BeanDefinitionBuilder.genericBeanDefinition; -import io.swagger.models.Model; -import io.swagger.models.Operation; -import io.swagger.models.Path; -import io.swagger.models.Swagger; -import io.swagger.parser.SwaggerParser; -import java.io.IOException; +import io.apicurio.datamodels.combined.visitors.CombinedVisitorAdapter; +import io.apicurio.datamodels.openapi.models.OasDocument; +import io.apicurio.datamodels.openapi.models.OasOperation; +import io.apicurio.datamodels.openapi.models.OasPathItem; +import io.apicurio.datamodels.openapi.models.OasPaths; +import jakarta.annotation.Nonnull; import java.util.Map; -import org.citrusframework.simulator.exception.SimulatorException; +import lombok.Getter; +import org.citrusframework.context.TestContext; +import org.citrusframework.openapi.OpenApiSpecification; +import org.citrusframework.openapi.model.OasModelHelper; +import org.citrusframework.simulator.service.ScenarioLookupService; import org.citrusframework.spi.CitrusResourceWrapper; import org.citrusframework.spi.Resource; import org.slf4j.Logger; @@ -38,7 +41,6 @@ import org.springframework.beans.factory.support.BeanDefinitionRegistry; import org.springframework.core.io.support.PathMatchingResourcePatternResolver; import org.springframework.util.Assert; -import org.springframework.web.bind.annotation.RequestMethod; /** * @author Christoph Deppisch @@ -48,94 +50,186 @@ public class HttpScenarioGenerator implements BeanFactoryPostProcessor { private static final Logger logger = LoggerFactory.getLogger(HttpScenarioGenerator.class); /** - * Target swagger API to generate scenarios from + * Target Open API to generate scenarios from */ - private final Resource swaggerResource; + private final Resource openApiResource; + + private OpenApiSpecification openApiSpecification; /** * Optional context path */ + @Getter private String contextPath = ""; + @Getter + private boolean requestValidationEnabled = true; + + @Getter + private boolean responseValidationEnabled = true; + /** * Constructor using Spring environment. */ public HttpScenarioGenerator(SimulatorRestConfigurationProperties simulatorRestConfigurationProperties) { - swaggerResource = new CitrusResourceWrapper( + openApiResource = new CitrusResourceWrapper( new PathMatchingResourcePatternResolver() - .getResource(simulatorRestConfigurationProperties.getSwagger().getApi()) + .getResource(simulatorRestConfigurationProperties.getOpenApi().getApi()) ); - contextPath = simulatorRestConfigurationProperties.getSwagger().getContextPath(); + this.contextPath = simulatorRestConfigurationProperties.getOpenApi().getContextPath(); } /** * Constructor using swagger API file resource. * - * @param swaggerResource + * @param openApiResource */ - public HttpScenarioGenerator(Resource swaggerResource) { - this.swaggerResource = swaggerResource; + public HttpScenarioGenerator(Resource openApiResource) { + this.openApiResource = openApiResource; } - @Override - public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) throws BeansException { - try { - Assert.notNull(swaggerResource, - "Missing either swagger api system property setting or explicit swagger api resource for scenario auto generation"); + public HttpScenarioGenerator(OpenApiSpecification openApiSpecification) { + this.openApiResource = null; + this.openApiSpecification = openApiSpecification; + this.contextPath = openApiSpecification.getRootContextPath(); + } - Swagger swagger = new SwaggerParser().parse(readToString(swaggerResource)); + public void setRequestValidationEnabled(boolean enabled) { + this.requestValidationEnabled = enabled; + if (openApiSpecification != null) { + openApiSpecification.setApiRequestValidationEnabled(enabled); + } + } - for (Map.Entry path : swagger.getPaths().entrySet()) { - for (Map.Entry operation : path.getValue().getOperationMap().entrySet()) { + public void setResponseValidationEnabled(boolean enabled) { + this.responseValidationEnabled = enabled; + if (openApiSpecification != null) { + openApiSpecification.setApiResponseValidationEnabled(enabled); + } + } - if (beanFactory instanceof BeanDefinitionRegistry beanDefinitionRegistry) { - logger.info("Register auto generated scenario as bean definition: {}", operation.getValue().getOperationId()); + @Override + public void postProcessBeanFactory(@Nonnull ConfigurableListableBeanFactory beanFactory) throws BeansException { - BeanDefinitionBuilder beanDefinitionBuilder = genericBeanDefinition(HttpOperationScenario.class) - .addConstructorArgValue((contextPath + (swagger.getBasePath() != null ? swagger.getBasePath() : "")) + path.getKey()) - .addConstructorArgValue(RequestMethod.valueOf(operation.getKey().name())) - .addConstructorArgValue(operation.getValue()) - .addConstructorArgValue(swagger.getDefinitions()); + if (openApiSpecification == null) { + initOpenApiSpecification(); + } - if (beanFactory.containsBeanDefinition("inboundJsonDataDictionary")) { - beanDefinitionBuilder.addPropertyReference("inboundDataDictionary", "inboundJsonDataDictionary"); - } + TestContext testContext = new TestContext(); + OasDocument openApiDocument = openApiSpecification.getOpenApiDoc(testContext); + if (openApiDocument != null && openApiDocument.paths != null) { + openApiDocument.paths.accept(new ScenarioRegistrar(beanFactory)); - if (beanFactory.containsBeanDefinition("outboundJsonDataDictionary")) { - beanDefinitionBuilder.addPropertyReference("outboundDataDictionary", "outboundJsonDataDictionary"); - } - beanDefinitionRegistry.registerBeanDefinition(operation.getValue().getOperationId(), beanDefinitionBuilder.getBeanDefinition()); - } else { - logger.info("Register auto generated scenario as singleton: {}", operation.getValue().getOperationId()); - beanFactory.registerSingleton(operation.getValue().getOperationId(), createScenario((contextPath + (swagger.getBasePath() != null ? swagger.getBasePath() : "")) + path.getKey(), RequestMethod.valueOf(operation.getKey().name()), operation.getValue(), swagger.getDefinitions())); - } - } + try { + ScenarioLookupService scenarioLookupService = beanFactory.getBean(ScenarioLookupService.class); + scenarioLookupService.evictAndReloadScenarioCache(); + } catch (Exception e) { + // Ignore because no service, no need to update after adding scenarios } - } catch (IOException e) { - throw new SimulatorException("Failed to read swagger api resource", e); } } + private void initOpenApiSpecification() { + Assert.notNull(openApiResource, + """ + Failed to load OpenAPI specification. No OpenAPI specification was provided. + To load a specification, ensure that either the 'openApiResource' property is set + or the 'swagger.api' system property is configured to specify the location of the OpenAPI resource."""); + openApiSpecification = OpenApiSpecification.from(openApiResource); + openApiSpecification.setRootContextPath(contextPath); + openApiSpecification.setApiResponseValidationEnabled(responseValidationEnabled); + openApiSpecification.setApiRequestValidationEnabled(requestValidationEnabled); + } + + private static HttpResponseActionBuilderProvider retrieveOptionalBuilderProvider( + ConfigurableListableBeanFactory beanFactory) { + HttpResponseActionBuilderProvider httpResponseActionBuilderProvider = null; + try { + httpResponseActionBuilderProvider = beanFactory.getBean( + HttpResponseActionBuilderProvider.class); + } catch (BeansException e) { + // Ignore non existing optional provider + } + return httpResponseActionBuilderProvider; + } + /** * Creates an HTTP scenario based on the given swagger path and operation information. * - * @param path Request path - * @param method Request method - * @param operation Swagger operation - * @param definitions Additional definitions + * @param path Full request path, including the context + * @param scenarioId Request method + * @param openApiSpecification OpenApiSpecification + * @param operation OpenApi operation * @return a matching HTTP scenario */ - protected HttpOperationScenario createScenario(String path, RequestMethod method, Operation operation, Map definitions) { - return new HttpOperationScenario(path, method, operation, definitions); - } - - public String getContextPath() { - return contextPath; + protected HttpOperationScenario createScenario(String path, String scenarioId, OpenApiSpecification openApiSpecification, OasOperation operation, HttpResponseActionBuilderProvider httpResponseActionBuilderProvider) { + return new HttpOperationScenario(path, scenarioId, openApiSpecification, operation, httpResponseActionBuilderProvider); } public void setContextPath(String contextPath) { this.contextPath = contextPath; + + if (openApiSpecification != null) { + openApiSpecification.setRootContextPath(contextPath); + } + } + + private class ScenarioRegistrar extends CombinedVisitorAdapter { + + private final ConfigurableListableBeanFactory beanFactory; + + private ScenarioRegistrar(ConfigurableListableBeanFactory beanFactory) { + this.beanFactory = beanFactory; + } + + @Override + public void visitPaths(OasPaths oasPaths) { + oasPaths.getPathItems().forEach(oasPathItem -> oasPathItem.accept(this)); + } + + @Override + public void visitPathItem(OasPathItem oasPathItem) { + + HttpResponseActionBuilderProvider httpResponseActionBuilderProvider = retrieveOptionalBuilderProvider( + beanFactory); + + for (Map.Entry operationEntry : OasModelHelper.getOperationMap( + oasPathItem).entrySet()) { + + String fullPath = openApiSpecification.getFullPath(oasPathItem); + OasOperation oasOperation = operationEntry.getValue(); + + String scenarioId = openApiSpecification.getUniqueId(oasOperation); + + if (beanFactory instanceof BeanDefinitionRegistry beanDefinitionRegistry) { + logger.info("Register auto generated scenario as bean definition: {}", fullPath); + + BeanDefinitionBuilder beanDefinitionBuilder = genericBeanDefinition(HttpOperationScenario.class) + .addConstructorArgValue(fullPath) + .addConstructorArgValue(scenarioId) + .addConstructorArgValue(openApiSpecification) + .addConstructorArgValue(oasOperation) + .addConstructorArgValue(httpResponseActionBuilderProvider); + + if (beanFactory.containsBeanDefinition("inboundJsonDataDictionary")) { + beanDefinitionBuilder.addPropertyReference("inboundDataDictionary", "inboundJsonDataDictionary"); + } + + if (beanFactory.containsBeanDefinition("outboundJsonDataDictionary")) { + beanDefinitionBuilder.addPropertyReference("outboundDataDictionary", "outboundJsonDataDictionary"); + } + + if (!beanDefinitionRegistry.isBeanDefinitionOverridable(scenarioId) && beanDefinitionRegistry.containsBeanDefinition(scenarioId)) { + beanDefinitionRegistry.removeBeanDefinition(scenarioId); + } + beanDefinitionRegistry.registerBeanDefinition(scenarioId, beanDefinitionBuilder.getBeanDefinition()); + } else { + logger.info("Register auto generated scenario as singleton: {}", scenarioId); + beanFactory.registerSingleton(scenarioId, createScenario(fullPath, scenarioId, openApiSpecification, oasOperation, httpResponseActionBuilderProvider)); + } + } + } } } diff --git a/simulator-spring-boot/src/main/java/org/citrusframework/simulator/http/SimulatorRestAutoConfiguration.java b/simulator-spring-boot/src/main/java/org/citrusframework/simulator/http/SimulatorRestAutoConfiguration.java index f917f6e04..22b543ac0 100644 --- a/simulator-spring-boot/src/main/java/org/citrusframework/simulator/http/SimulatorRestAutoConfiguration.java +++ b/simulator-spring-boot/src/main/java/org/citrusframework/simulator/http/SimulatorRestAutoConfiguration.java @@ -16,10 +16,20 @@ package org.citrusframework.simulator.http; +import static java.util.Collections.addAll; +import static java.util.Objects.isNull; +import static java.util.Objects.nonNull; +import static lombok.AccessLevel.PROTECTED; + import jakarta.annotation.Nullable; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.constraints.NotNull; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; import lombok.Getter; import org.citrusframework.endpoint.EndpointAdapter; import org.citrusframework.endpoint.adapter.EmptyResponseEndpointAdapter; @@ -56,17 +66,6 @@ import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter; import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping; -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -import static java.util.Collections.addAll; -import static java.util.Objects.isNull; -import static java.util.Objects.nonNull; -import static lombok.AccessLevel.PROTECTED; - @Configuration @ConditionalOnWebApplication @AutoConfigureAfter(SimulatorAutoConfiguration.class) diff --git a/simulator-spring-boot/src/main/java/org/citrusframework/simulator/http/SimulatorRestConfigurationProperties.java b/simulator-spring-boot/src/main/java/org/citrusframework/simulator/http/SimulatorRestConfigurationProperties.java index e962dd213..951f74491 100644 --- a/simulator-spring-boot/src/main/java/org/citrusframework/simulator/http/SimulatorRestConfigurationProperties.java +++ b/simulator-spring-boot/src/main/java/org/citrusframework/simulator/http/SimulatorRestConfigurationProperties.java @@ -16,21 +16,24 @@ package org.citrusframework.simulator.http; +import static java.util.Collections.emptyList; +import static java.util.Collections.unmodifiableList; + import jakarta.validation.constraints.NotNull; +import java.util.List; +import lombok.Getter; +import lombok.Setter; import org.apache.commons.lang3.builder.ToStringBuilder; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.InitializingBean; import org.springframework.boot.context.properties.ConfigurationProperties; -import java.util.List; - -import static java.util.Collections.emptyList; -import static java.util.Collections.unmodifiableList; - /** * @author Christoph Deppisch */ +@Getter +@Setter @ConfigurationProperties(prefix = "citrus.simulator.rest") public class SimulatorRestConfigurationProperties implements InitializingBean { @@ -47,25 +50,10 @@ public class SimulatorRestConfigurationProperties implements InitializingBean { */ private List urlMappings = List.of("/services/rest/**"); - private Swagger swagger = new Swagger(); - /** - * Gets the enabled. - * - * @return + * The OpenApi used by the simulator to simulate OpenApi operations. */ - public boolean isEnabled() { - return enabled; - } - - /** - * Sets the enabled. - * - * @param enabled - */ - public void setEnabled(boolean enabled) { - this.enabled = enabled; - } + private OpenApi openApi = new OpenApi(); /** * Gets the urlMappings. @@ -86,14 +74,6 @@ public void setUrlMappings(List urlMappings) { this.urlMappings = urlMappings != null ? unmodifiableList(urlMappings) : emptyList(); } - public Swagger getSwagger() { - return swagger; - } - - public void setSwagger(Swagger swagger) { - this.swagger = swagger; - } - @Override public void afterPropertiesSet() throws Exception { logger.info("Using the simulator configuration: {}", this); @@ -107,34 +87,12 @@ public String toString() { .toString(); } - public static class Swagger { - + @Getter + @Setter + public static class OpenApi { private String api; private String contextPath; private boolean enabled = false; - - public String getApi() { - return api; - } - - public void setApi(String api) { - this.api = api; - } - - public String getContextPath() { - return contextPath; - } - - public void setContextPath(String contextPath) { - this.contextPath = contextPath; - } - - public boolean isEnabled() { - return enabled; - } - - public void setEnabled(boolean enabled) { - this.enabled = enabled; - } + private String alias; } } diff --git a/simulator-spring-boot/src/main/java/org/citrusframework/simulator/scenario/IdentifiableSimulatorScenario.java b/simulator-spring-boot/src/main/java/org/citrusframework/simulator/scenario/IdentifiableSimulatorScenario.java new file mode 100644 index 000000000..19334ffb3 --- /dev/null +++ b/simulator-spring-boot/src/main/java/org/citrusframework/simulator/scenario/IdentifiableSimulatorScenario.java @@ -0,0 +1,6 @@ +package org.citrusframework.simulator.scenario; + +public interface IdentifiableSimulatorScenario extends SimulatorScenario { + + String getScenarioId(); +} diff --git a/simulator-spring-boot/src/main/java/org/citrusframework/simulator/scenario/mapper/ScenarioMappers.java b/simulator-spring-boot/src/main/java/org/citrusframework/simulator/scenario/mapper/ScenarioMappers.java index b0edaa50e..1727deb47 100644 --- a/simulator-spring-boot/src/main/java/org/citrusframework/simulator/scenario/mapper/ScenarioMappers.java +++ b/simulator-spring-boot/src/main/java/org/citrusframework/simulator/scenario/mapper/ScenarioMappers.java @@ -16,7 +16,10 @@ package org.citrusframework.simulator.scenario.mapper; -import io.swagger.models.Operation; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; import org.citrusframework.message.Message; import org.citrusframework.simulator.config.SimulatorConfigurationPropertiesAware; import org.citrusframework.simulator.http.HttpOperationScenario; @@ -28,11 +31,6 @@ import org.springframework.core.annotation.AnnotationUtils; import org.springframework.util.StringUtils; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; -import java.util.Optional; - /** * Scenario mapper chain goes through a list of mappers to find best match of extracted mapping keys. When no suitable * mapping key is found in the list of mappers a default mapping is used based on provided base class evaluation. @@ -67,8 +65,8 @@ public static ScenarioMappers of(ScenarioMapper ... scenarioMappers) { public String getMappingKey(Message message) { return scenarioMapperList.stream() .map(mapper -> { - if (mapper instanceof AbstractScenarioMapper) { - ((AbstractScenarioMapper) mapper).setUseDefaultMapping(false); + if (mapper instanceof AbstractScenarioMapper abstractScenarioMapper) { + abstractScenarioMapper.setUseDefaultMapping(false); } try { @@ -82,11 +80,8 @@ public String getMappingKey(Message message) { .filter(StringUtils::hasLength) .filter(key -> scenarioList.parallelStream() .anyMatch(scenario -> { - if (scenario instanceof HttpOperationScenario) { - return Optional.ofNullable(((HttpOperationScenario) scenario).getOperation()) - .map(Operation::getOperationId) - .orElse("") - .equals(key); + if (scenario instanceof HttpOperationScenario httpOperationScenario) { + return key.equals(httpOperationScenario.getScenarioId()); } return Optional.ofNullable(AnnotationUtils.findAnnotation(scenario.getClass(), Scenario.class)) @@ -101,13 +96,13 @@ public String getMappingKey(Message message) { @Override public void afterPropertiesSet() { scenarioMapperList.stream() - .filter(mapper -> mapper instanceof ScenarioListAware) - .map(mapper -> (ScenarioListAware) mapper) + .filter(ScenarioListAware.class::isInstance) + .map(ScenarioListAware.class::cast) .forEach(mapper -> mapper.setScenarioList(scenarioList)); scenarioMapperList.stream() - .filter(mapper -> mapper instanceof SimulatorConfigurationPropertiesAware) - .map(mapper -> (SimulatorConfigurationPropertiesAware) mapper) + .filter(SimulatorConfigurationPropertiesAware.class::isInstance) + .map(SimulatorConfigurationPropertiesAware.class::cast) .forEach(mapper -> mapper.setSimulatorConfigurationProperties(getSimulatorConfigurationProperties())); } diff --git a/simulator-spring-boot/src/main/java/org/citrusframework/simulator/service/impl/ScenarioLookupServiceImpl.java b/simulator-spring-boot/src/main/java/org/citrusframework/simulator/service/impl/ScenarioLookupServiceImpl.java index e180a86b7..4fe312a37 100644 --- a/simulator-spring-boot/src/main/java/org/citrusframework/simulator/service/impl/ScenarioLookupServiceImpl.java +++ b/simulator-spring-boot/src/main/java/org/citrusframework/simulator/service/impl/ScenarioLookupServiceImpl.java @@ -16,6 +16,11 @@ package org.citrusframework.simulator.service.impl; +import java.util.Collection; +import java.util.Collections; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; import org.citrusframework.simulator.events.ScenariosReloadedEvent; import org.citrusframework.simulator.model.ScenarioParameter; import org.citrusframework.simulator.scenario.Scenario; @@ -29,12 +34,6 @@ import org.springframework.context.ApplicationContext; import org.springframework.stereotype.Service; -import java.util.Collection; -import java.util.Collections; -import java.util.Map; -import java.util.Set; -import java.util.stream.Collectors; - /** * Service for looking-up and accessing {@link Scenario}'s and {@link Starter}'s. */ @@ -55,6 +54,7 @@ public class ScenarioLookupServiceImpl implements InitializingBean, ScenarioLook */ private Map scenarioStarters; + private boolean scenarioListsInvalidated; public ScenarioLookupServiceImpl(ApplicationContext applicationContext) { this.applicationContext = applicationContext; @@ -64,7 +64,6 @@ private static Map getSimulatorScenarios(ApplicationC return context.getBeansOfType(SimulatorScenario.class).entrySet().stream() .filter(map -> !map.getValue().getClass().isAnnotationPresent(Starter.class)) .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); - } private static Map getScenarioStarters(ApplicationContext context) { @@ -129,4 +128,5 @@ public Collection lookupScenarioParameters(String scenarioNam return Collections.emptyList(); } + } diff --git a/simulator-spring-boot/src/main/resources/META-INF/citrus/openapi/processor/scenarioRegistrar b/simulator-spring-boot/src/main/resources/META-INF/citrus/openapi/processor/scenarioRegistrar new file mode 100644 index 000000000..4c6b54b46 --- /dev/null +++ b/simulator-spring-boot/src/main/resources/META-INF/citrus/openapi/processor/scenarioRegistrar @@ -0,0 +1,2 @@ +name=httpOperationScenarioRegistrar +type=org.citrusframework.simulator.http.HttpOperationScenarioRegistrar diff --git a/simulator-spring-boot/src/test/java/org/citrusframework/simulator/http/HttpOperationScenarioIT.java b/simulator-spring-boot/src/test/java/org/citrusframework/simulator/http/HttpOperationScenarioIT.java new file mode 100644 index 000000000..5aff3bdd3 --- /dev/null +++ b/simulator-spring-boot/src/test/java/org/citrusframework/simulator/http/HttpOperationScenarioIT.java @@ -0,0 +1,268 @@ +/* + * Copyright 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.citrusframework.simulator.http; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.params.provider.Arguments.arguments; + +import io.apicurio.datamodels.openapi.models.OasOperation; +import java.io.IOException; +import java.util.List; +import java.util.function.Function; +import java.util.stream.Stream; +import lombok.Getter; +import org.citrusframework.context.SpringBeanReferenceResolver; +import org.citrusframework.context.TestContext; +import org.citrusframework.exceptions.CitrusRuntimeException; +import org.citrusframework.exceptions.TestCaseFailedException; +import org.citrusframework.exceptions.ValidationException; +import org.citrusframework.functions.DefaultFunctionRegistry; +import org.citrusframework.http.actions.HttpServerResponseActionBuilder; +import org.citrusframework.http.message.HttpMessage; +import org.citrusframework.log.DefaultLogModifier; +import org.citrusframework.message.Message; +import org.citrusframework.openapi.OpenApiRepository; +import org.citrusframework.openapi.model.OasModelHelper; +import org.citrusframework.simulator.IntegrationTest; +import org.citrusframework.simulator.http.HttpOperationScenarioIT.HttpOperationScenarioTestConfiguration; +import org.citrusframework.simulator.scenario.ScenarioEndpoint; +import org.citrusframework.simulator.scenario.ScenarioEndpointConfiguration; +import org.citrusframework.simulator.scenario.ScenarioRunner; +import org.citrusframework.spi.Resources.ClasspathResource; +import org.citrusframework.util.FileUtils; +import org.citrusframework.validation.DefaultMessageHeaderValidator; +import org.citrusframework.validation.DefaultMessageValidatorRegistry; +import org.citrusframework.validation.context.HeaderValidationContext; +import org.citrusframework.validation.json.JsonMessageValidationContext; +import org.citrusframework.validation.json.JsonTextMessageValidator; +import org.citrusframework.validation.matcher.DefaultValidationMatcherRegistry; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.beans.factory.support.DefaultListableBeanFactory; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.http.HttpHeaders; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.util.ReflectionTestUtils; + +@IntegrationTest +@ContextConfiguration(classes = HttpOperationScenarioTestConfiguration.class) +class HttpOperationScenarioIT { + + private static final Function IDENTITY = (text) -> text; + + private final DirectScenarioEndpoint scenarioEndpoint = new DirectScenarioEndpoint(); + + private static DefaultListableBeanFactory defaultListableBeanFactory; + + private ScenarioRunner scenarioRunner; + + private TestContext testContext; + + @BeforeEach + void beforeEach(ApplicationContext applicationContext) { + defaultListableBeanFactory = (DefaultListableBeanFactory) ((ConfigurableApplicationContext)applicationContext).getBeanFactory(); + testContext = new TestContext(); + testContext.setReferenceResolver(new SpringBeanReferenceResolver(applicationContext)); + testContext.setMessageValidatorRegistry(new DefaultMessageValidatorRegistry()); + testContext.setFunctionRegistry(new DefaultFunctionRegistry()); + testContext.setValidationMatcherRegistry(new DefaultValidationMatcherRegistry()); + testContext.setLogModifier(new DefaultLogModifier()); + + scenarioRunner = new ScenarioRunner(scenarioEndpoint, applicationContext, testContext); + } + + static Stream scenarioExecution() { + return Stream.of( + arguments("v2_addPet_success", "POST_/api/petstore/v2/pet", "data/addPet.json", IDENTITY, null), + arguments("v3_addPet_success", "POST_/api/petstore/v3/pet", "data/addPet.json", IDENTITY, null), + arguments("v2_addPet_payloadValidationFailure", "POST_/api/petstore/v2/pet", "data/addPet_incorrect.json", IDENTITY, "OpenApi request validation failed for operation: POST_/pet (addPet)\n" + + "\tERROR - Object instance has properties which are not allowed by the schema: [\"wrong_id_property\"]: []"), + arguments("v3_addPet_payloadValidationFailure", "POST_/api/petstore/v3/pet", "data/addPet_incorrect.json", IDENTITY, "OpenApi request validation failed for operation: POST_/pet (addPet)\n" + + "\tERROR - Object instance has properties which are not allowed by the schema: [\"wrong_id_property\"]: []"), + arguments("v2_getPetById_success", "GET_/api/petstore/v2/pet/{petId}", null, (Function)(text) -> text.replace("{petId}", "1234"), null), + arguments("v3_getPetById_success", "GET_/api/petstore/v3/pet/{petId}", null, (Function)(text) -> text.replace("{petId}", "1234"), null), + arguments("v2_getPetById_pathParameterValidationFailure", "GET_/api/petstore/v2/pet/{petId}", null, (Function)(text) -> text.replace("{petId}", "xxxx"), "OpenApi request validation failed for operation: GET_/pet/{petId} (getPetById)\n" + + "\tERROR - Instance type (string) does not match any allowed primitive type (allowed: [\"integer\"]): []"), + arguments("v3_getPetById_pathParameterValidationFailure", "GET_/api/petstore/v3/pet/{petId}", null, (Function)(text) -> text.replace("{petId}", "xxxx"), "OpenApi request validation failed for operation: GET_/pet/{petId} (getPetById)\n" + + "\tERROR - Instance type (string) does not match any allowed primitive type (allowed: [\"integer\"]): []") + ); + } + + @ParameterizedTest(name="{0}") + @MethodSource() + void scenarioExecution(String name, String operationName, String payloadFile, Function urlAdjuster, String exceptionMessage) + throws IOException { + if (defaultListableBeanFactory.containsSingleton("httpResponseActionBuilderProvider")) { + defaultListableBeanFactory.destroySingleton("httpResponseActionBuilderProvider"); + } + + this.scenarioExecution(operationName, payloadFile, urlAdjuster, exceptionMessage, new HttpMessage()); + } + + @ParameterizedTest(name="{0}_custom_payload") + @MethodSource("scenarioExecution") + void scenarioExecutionWithProvider(String name, String operationName, String payloadFile, Function urlAdjuster, String exceptionMessage) { + + String payload = "{\"id\":1234}"; + HttpResponseActionBuilderProvider httpResponseActionBuilderProvider = (scenarioRunner, simulatorScenario, receivedMessage) -> { + HttpServerResponseActionBuilder serverResponseActionBuilder = new HttpServerResponseActionBuilder(); + serverResponseActionBuilder + .endpoint(scenarioEndpoint) + .getMessageBuilderSupport() + .body(payload); + return serverResponseActionBuilder; + }; + + if (!defaultListableBeanFactory.containsSingleton("httpResponseActionBuilderProvider")) { + defaultListableBeanFactory.registerSingleton("httpResponseActionBuilderProvider", + httpResponseActionBuilderProvider); + } + + HttpOperationScenario httpOperationScenario = getHttpOperationScenario(operationName); + try { + ReflectionTestUtils.setField(httpOperationScenario, "httpResponseActionBuilderProvider", + httpResponseActionBuilderProvider); + + HttpMessage correctPayloadMessage = new HttpMessage(payload); + assertThatCode(() -> this.scenarioExecution(operationName, payloadFile, urlAdjuster, exceptionMessage, + correctPayloadMessage)).doesNotThrowAnyException(); + + if (exceptionMessage == null) { + String otherPayload = "{\"id\":12345}"; + HttpMessage incorrectPayloadMessage = new HttpMessage(otherPayload); + assertThatThrownBy( + () -> this.scenarioExecution(operationName, payloadFile, urlAdjuster, + exceptionMessage, + incorrectPayloadMessage)).isInstanceOf(CitrusRuntimeException.class); + } + } finally { + ReflectionTestUtils.setField(httpOperationScenario, "httpResponseActionBuilderProvider", + null); + } + } + + private void scenarioExecution(String operationName, String payloadFile, Function urlAdjuster, String exceptionMessage, Message controlMessage) + throws IOException { + HttpOperationScenario httpOperationScenario = getHttpOperationScenario(operationName); + OasOperation oasOperation = httpOperationScenario.getOperation(); + + String payload = payloadFile != null ? FileUtils.readToString(new ClasspathResource(payloadFile)) : null; + + Message receiveMessage = new HttpMessage() + .setPayload(payload) + .setHeader("citrus_http_request_uri", urlAdjuster.apply(httpOperationScenario.getPath())) + .setHeader("citrus_http_method", httpOperationScenario.getMethod().toUpperCase()); + + OasModelHelper.getRequestContentType(oasOperation) + .ifPresent(contentType -> receiveMessage.setHeader(HttpHeaders.CONTENT_TYPE, contentType)); + + scenarioEndpoint.setReceiveMessage(receiveMessage); + + ReflectionTestUtils.setField(httpOperationScenario, "scenarioEndpoint", + scenarioEndpoint); + + if (exceptionMessage != null) { + assertThatThrownBy(() -> httpOperationScenario.run(scenarioRunner)).isInstanceOf( + TestCaseFailedException.class).cause().isInstanceOf(ValidationException.class).hasMessage(exceptionMessage); + } else { + assertThatCode(() -> httpOperationScenario.run(scenarioRunner)).doesNotThrowAnyException(); + + Message sendMessage = scenarioEndpoint.getSendMessage(); + + JsonTextMessageValidator jsonTextMessageValidator = new JsonTextMessageValidator(); + jsonTextMessageValidator.validateMessage(sendMessage, controlMessage, testContext, + List.of(new JsonMessageValidationContext())); + DefaultMessageHeaderValidator defaultMessageHeaderValidator = new DefaultMessageHeaderValidator(); + defaultMessageHeaderValidator.validateMessage(sendMessage, controlMessage, testContext, List.of(new HeaderValidationContext())); + } + + } + + private HttpOperationScenario getHttpOperationScenario(String operationName) { + Object bean = defaultListableBeanFactory.getBean(operationName); + + assertThat(bean).isInstanceOf(HttpOperationScenario.class); + + return (HttpOperationScenario) bean; + } + + private static class DirectScenarioEndpoint extends ScenarioEndpoint { + + private Message receiveMessage; + + @Getter + private Message sendMessage; + + public DirectScenarioEndpoint() { + super(new ScenarioEndpointConfiguration()); + } + + @Override + public void send(Message message, TestContext context) { + this.sendMessage = new HttpMessage(message); + + if (sendMessage.getPayload() instanceof String stringPayload) { + this.sendMessage.setPayload( + context.replaceDynamicContentInString(stringPayload)); + } + } + + @Override + public Message receive(TestContext context) { + return receiveMessage; + } + + @Override + public Message receive(TestContext context, long timeout) { + return receiveMessage; + } + + public void setReceiveMessage(Message receiveMessage) { + this.receiveMessage = receiveMessage; + } + + } + + @TestConfiguration + public static class HttpOperationScenarioTestConfiguration { + + @Bean + public OpenApiRepository petstoreV3Repository() { + // TODO Document rootContextPath configuration + OpenApiRepository openApiRepository = new OpenApiRepository(); + openApiRepository.setLocations(List.of("swagger/petstore-v3.json")); + openApiRepository.setRootContextPath("/api"); + return openApiRepository; + } + + @Bean + public OpenApiRepository petstoreV2Repository() { + OpenApiRepository openApiRepository = new OpenApiRepository(); + openApiRepository.setLocations(List.of("swagger/petstore-v2.json")); + openApiRepository.setRootContextPath("/api"); + return openApiRepository; + } + + } +} diff --git a/simulator-spring-boot/src/test/java/org/citrusframework/simulator/http/HttpPathSpecificityComparatorTest.java b/simulator-spring-boot/src/test/java/org/citrusframework/simulator/http/HttpPathSpecificityComparatorTest.java new file mode 100644 index 000000000..add1339bc --- /dev/null +++ b/simulator-spring-boot/src/test/java/org/citrusframework/simulator/http/HttpPathSpecificityComparatorTest.java @@ -0,0 +1,50 @@ +package org.citrusframework.simulator.http; + +import java.util.stream.Stream; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.Mockito; + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class HttpPathSpecificityComparatorTest { + + private final HttpPathSpecificityComparator comparator = new HttpPathSpecificityComparator(); + + static Stream pathSpecificityData() { + return Stream.of( + new Object[]{createMockScenario("/path/to/resource/{id}"), createMockScenario("/path/to/resource/a"), 1}, + new Object[]{createMockScenario("/path/to/resource"), createMockScenario("/path/to"), -1}, + new Object[]{createMockScenario("/path/to"), createMockScenario("/path/to/resource"), 1}, + new Object[]{createMockScenario("/path/{variable}/resource"), createMockScenario("/path/to/resource"), 1}, + new Object[]{createMockScenario("/path/to/resource"), createMockScenario("/path/{variable}/resource"), -1}, + new Object[]{createMockScenario("/path/to/resourceA"), createMockScenario("/path/to/resourceB"), -1}, + new Object[]{createMockScenario("/path/to/resource"), createMockScenario("/path/to/resource"), 0}, + new Object[]{createMockScenario(null), createMockScenario("/path/to/resource"), 1}, + new Object[]{createMockScenario("/path/to/resource"), createMockScenario(null), -1}, + new Object[]{createMockScenario(null), createMockScenario(null), 0} + ); + } + + @DisplayName("Should correctly compare HttpScenarios by path specificity") + @ParameterizedTest(name = "{index} => scenario1={0}, scenario2={1}, expected={2}") + @MethodSource("pathSpecificityData") + void testPathSpecificity(HttpScenario scenario1, HttpScenario scenario2, int expected) { + int result = comparator.compare(scenario1, scenario2); + + Assertions.assertThat(result) + .withFailMessage("Expected comparison result %d for paths %s and %s", expected, + scenario1.getPath(), + scenario2.getPath()) + .isEqualTo(expected); + } + + // Helper method to create a mocked HttpScenario + private static HttpScenario createMockScenario(String path) { + HttpScenario mockScenario = Mockito.mock(HttpScenario.class); + Mockito.when(mockScenario.getPath()).thenReturn(path); + return mockScenario; + } +} diff --git a/simulator-spring-boot/src/test/java/org/citrusframework/simulator/http/HttpRequestPathScenarioMapperTest.java b/simulator-spring-boot/src/test/java/org/citrusframework/simulator/http/HttpRequestPathScenarioMapperTest.java index 6d4c69e54..75a36b675 100644 --- a/simulator-spring-boot/src/test/java/org/citrusframework/simulator/http/HttpRequestPathScenarioMapperTest.java +++ b/simulator-spring-boot/src/test/java/org/citrusframework/simulator/http/HttpRequestPathScenarioMapperTest.java @@ -16,33 +16,51 @@ package org.citrusframework.simulator.http; -import io.swagger.models.Operation; +import static org.assertj.core.api.Assertions.fail; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; + +import io.apicurio.datamodels.openapi.models.OasDocument; +import io.apicurio.datamodels.openapi.models.OasOperation; +import io.apicurio.datamodels.openapi.v2.models.Oas20Document; +import io.apicurio.datamodels.openapi.v2.models.Oas20Operation; +import io.apicurio.datamodels.openapi.v3.models.Oas30Document; +import io.apicurio.datamodels.openapi.v3.models.Oas30Operation; +import java.util.Arrays; import org.citrusframework.exceptions.CitrusRuntimeException; import org.citrusframework.http.message.HttpMessage; +import org.citrusframework.openapi.OpenApiSpecification; import org.citrusframework.simulator.config.SimulatorConfigurationProperties; import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.mockito.Mock; -import org.mockito.Mockito; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.http.HttpMethod; import org.springframework.web.bind.annotation.RequestMethod; -import java.util.Arrays; -import java.util.Collections; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.mockito.Mockito.doReturn; -import static org.mockito.Mockito.when; - /** * @author Christoph Deppisch */ @ExtendWith(MockitoExtension.class) class HttpRequestPathScenarioMapperTest { + public static final String DEFAULT_SCENARIO = "default"; + public static final String FOO_LIST_SCENARIO = "fooListScenario"; + public static final String FOO_LIST_POST_SCENARIO = "fooListPostScenario"; + public static final String BAR_LIST_SCENARIO = "barListScenario"; + public static final String FOO_SCENARIO = "fooScenario"; + public static final String ISSUE_SCENARIO = "issueScenario"; + public static final String BAR_SCENARIO = "barScenario"; + public static final String FOO_DETAIL_SCENARIO = "fooDetailScenario"; + public static final String BAR_DETAIL_SCENARIO = "barDetailScenario"; + + public static final String FOOBAR_GET_SCENARIO = "foobarGetScenario"; + public static final String FOOBAR_POST_SCENARIO = "foobarPostScenario"; + @Mock private SimulatorConfigurationProperties simulatorConfigurationMock; @@ -53,42 +71,75 @@ void beforeEachSetup() { fixture = new HttpRequestPathScenarioMapper(); fixture.setConfiguration(simulatorConfigurationMock); - doReturn("default").when(simulatorConfigurationMock).getDefaultScenario(); + doReturn(DEFAULT_SCENARIO).when(simulatorConfigurationMock).getDefaultScenario(); } - @Test - void testGetMappingKey() { - Operation operation = Mockito.mock(Operation.class); - - fixture.setScenarioList(Arrays.asList(new HttpOperationScenario("/issues/foos", RequestMethod.GET, operation, Collections.emptyMap()), - new HttpOperationScenario("/issues/foos", RequestMethod.POST, operation, Collections.emptyMap()), - new HttpOperationScenario("/issues/foo/{id}", RequestMethod.GET, operation, Collections.emptyMap()), - new HttpOperationScenario("/issues/foo/detail", RequestMethod.GET, operation, Collections.emptyMap()), - new HttpOperationScenario("/issues/bars", RequestMethod.GET, operation, Collections.emptyMap()), - new HttpOperationScenario("/issues/bar/{id}", RequestMethod.GET, operation, Collections.emptyMap()), - new HttpOperationScenario("/issues/bar/detail", RequestMethod.GET, operation, Collections.emptyMap()))); - - when(operation.getOperationId()) - .thenReturn("fooListScenario") - .thenReturn("fooListPostScenario") - .thenReturn("barListScenario") - .thenReturn("fooScenario") - .thenReturn("barScenario") - .thenReturn("fooDetailScenario") - .thenReturn("barDetailScenario"); - - assertEquals(fixture.getMappingKey(new HttpMessage().method(HttpMethod.GET)), "default"); - assertEquals(fixture.getMappingKey(new HttpMessage().method(HttpMethod.POST)), "default"); - assertEquals(fixture.getMappingKey(new HttpMessage().method(HttpMethod.GET).path("/issues")), "default"); - assertEquals(fixture.getMappingKey(new HttpMessage().method(HttpMethod.GET).path("/issues/foos")), "fooListScenario"); - assertEquals(fixture.getMappingKey(new HttpMessage().method(HttpMethod.POST).path("/issues/foos")), "fooListPostScenario"); - assertEquals(fixture.getMappingKey(new HttpMessage().method(HttpMethod.PUT).path("/issues/foos")), "default"); - assertEquals(fixture.getMappingKey(new HttpMessage().method(HttpMethod.GET).path("/issues/bars")), "barListScenario"); - assertEquals(fixture.getMappingKey(new HttpMessage().method(HttpMethod.DELETE).path("/issues/bars")), "default"); - assertEquals(fixture.getMappingKey(new HttpMessage().method(HttpMethod.GET).path("/issues/foo/1")), "fooScenario"); - assertEquals(fixture.getMappingKey(new HttpMessage().method(HttpMethod.GET).path("/issues/bar/1")), "barScenario"); - assertEquals(fixture.getMappingKey(new HttpMessage().method(HttpMethod.GET).path("/issues/foo/detail")), "fooDetailScenario"); - assertEquals(fixture.getMappingKey(new HttpMessage().method(HttpMethod.GET).path("/issues/bar/detail")), "barDetailScenario"); + @ParameterizedTest + @ValueSource(strings = {"oas2", "oas3"}) + void testGetMappingKey(String version) { + OpenApiSpecification openApiSpecificationMock = mock(); + + OasDocument oasDocument = null; + if ("oas2".equals(version)) { + oasDocument = mock(Oas20Document.class); + } else if ("oas3".equals(version)) { + oasDocument = mock(Oas30Document.class); + } else { + fail("Unexpected version: "+ version); + } + + HttpScenario foobarGetScenarioMock = mockHttpScenario("GET", "/issues/foobar", FOOBAR_GET_SCENARIO); + HttpScenario foobarPostScenarioMock = mockHttpScenario("POST", "/issues/foobar", FOOBAR_POST_SCENARIO); + + fixture.setScenarioList(Arrays.asList(new HttpOperationScenario("/issues/foos", + FOO_LIST_SCENARIO, openApiSpecificationMock, mockOperation(oasDocument, RequestMethod.GET), null), + + new HttpOperationScenario("/issues/foos", FOO_LIST_POST_SCENARIO, openApiSpecificationMock, mockOperation(oasDocument, RequestMethod.POST), null), + new HttpOperationScenario("/issues/foo/{id}", FOO_SCENARIO, openApiSpecificationMock, mockOperation(oasDocument, RequestMethod.GET), null), + new HttpOperationScenario("/issues/foo/detail", FOO_DETAIL_SCENARIO, openApiSpecificationMock, mockOperation(oasDocument, RequestMethod.GET), null), + new HttpOperationScenario("/issues/bars", BAR_LIST_SCENARIO, openApiSpecificationMock, mockOperation(oasDocument, RequestMethod.GET), null), + new HttpOperationScenario("/issues/{bar}/{id}", ISSUE_SCENARIO, openApiSpecificationMock, mockOperation(oasDocument, RequestMethod.GET), null), + new HttpOperationScenario("/issues/bar/{id}", BAR_SCENARIO, openApiSpecificationMock, mockOperation(oasDocument, RequestMethod.GET), null), + new HttpOperationScenario("/issues/bar/detail", BAR_DETAIL_SCENARIO, openApiSpecificationMock, mockOperation(oasDocument, RequestMethod.GET), null), + foobarGetScenarioMock, + foobarPostScenarioMock) + ); + + assertEquals(DEFAULT_SCENARIO, + fixture.getMappingKey(new HttpMessage().method(HttpMethod.GET))); + assertEquals(DEFAULT_SCENARIO, + fixture.getMappingKey(new HttpMessage().method(HttpMethod.POST))); + assertEquals(DEFAULT_SCENARIO, + fixture.getMappingKey(new HttpMessage().method(HttpMethod.GET).path("/issues"))); + assertEquals(FOO_LIST_SCENARIO, + fixture.getMappingKey(new HttpMessage().method(HttpMethod.GET).path("/issues/foos"))); + assertEquals(FOO_LIST_POST_SCENARIO, + fixture.getMappingKey(new HttpMessage().method(HttpMethod.POST).path("/issues/foos"))); + assertEquals(DEFAULT_SCENARIO, + fixture.getMappingKey(new HttpMessage().method(HttpMethod.PUT).path("/issues/foos"))); + assertEquals(BAR_LIST_SCENARIO, + fixture.getMappingKey(new HttpMessage().method(HttpMethod.GET).path("/issues/bars"))); + assertEquals(DEFAULT_SCENARIO, + fixture.getMappingKey(new HttpMessage().method(HttpMethod.DELETE).path("/issues/bars"))); + assertEquals(FOO_SCENARIO, + fixture.getMappingKey(new HttpMessage().method(HttpMethod.GET).path("/issues/foo/1"))); + assertEquals(BAR_SCENARIO, + fixture.getMappingKey(new HttpMessage().method(HttpMethod.GET).path("/issues/bar/1"))); + assertEquals(FOO_DETAIL_SCENARIO, + fixture.getMappingKey( + new HttpMessage().method(HttpMethod.GET).path("/issues/foo/detail"))); + assertEquals(BAR_DETAIL_SCENARIO, + fixture.getMappingKey( + new HttpMessage().method(HttpMethod.GET).path("/issues/bar/detail"))); + assertEquals(FOOBAR_GET_SCENARIO, + fixture.getMappingKey( + new HttpMessage().method(HttpMethod.GET).path("/issues/foobar"))); + assertEquals(FOOBAR_POST_SCENARIO, + fixture.getMappingKey( + new HttpMessage().method(HttpMethod.POST).path("/issues/foobar"))); + assertEquals(ISSUE_SCENARIO, + fixture.getMappingKey( + new HttpMessage().method(HttpMethod.GET).path("/issues/1/2"))); fixture.setUseDefaultMapping(false); @@ -98,4 +149,27 @@ void testGetMappingKey() { HttpMessage httpGetIssuesMessage = new HttpMessage().method(HttpMethod.GET).path("/issues"); assertThrows(CitrusRuntimeException.class, () -> fixture.getMappingKey(httpGetIssuesMessage)); } + + private HttpScenario mockHttpScenario(String method, String path, String scenarioId) { + HttpScenario httpScenario = mock(); + doReturn(method).when(httpScenario).getMethod(); + doReturn(path).when(httpScenario).getPath(); + doReturn(scenarioId).when(httpScenario).getScenarioId(); + return httpScenario; + } + + private OasOperation mockOperation(OasDocument oasDocument, RequestMethod requestMethod) { + + OasOperation oasOperationMock = null; + if (oasDocument instanceof Oas20Document) { + oasOperationMock = mock(Oas20Operation.class); + } else if (oasDocument instanceof Oas30Document) { + oasOperationMock = mock(Oas30Operation.class); + } else { + fail("Unexpected version document type!"); + } + doReturn(requestMethod.toString()).when(oasOperationMock).getMethod(); + return oasOperationMock; + } + } diff --git a/simulator-spring-boot/src/test/java/org/citrusframework/simulator/http/HttpScenarioGeneratorTest.java b/simulator-spring-boot/src/test/java/org/citrusframework/simulator/http/HttpScenarioGeneratorTest.java index a13140bd7..95685ca2f 100644 --- a/simulator-spring-boot/src/test/java/org/citrusframework/simulator/http/HttpScenarioGeneratorTest.java +++ b/simulator-spring-boot/src/test/java/org/citrusframework/simulator/http/HttpScenarioGeneratorTest.java @@ -16,175 +16,197 @@ package org.citrusframework.simulator.http; -import io.swagger.models.Operation; -import org.citrusframework.spi.Resources; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.beans.factory.config.BeanDefinition; -import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; -import org.springframework.beans.factory.support.DefaultListableBeanFactory; -import org.springframework.web.bind.annotation.RequestMethod; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertNull; +import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; +import io.apicurio.datamodels.openapi.models.OasOperation; +import org.citrusframework.openapi.OpenApiSpecification; +import org.citrusframework.spi.Resources.ClasspathResource; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.BeanFactory; +import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; +import org.springframework.beans.factory.config.ConstructorArgumentValues.ValueHolder; +import org.springframework.beans.factory.support.DefaultListableBeanFactory; + /** * @author Christoph Deppisch */ @ExtendWith(MockitoExtension.class) class HttpScenarioGeneratorTest { - @Mock - private ConfigurableListableBeanFactory beanFactoryMock; + private HttpScenarioGenerator fixture; - @Mock - private DefaultListableBeanFactory beanRegistryMock; + @ParameterizedTest + @ValueSource(strings={"v2", "v3"}) + void generateHttpScenarios(String version) { + ConfigurableListableBeanFactory beanFactoryMock = mock(); - private HttpScenarioGenerator fixture; + mockBeanFactory(beanFactoryMock); - @BeforeEach - void beforeEachSetup() { - fixture = new HttpScenarioGenerator(new Resources.ClasspathResource("swagger/swagger-api.json")); - } + OpenApiSpecification openApiSpecification = createOpenApiSpecification(version); + fixture = new HttpScenarioGenerator(openApiSpecification); + + String addPetScenarioId = "POST_/api/petstore/"+version+"/pet"; + String getPetScenarioId = "GET_/api/petstore/"+version+"/pet/{petId}"; + String deletePetScenarioId = "DELETE_/api/petstore/"+version+"/pet/{petId}"; - @Test - void generateHttpScenarios() { + String context = "/api/petstore/"+ version ; doAnswer(invocation -> { HttpOperationScenario scenario = (HttpOperationScenario) invocation.getArguments()[1]; - - assertNotNull(scenario.getOperation()); - assertEquals(scenario.getPath(), "/v2/pet"); - assertEquals(scenario.getMethod(), RequestMethod.POST); - + assertScenarioProperties(scenario, context+"/pet", addPetScenarioId, "POST"); return null; - }).when(beanFactoryMock).registerSingleton(eq("addPet"), any(HttpOperationScenario.class)); + }).when(beanFactoryMock).registerSingleton(eq(addPetScenarioId), any(HttpOperationScenario.class)); doAnswer(invocation -> { HttpOperationScenario scenario = (HttpOperationScenario) invocation.getArguments()[1]; - - assertNotNull(scenario.getOperation()); - assertEquals(scenario.getPath(), "/v2/pet/{petId}"); - assertEquals(scenario.getMethod(), RequestMethod.GET); - + assertScenarioProperties(scenario, context+"/pet/{petId}", getPetScenarioId, "GET"); return null; - }).when(beanFactoryMock).registerSingleton(eq("getPetById"), any(HttpOperationScenario.class)); + }).when(beanFactoryMock).registerSingleton(eq(getPetScenarioId), any(HttpOperationScenario.class)); doAnswer(invocation -> { HttpOperationScenario scenario = (HttpOperationScenario) invocation.getArguments()[1]; - - assertNotNull(scenario.getOperation()); - assertEquals(scenario.getPath(), "/v2/pet/{petId}"); - assertEquals(scenario.getMethod(), RequestMethod.DELETE); - + assertScenarioProperties(scenario, context+"/pet/{petId}", deletePetScenarioId, "DELETE"); return null; - }).when(beanFactoryMock).registerSingleton(eq("deletePet"), any(HttpOperationScenario.class)); + }).when(beanFactoryMock).registerSingleton(eq(deletePetScenarioId), any(HttpOperationScenario.class)); fixture.postProcessBeanFactory(beanFactoryMock); - verify(beanFactoryMock).registerSingleton(eq("addPet"), any(HttpOperationScenario.class)); - verify(beanFactoryMock).registerSingleton(eq("getPetById"), any(HttpOperationScenario.class)); - verify(beanFactoryMock).registerSingleton(eq("deletePet"), any(HttpOperationScenario.class)); + verify(beanFactoryMock).registerSingleton(eq(addPetScenarioId), any(HttpOperationScenario.class)); + verify(beanFactoryMock).registerSingleton(eq(getPetScenarioId), any(HttpOperationScenario.class)); + verify(beanFactoryMock).registerSingleton(eq(deletePetScenarioId), any(HttpOperationScenario.class)); } - @Test - void testGenerateScenariosWithBeandDefinitionRegistry() { - doAnswer(invocation -> { - BeanDefinition scenario = (BeanDefinition) invocation.getArguments()[1]; + private static OpenApiSpecification createOpenApiSpecification(String version) { + OpenApiSpecification openApiSpecification = OpenApiSpecification.from(new ClasspathResource( + "swagger/petstore-" + version + ".json")); + openApiSpecification.setRootContextPath("/api"); + return openApiSpecification; + } - assertEquals(scenario.getConstructorArgumentValues().getArgumentValue(0, String.class).getValue(), "/v2/pet"); - assertEquals(scenario.getConstructorArgumentValues().getArgumentValue(1, RequestMethod.class).getValue(), RequestMethod.POST); - assertNotNull(scenario.getConstructorArgumentValues().getArgumentValue(2, Operation.class).getValue()); - assertNull(scenario.getPropertyValues().get("inboundDataDictionary")); - assertNull(scenario.getPropertyValues().get("outboundDataDictionary")); + @ParameterizedTest + @ValueSource(strings={"v2", "v3"}) + void testGenerateScenariosWithBeanDefinitionRegistry(String version) { - return null; - }).when(beanRegistryMock).registerBeanDefinition(eq("addPet"), any(BeanDefinition.class)); + DefaultListableBeanFactory beanRegistryMock = mock(); + mockBeanFactory(beanRegistryMock); - doAnswer(invocation -> { - BeanDefinition scenario = (BeanDefinition) invocation.getArguments()[1]; + OpenApiSpecification openApiSpecification = createOpenApiSpecification(version); + fixture = new HttpScenarioGenerator(openApiSpecification); - assertEquals(scenario.getConstructorArgumentValues().getArgumentValue(0, String.class).getValue(), "/v2/pet/{petId}"); - assertEquals(scenario.getConstructorArgumentValues().getArgumentValue(1, RequestMethod.class).getValue(), RequestMethod.GET); - assertNotNull(scenario.getConstructorArgumentValues().getArgumentValue(2, Operation.class).getValue()); - assertNull(scenario.getPropertyValues().get("inboundDataDictionary")); - assertNull(scenario.getPropertyValues().get("outboundDataDictionary")); + String context = openApiSpecification.getFullContextPath(); + doAnswer(invocation -> { + BeanDefinition scenario = (BeanDefinition) invocation.getArguments()[1]; + assertBeanDefinition(scenario, context+"/pet", "POST_/api/petstore/"+version+"/pet", "post", false); return null; - }).when(beanRegistryMock).registerBeanDefinition(eq("getPetById"), any(BeanDefinition.class)); + }).when(beanRegistryMock).registerBeanDefinition(eq("POST_/api/petstore/"+version+"/pet"), any(BeanDefinition.class)); doAnswer(invocation -> { BeanDefinition scenario = (BeanDefinition) invocation.getArguments()[1]; + assertBeanDefinition(scenario, context+"/pet/{petId}", "GET_/api/petstore/"+version+"/pet/{petId}", "get", false); + return null; + }).when(beanRegistryMock).registerBeanDefinition(eq("GET_/api/petstore/"+version+"/pet/{petId}"), any(BeanDefinition.class)); - assertEquals(scenario.getConstructorArgumentValues().getArgumentValue(0, String.class).getValue(), "/v2/pet/{petId}"); - assertEquals(scenario.getConstructorArgumentValues().getArgumentValue(1, RequestMethod.class).getValue(), RequestMethod.DELETE); - assertNotNull(scenario.getConstructorArgumentValues().getArgumentValue(2, Operation.class).getValue()); - assertNull(scenario.getPropertyValues().get("inboundDataDictionary")); - assertNull(scenario.getPropertyValues().get("outboundDataDictionary")); - + doAnswer(invocation -> { + BeanDefinition scenario = (BeanDefinition) invocation.getArguments()[1]; + assertBeanDefinition(scenario, context+"/pet/{petId}", "DELETE_/api/petstore/"+version+"/pet/{petId}", "delete", false); return null; - }).when(beanRegistryMock).registerBeanDefinition(eq("deletePet"), any(BeanDefinition.class)); + }).when(beanRegistryMock).registerBeanDefinition(eq("DELETE_/api/petstore/"+version+"/pet/{petId}"), any(BeanDefinition.class)); fixture.postProcessBeanFactory(beanRegistryMock); - verify(beanRegistryMock).registerBeanDefinition(eq("addPet"), any(BeanDefinition.class)); - verify(beanRegistryMock).registerBeanDefinition(eq("getPetById"), any(BeanDefinition.class)); - verify(beanRegistryMock).registerBeanDefinition(eq("deletePet"), any(BeanDefinition.class)); + verify(beanRegistryMock).registerBeanDefinition(eq("POST_/api/petstore/"+version+"/pet"), any(BeanDefinition.class)); + verify(beanRegistryMock).registerBeanDefinition(eq("GET_/api/petstore/"+version+"/pet/{petId}"), any(BeanDefinition.class)); + verify(beanRegistryMock).registerBeanDefinition(eq("DELETE_/api/petstore/"+version+"/pet/{petId}"), any(BeanDefinition.class)); } - @Test - void testGenerateScenariosWithDataDictionaries() { + @ParameterizedTest + @ValueSource(strings={"v2", "v3"}) + void testGenerateScenariosWithDataDictionariesAtRootContext(String version) { + DefaultListableBeanFactory beanRegistryMock = mock(); + mockBeanFactory(beanRegistryMock); + + OpenApiSpecification openApiSpecification = createOpenApiSpecification(version); + openApiSpecification.setRootContextPath("/services/rest2"); + + fixture = new HttpScenarioGenerator(openApiSpecification); + + String addPetScenarioId = "POST_/services/rest2/petstore/"+version+"/pet"; + String getPetScenarioId = "GET_/services/rest2/petstore/"+version+"/pet/{petId}"; + String deletePetScenarioId = "DELETE_/services/rest2/petstore/"+version+"/pet/{petId}"; + + String context = openApiSpecification.getFullContextPath(); + doReturn(true).when(beanRegistryMock).containsBeanDefinition("inboundJsonDataDictionary"); doReturn(true).when(beanRegistryMock).containsBeanDefinition("outboundJsonDataDictionary"); doAnswer(invocation -> { BeanDefinition scenario = (BeanDefinition) invocation.getArguments()[1]; - - assertEquals(scenario.getConstructorArgumentValues().getArgumentValue(0, String.class).getValue(), "/v2/pet"); - assertEquals(scenario.getConstructorArgumentValues().getArgumentValue(1, RequestMethod.class).getValue(), RequestMethod.POST); - assertNotNull(scenario.getConstructorArgumentValues().getArgumentValue(2, Operation.class).getValue()); - assertNotNull(scenario.getPropertyValues().get("inboundDataDictionary")); - assertNotNull(scenario.getPropertyValues().get("outboundDataDictionary")); - + assertBeanDefinition(scenario, context+"/pet", addPetScenarioId, "post", true); return null; - }).when(beanRegistryMock).registerBeanDefinition(eq("addPet"), any(BeanDefinition.class)); + }).when(beanRegistryMock).registerBeanDefinition(eq(addPetScenarioId), any(BeanDefinition.class)); doAnswer(invocation -> { BeanDefinition scenario = (BeanDefinition) invocation.getArguments()[1]; - - assertEquals(scenario.getConstructorArgumentValues().getArgumentValue(0, String.class).getValue(), "/v2/pet/{petId}"); - assertEquals(scenario.getConstructorArgumentValues().getArgumentValue(1, RequestMethod.class).getValue(), RequestMethod.GET); - assertNotNull(scenario.getConstructorArgumentValues().getArgumentValue(2, Operation.class).getValue()); - assertNotNull(scenario.getPropertyValues().get("inboundDataDictionary")); - assertNotNull(scenario.getPropertyValues().get("outboundDataDictionary")); - + assertBeanDefinition(scenario, context+"/pet/{petId}", getPetScenarioId, "get", true); return null; - }).when(beanRegistryMock).registerBeanDefinition(eq("getPetById"), any(BeanDefinition.class)); + }).when(beanRegistryMock).registerBeanDefinition(eq(getPetScenarioId), any(BeanDefinition.class)); doAnswer(invocation -> { BeanDefinition scenario = (BeanDefinition) invocation.getArguments()[1]; - - assertEquals(scenario.getConstructorArgumentValues().getArgumentValue(0, String.class).getValue(), "/v2/pet/{petId}"); - assertEquals(scenario.getConstructorArgumentValues().getArgumentValue(1, RequestMethod.class).getValue(), RequestMethod.DELETE); - assertNotNull(scenario.getConstructorArgumentValues().getArgumentValue(2, Operation.class).getValue()); - assertNotNull(scenario.getPropertyValues().get("inboundDataDictionary")); - assertNotNull(scenario.getPropertyValues().get("outboundDataDictionary")); - + assertBeanDefinition(scenario, context+"/pet/{petId}",deletePetScenarioId,"delete", true); return null; - }).when(beanRegistryMock).registerBeanDefinition(eq("deletePet"), any(BeanDefinition.class)); + }).when(beanRegistryMock).registerBeanDefinition(eq(deletePetScenarioId), any(BeanDefinition.class)); fixture.postProcessBeanFactory(beanRegistryMock); - verify(beanRegistryMock).registerBeanDefinition(eq("addPet"), any(BeanDefinition.class)); - verify(beanRegistryMock).registerBeanDefinition(eq("getPetById"), any(BeanDefinition.class)); - verify(beanRegistryMock).registerBeanDefinition(eq("deletePet"), any(BeanDefinition.class)); + verify(beanRegistryMock).registerBeanDefinition(eq(addPetScenarioId), any(BeanDefinition.class)); + verify(beanRegistryMock).registerBeanDefinition(eq(getPetScenarioId), any(BeanDefinition.class)); + verify(beanRegistryMock).registerBeanDefinition(eq(deletePetScenarioId), any(BeanDefinition.class)); + } + + private void mockBeanFactory(BeanFactory beanFactory) { + doThrow(new BeansException("No such bean") { + }).when(beanFactory).getBean(HttpResponseActionBuilderProvider.class); + } + + private void assertBeanDefinition(BeanDefinition scenario, String path, String scenarioId, String method, boolean withDictionaries) { + assertThat(getConstructorArgument(scenario, 0)).isEqualTo( path); + assertThat(getConstructorArgument(scenario, 1)).isEqualTo( scenarioId); + assertThat(getConstructorArgument(scenario, 2)).isInstanceOf(OpenApiSpecification.class); + assertThat(getConstructorArgument(scenario, 3)).isInstanceOf(OasOperation.class); + assertThat(((OasOperation)getConstructorArgument(scenario, 3)).getMethod()).isEqualTo(method); + + if (withDictionaries) { + assertThat(scenario.getPropertyValues().get("inboundDataDictionary")).isNotNull(); + assertThat(scenario.getPropertyValues().get("outboundDataDictionary")).isNotNull(); + } else { + assertThat(scenario.getPropertyValues().get("inboundDataDictionary")).isNull(); + assertThat(scenario.getPropertyValues().get("outboundDataDictionary")).isNull(); + } + } + + private static Object getConstructorArgument(BeanDefinition scenario, int index) { + ValueHolder argumentValue = scenario.getConstructorArgumentValues() + .getArgumentValue(index, String.class); + assertThat(argumentValue).isNotNull(); + return argumentValue.getValue(); + } + + private void assertScenarioProperties(HttpOperationScenario scenario, String path, String operationId, String method) { + assertThat(scenario).extracting(HttpOperationScenario::getPath, HttpOperationScenario::getScenarioId).containsExactly(path, operationId); + assertThat(scenario.getMethod()).isEqualTo(method); + assertThat(scenario.getOperation()).isNotNull().extracting(OasOperation::getMethod).isEqualTo(method.toLowerCase()); } } diff --git a/simulator-spring-boot/src/test/resources/data/addPet.json b/simulator-spring-boot/src/test/resources/data/addPet.json new file mode 100644 index 000000000..fae210be0 --- /dev/null +++ b/simulator-spring-boot/src/test/resources/data/addPet.json @@ -0,0 +1,15 @@ +{ + "id": 0, + "category": { + "id": 0, + "name": "string" + }, + "name": "doggie", + "photoUrls": [ + "string" + ], + "tags": [ + {} + ], + "status": "available" +} diff --git a/simulator-spring-boot/src/test/resources/data/addPet_incorrect.json b/simulator-spring-boot/src/test/resources/data/addPet_incorrect.json new file mode 100644 index 000000000..0e4b8f388 --- /dev/null +++ b/simulator-spring-boot/src/test/resources/data/addPet_incorrect.json @@ -0,0 +1,15 @@ +{ + "wrong_id_property": 0, + "category": { + "id": 0, + "name": "string" + }, + "name": "doggie", + "photoUrls": [ + "string" + ], + "tags": [ + {} + ], + "status": "available" +} diff --git a/simulator-spring-boot/src/test/resources/swagger/swagger-api.json b/simulator-spring-boot/src/test/resources/swagger/petstore-v2.json similarity index 99% rename from simulator-spring-boot/src/test/resources/swagger/swagger-api.json rename to simulator-spring-boot/src/test/resources/swagger/petstore-v2.json index bfd4ee2fb..f1012b5c4 100644 --- a/simulator-spring-boot/src/test/resources/swagger/swagger-api.json +++ b/simulator-spring-boot/src/test/resources/swagger/petstore-v2.json @@ -1,5 +1,5 @@ { - "swagger": "3.0.3", + "swagger": "2.0", "info": { "version": "1.0.0", "title": "Swagger Petstore", @@ -9,7 +9,7 @@ } }, "host": "localhost", - "basePath": "/v2", + "basePath": "/petstore/v2", "schemes": [ "http" ], @@ -281,4 +281,4 @@ } } } -} \ No newline at end of file +} diff --git a/simulator-spring-boot/src/test/resources/swagger/petstore-v3.json b/simulator-spring-boot/src/test/resources/swagger/petstore-v3.json new file mode 100644 index 000000000..6c2b5bdfb --- /dev/null +++ b/simulator-spring-boot/src/test/resources/swagger/petstore-v3.json @@ -0,0 +1,254 @@ +{ + "openapi": "3.0.2", + "info": { + "title": "Swagger Petstore", + "version": "1.0.1", + "description": "This is a sample server Petstore server.", + "license": { + "name": "Apache 2.0", + "url": "http://www.apache.org/licenses/LICENSE-2.0.html" + } + }, + "servers": [ + { + "url": "http://localhost/petstore/v3" + } + ], + "paths": { + "/pet": { + "post": { + "requestBody": { + "description": "Pet object that needs to be added to the store", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + } + }, + "required": true + }, + "tags": [ + "pet" + ], + "responses": { + "201": { + "description": "Created" + }, + "405": { + "description": "Invalid input" + } + }, + "operationId": "addPet", + "summary": "Add a new pet to the store", + "description": "" + } + }, + "/pet/{petId}": { + "get": { + "tags": [ + "pet" + ], + "parameters": [ + { + "name": "petId", + "description": "ID of pet to return", + "schema": { + "format": "int64", + "type": "integer" + }, + "in": "path", + "required": true + }, + { + "name": "verbose", + "description": "Output details", + "schema": { + "type": "boolean" + }, + "in": "query", + "required": false + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + } + }, + "description": "successful operation" + }, + "400": { + "description": "Invalid ID supplied" + }, + "404": { + "description": "Pet not found" + } + }, + "operationId": "getPetById", + "summary": "Find pet by ID", + "description": "Returns a single pet" + }, + "delete": { + "tags": [ + "pet" + ], + "parameters": [ + { + "name": "api_key", + "schema": { + "type": "string" + }, + "in": "header", + "required": false + }, + { + "name": "petId", + "description": "Pet id to delete", + "schema": { + "format": "int64", + "type": "integer" + }, + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No content" + }, + "400": { + "description": "Invalid ID supplied" + }, + "404": { + "description": "Pet not found" + } + }, + "operationId": "deletePet", + "summary": "Deletes a pet", + "description": "" + } + } + }, + "components": { + "schemas": { + "Category": { + "type": "object", + "properties": { + "id": { + "format": "int64", + "type": "integer" + }, + "name": { + "type": "string" + } + }, + "xml": { + "name": "Category" + } + }, + "Tag": { + "type": "object", + "properties": { + "id": { + "format": "int64", + "type": "integer" + }, + "name": { + "type": "string" + } + }, + "xml": { + "name": "Tag" + } + }, + "Pet": { + "required": [ + "category", + "name", + "status" + ], + "type": "object", + "properties": { + "id": { + "format": "int64", + "type": "integer" + }, + "category": { + "$ref": "#/components/schemas/Category" + }, + "name": { + "type": "string", + "example": "doggie" + }, + "photoUrls": { + "type": "array", + "items": { + "type": "string" + }, + "xml": { + "name": "photoUrl", + "wrapped": true + } + }, + "tags": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Tag" + }, + "xml": { + "name": "tag", + "wrapped": true + } + }, + "status": { + "description": "pet status in the store", + "enum": [ + "available", + "pending", + "sold" + ], + "type": "string" + } + }, + "xml": { + "name": "Pet" + } + }, + "ApiResponse": { + "type": "object", + "properties": { + "code": { + "format": "int32", + "type": "integer" + }, + "type": { + "type": "string" + }, + "message": { + "type": "string" + } + } + } + } + }, + "tags": [ + { + "name": "pet", + "description": "Everything about your Pets" + } + ] +} diff --git a/simulator-ui/.npmrc b/simulator-ui/.npmrc deleted file mode 100644 index b6f27f135..000000000 --- a/simulator-ui/.npmrc +++ /dev/null @@ -1 +0,0 @@ -engine-strict=true