From a75b86df83257632d093229fed4dc7053db878a5 Mon Sep 17 00:00:00 2001 From: Thorsten Schlathoelter Date: Fri, 21 Jun 2024 16:33:20 +0200 Subject: [PATCH] feat(#285): implement validation and refine scenario generation --- .../simulator/sample/Simulator.java | 38 +- .../SpecificPingResponseMessageBuilder.java | 121 ++ .../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 | 162 ++- .../citrusframework/simulator/OpenApiIT.java | 336 +++++ .../simulator/ResponseGeneratorIT.java | 187 +++ .../simulator/SimulatorSwaggerIT.java | 150 +- .../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 + .../SimulatorConfigurationProperties.java | 23 +- .../simulator/http/HttpOperationScenario.java | 68 +- .../http/HttpOperationScenarioRegistrar.java | 35 +- .../HttpResponseActionBuilderProvider.java | 24 +- .../simulator/http/HttpScenarioGenerator.java | 157 ++- .../SimulatorRestConfigurationProperties.java | 2 +- .../http/HttpOperationScenarioIT.java | 93 +- .../HttpRequestPathScenarioMapperTest.java | 2 - 22 files changed, 2532 insertions(+), 240 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 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..12f10849b 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,31 @@ protected Message handleMessageInternal(Message message) { @Bean public static HttpScenarioGenerator scenarioGenerator() { - HttpScenarioGenerator generator = new HttpScenarioGenerator(new Resources.ClasspathResource("swagger/petstore-api.json")); + HttpScenarioGenerator generator = new HttpScenarioGenerator( + new Resources.ClasspathResource("swagger/petstore-api.json")); generator.setContextPath("/petstore"); return generator; } + + @Bean + public static OpenApiRepository petstoreRepository() { + OpenApiRepository openApiRepository = new OpenApiRepository(); + openApiRepository.setRootContextPath("/petstore"); + openApiRepository.setLocations(List.of("openapi/*.json")); + return openApiRepository; + } + + @Bean + public static OpenApiRepository pingRepository() { + OpenApiRepository openApiRepository = new OpenApiRepository(); + openApiRepository.setLocations(List.of("openapi/*.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..f74f25501 --- /dev/null +++ b/simulator-samples/sample-swagger/src/main/java/org/citrusframework/simulator/sample/SpecificPingResponseMessageBuilder.java @@ -0,0 +1,121 @@ +package org.citrusframework.simulator.sample; + +import static java.lang.String.format; + +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.OpenApiServerActionBuilder; +import org.citrusframework.openapi.actions.OpenApiServerResponseActionBuilder; +import org.citrusframework.simulator.http.HttpResponseActionBuilderProvider; +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; + + private static final TriFunction NULL_RESPONSE = SpecificPingResponseMessageBuilder::createNull; + private static final Map> SPECIFC_BUILDER_MAP = new HashMap<>(); + + // Specific responses for some ids, all others will be handled by the random generator + 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( + OpenApiServerActionBuilder openApiServerActionBuilder, OasOperation oasOperation, + HttpMessage receivedMessage) { + return SPECIFC_BUILDER_MAP.getOrDefault(getIdFromPingRequest(receivedMessage), NULL_RESPONSE).apply(openApiServerActionBuilder, oasOperation, 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(false); + 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..4e0e16add --- /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": "/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 5fc5236b7..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 @@ -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,6 +1125,15 @@ "type": "string" } } + }, + "Message": { + "type": "object", + "required": ["message"], + "properties": { + "message": { + "type": "string" + } + } } }, "externalDocs": { 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..6d175eb6c --- /dev/null +++ b/simulator-samples/sample-swagger/src/test/java/org/citrusframework/simulator/OpenApiIT.java @@ -0,0 +1,336 @@ +/* + * 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.Test; + +/** + * @author Christoph Deppisch + */ +@Test +@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 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(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 63807cf0e..0280ea2a0 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,8 @@ 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; @@ -36,8 +38,6 @@ import org.springframework.test.context.ContextConfiguration; import org.testng.annotations.Test; -import static org.citrusframework.http.actions.HttpActionBuilder.http; - /** * @author Christoph Deppisch */ @@ -47,8 +47,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 @@ -56,7 +56,7 @@ public class SimulatorSwaggerIT extends TestNGCitrusSpringSupport { private HttpClient simulatorUiClient; @CitrusTest - public void testUiInfo() { + public void uiInfoShouldSucceed() { $(http().client(simulatorUiClient) .send() .get("/api/manage/info") @@ -80,13 +80,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() @@ -94,35 +94,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() @@ -131,13 +176,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() @@ -145,21 +205,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() @@ -168,28 +228,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() @@ -198,35 +258,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')@")); @@ -236,7 +314,7 @@ public void testLoginUser() { public static class EndpointConfig { @Bean - public HttpClient petstoreClient() { + public HttpClient petstoreClientV2() { return CitrusEndpoints.http().client() .requestUrl(String.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/src/main/java/org/citrusframework/simulator/config/SimulatorConfigurationProperties.java b/simulator-spring-boot/src/main/java/org/citrusframework/simulator/config/SimulatorConfigurationProperties.java index c375712d1..e2ce3785a 100644 --- a/simulator-spring-boot/src/main/java/org/citrusframework/simulator/config/SimulatorConfigurationProperties.java +++ b/simulator-spring-boot/src/main/java/org/citrusframework/simulator/config/SimulatorConfigurationProperties.java @@ -16,17 +16,13 @@ package org.citrusframework.simulator.config; -import jakarta.annotation.Nonnull; import lombok.Getter; import lombok.Setter; import lombok.ToString; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.beans.BeansException; import org.springframework.beans.factory.InitializingBean; import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.context.ApplicationContext; -import org.springframework.context.ApplicationContextAware; import org.springframework.context.EnvironmentAware; import org.springframework.core.env.Environment; @@ -37,7 +33,7 @@ @Setter @ToString @ConfigurationProperties(prefix = "citrus.simulator") -public class SimulatorConfigurationProperties implements ApplicationContextAware, EnvironmentAware, InitializingBean { +public class SimulatorConfigurationProperties implements EnvironmentAware, InitializingBean { private static final Logger logger = LoggerFactory.getLogger(SimulatorConfigurationProperties.class); @@ -50,8 +46,6 @@ public class SimulatorConfigurationProperties implements ApplicationContextAware private static final String SIMULATOR_OUTBOUND_JSON_DICTIONARY_PROPERTY = "citrus.simulator.outbound.json.dictionary.location"; private static final String SIMULATOR_OUTBOUND_JSON_DICTIONARY_ENV = "CITRUS_SIMULATOR_OUTBOUND_JSON_DICTIONARY_LOCATION"; - private static ApplicationContext applicationContext; - /** * Global option to enable/disable simulator support, default is true. */ @@ -108,13 +102,6 @@ public class SimulatorConfigurationProperties implements ApplicationContextAware */ private String outboundJsonDictionary = "outbound-json-dictionary.properties"; - public static ApplicationContext getApplicationContext() { - if (applicationContext == null) { - throw new IllegalStateException("Application context has not been initialized. This bean needs to be instantiated by Spring in order to function properly!"); - } - return applicationContext; - } - @Override public void setEnvironment(Environment environment) { inboundXmlDictionary = environment.getProperty(SIMULATOR_INBOUND_XML_DICTIONARY_PROPERTY, environment.getProperty(SIMULATOR_INBOUND_XML_DICTIONARY_ENV, inboundXmlDictionary)); @@ -128,12 +115,4 @@ public void afterPropertiesSet() { logger.info("Using the simulator configuration: {}", this); } - @Override - public void setApplicationContext(@Nonnull ApplicationContext context) throws BeansException { - initStaticApplicationContext(context); - } - - private static void initStaticApplicationContext(ApplicationContext context) { - applicationContext = context; - } } 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 8334077da..239492531 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 @@ -25,7 +25,7 @@ import java.util.concurrent.atomic.AtomicReference; import lombok.Getter; import org.citrusframework.http.actions.HttpServerResponseActionBuilder; -import org.citrusframework.message.Message; +import org.citrusframework.http.message.HttpMessage; import org.citrusframework.message.MessageHeaders; import org.citrusframework.openapi.OpenApiSpecification; import org.citrusframework.openapi.actions.OpenApiActionBuilder; @@ -53,10 +53,6 @@ public class HttpOperationScenario extends AbstractSimulatorScenario { private final OasOperation operation; - private OasResponse response; - - private HttpStatus statusCode = HttpStatus.OK; - private JsonPathMappingDataDictionary inboundDataDictionary; private JsonPathMappingDataDictionary outboundDataDictionary; @@ -69,10 +65,6 @@ public HttpOperationScenario(String path, String scenarioId, OpenApiSpecificatio this.openApiSpecification = openApiSpecification; this.operation = operation; this.httpResponseActionBuilderProvider = httpResponseActionBuilderProvider; - - // Note, that in case of an absent response, an OK response will be sent. This is to maintain backwards compatibility with previous swagger implementation. - // Also, the petstore api lacks the definition of good responses for several operations - this.response = OasModelHelper.getResponseForRandomGeneration(getOasDocument(), operation).orElse(null); } @Override @@ -83,11 +75,11 @@ public void run(ScenarioRunner scenario) { OpenApiServerActionBuilder openApiServerActionBuilder = new OpenApiActionBuilder( openApiSpecification).server(getScenarioEndpoint()); - Message receivedMessage = receive(scenario, openApiServerActionBuilder); + HttpMessage receivedMessage = receive(scenario, openApiServerActionBuilder); respond(scenario, openApiServerActionBuilder, receivedMessage); } - private Message receive(ScenarioRunner scenario, + private HttpMessage receive(ScenarioRunner scenario, OpenApiServerActionBuilder openApiServerActionBuilder) { OpenApiServerRequestActionBuilder requestActionBuilder = openApiServerActionBuilder.receive( @@ -101,9 +93,9 @@ private Message receive(ScenarioRunner scenario, requestActionBuilder.message().dictionary(inboundDataDictionary); } - AtomicReference receivedMessage = new AtomicReference<>(); + AtomicReference receivedMessage = new AtomicReference<>(); requestActionBuilder.getMessageProcessors().add( - (message, context) -> receivedMessage.set(message)); + (message, context) -> receivedMessage.set((HttpMessage)message)); // Verify incoming request scenario.$(requestActionBuilder); @@ -112,23 +104,41 @@ private Message receive(ScenarioRunner scenario, } private void respond(ScenarioRunner scenario, - OpenApiServerActionBuilder openApiServerActionBuilder, Message receivedMessage) { + OpenApiServerActionBuilder openApiServerActionBuilder, HttpMessage receivedMessage) { HttpServerResponseActionBuilder responseBuilder = null; if (httpResponseActionBuilderProvider != null) { - responseBuilder = httpResponseActionBuilderProvider.provideHttpServerResponseActionBuilder(operation, receivedMessage); + responseBuilder = httpResponseActionBuilderProvider.provideHttpServerResponseActionBuilder(openApiServerActionBuilder, operation, receivedMessage); } - HttpStatus httpStatus = response != null && response.getStatusCode() != null ? HttpStatus.valueOf(Integer.parseInt(response.getStatusCode())) : HttpStatus.OK; - responseBuilder = responseBuilder != null ? responseBuilder : openApiServerActionBuilder.send( - operation.operationId, httpStatus); + if (responseBuilder == null) { + responseBuilder = createRandomMessageResponseBuilder(openApiServerActionBuilder, receivedMessage.getAccept()); + } + + scenario.$(responseBuilder); + } + + /** + * Creates a builder that creates a random message based on OpenApi specification. + * @param openApiServerActionBuilder + * @return + */ + private HttpServerResponseActionBuilder createRandomMessageResponseBuilder( + OpenApiServerActionBuilder openApiServerActionBuilder, String accept) { + HttpServerResponseActionBuilder responseBuilder; + + OasResponse response = determineResponse(accept); + HttpStatus httpStatus = response != null && response.getStatusCode() != null ? HttpStatus.valueOf(Integer.parseInt(response.getStatusCode())) : HttpStatus.OK; + responseBuilder = openApiServerActionBuilder.send(operation.operationId, httpStatus, accept); responseBuilder.message() .status(httpStatus) .header(MessageHeaders.MESSAGE_PREFIX + "generated", true); + return responseBuilder; + } - // Return generated response - scenario.$(responseBuilder); + OasResponse determineResponse(String accept) { + return OasModelHelper.getResponseForRandomGeneration(getOasDocument(), operation, null, accept).orElse(null); } /** @@ -144,24 +154,6 @@ public String getMethod() { return operation.getMethod() != null ? operation.getMethod().toUpperCase() : null; } - /** - * Sets the response. - * - * @param response - */ - public void setResponse(OasResponse response) { - this.response = response; - } - - /** - * Sets the statusCode. - * - * @param statusCode - */ - public void setStatusCode(HttpStatus statusCode) { - this.statusCode = statusCode; - } - /** * Sets the inboundDataDictionary. * 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 index 5bdc6eec3..e92571ef5 100644 --- 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 @@ -1,10 +1,28 @@ +/* + * 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.simulator.config.SimulatorConfigurationProperties; +import org.citrusframework.spi.ReferenceResolver; import org.springframework.context.ApplicationContext; -import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.support.AbstractApplicationContext; /** * Registrar for HTTP operation scenarios based on an OpenAPI specification. @@ -19,10 +37,15 @@ public class HttpOperationScenarioRegistrar implements OpenApiSpecificationProce public void process(OpenApiSpecification openApiSpecification) { HttpScenarioGenerator generator = new HttpScenarioGenerator(openApiSpecification); - ApplicationContext applicationContext = SimulatorConfigurationProperties.getApplicationContext(); - if (applicationContext instanceof ConfigurableApplicationContext configurableApplicationContext) { - generator.postProcessBeanFactory(configurableApplicationContext.getBeanFactory()); - } + CitrusInstanceManager.get().ifPresent(citrus -> { + ReferenceResolver referenceResolver = citrus.getCitrusContext().getReferenceResolver(); + if (referenceResolver instanceof SpringBeanReferenceResolver springBeanReferenceResolver) { + ApplicationContext applicationContext = springBeanReferenceResolver.getApplicationContext(); + if (applicationContext instanceof AbstractApplicationContext abstractApplicationContext) { + generator.postProcessBeanFactory(abstractApplicationContext.getBeanFactory()); + } + } + }); } } 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 index a68b230b5..5f5c61197 100644 --- 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 @@ -1,15 +1,33 @@ +/* + * 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 io.apicurio.datamodels.openapi.models.OasOperation; import org.citrusframework.http.actions.HttpServerResponseActionBuilder; -import org.citrusframework.message.Message; +import org.citrusframework.http.message.HttpMessage; +import org.citrusframework.openapi.actions.OpenApiServerActionBuilder; /** * Interface for providing an {@link HttpServerResponseActionBuilder} based on an OpenAPI operation and a received message. */ public interface HttpResponseActionBuilderProvider { - HttpServerResponseActionBuilder provideHttpServerResponseActionBuilder(OasOperation oasOperation, - Message receivedMessage); + HttpServerResponseActionBuilder provideHttpServerResponseActionBuilder( + OpenApiServerActionBuilder openApiServerActionBuilder, OasOperation oasOperation, + HttpMessage receivedMessage); } 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 d7462bbac..91a5beb29 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,6 +16,7 @@ package org.citrusframework.simulator.http; +import static org.citrusframework.util.StringUtils.appendSegmentToUrlPath; import static org.springframework.beans.factory.support.BeanDefinitionBuilder.genericBeanDefinition; import io.apicurio.datamodels.combined.visitors.CombinedVisitorAdapter; @@ -25,6 +26,7 @@ import io.apicurio.datamodels.openapi.models.OasPaths; import jakarta.annotation.Nonnull; import java.util.Map; +import lombok.Getter; import org.citrusframework.context.TestContext; import org.citrusframework.openapi.OpenApiSpecification; import org.citrusframework.openapi.model.OasModelHelper; @@ -58,8 +60,15 @@ public class HttpScenarioGenerator implements BeanFactoryPostProcessor { /** * Optional context path */ + @Getter private String contextPath = ""; + @Getter + private boolean requestValidationEnabled = true; + + @Getter + private boolean responseValidationEnabled = true; + /** * Constructor using Spring environment. */ @@ -86,75 +95,46 @@ public HttpScenarioGenerator(OpenApiSpecification openApiSpecification) { this.openApiSpecification = openApiSpecification; } + public void setRequestValidationEnabled(boolean enabled) { + this.requestValidationEnabled = enabled; + if (openApiSpecification != null) { + openApiSpecification.setRequestValidationEnabled(enabled); + } + } + + public void setResponseValidationEnabled(boolean enabled) { + this.responseValidationEnabled = enabled; + if (openApiSpecification != null) { + openApiSpecification.setResponseValidationEnabled(enabled); + } + } + @Override public void postProcessBeanFactory(@Nonnull ConfigurableListableBeanFactory beanFactory) throws BeansException { if (openApiSpecification == null) { - 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); + initOpenApiSpecification(); } - HttpResponseActionBuilderProvider httpResponseActionBuilderProvider = retrieveOptionalBuilderProvider( - beanFactory); - - OpenApiScenarioIdGenerationMode openApiScenarioIdGenerationMode = beanFactory.getBean( - SimulatorRestConfigurationProperties.class).getOpenApiScenarioIdGenerationMode(); - TestContext testContext = new TestContext(); OasDocument openApiDocument = openApiSpecification.getOpenApiDoc(testContext); if (openApiDocument != null && openApiDocument.paths != null) { - openApiDocument.paths.accept(new CombinedVisitorAdapter() { - - @Override - public void visitPaths(OasPaths oasPaths) { - oasPaths.getPathItems().forEach(oasPathItem -> oasPathItem.accept(this)); - } - - @Override - public void visitPathItem(OasPathItem oasPathItem) { - String path = oasPathItem.getPath(); - for (Map.Entry operationEntry : OasModelHelper.getOperationMap( - oasPathItem).entrySet()) { - - String fullPath = contextPath + OasModelHelper.getBasePath(openApiDocument) + path; - OasOperation oasOperation = operationEntry.getValue(); - - String scenarioId = HttpOperationScenario.getUniqueScenarioId(openApiScenarioIdGenerationMode, OasModelHelper.getBasePath(openApiDocument) + path, 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"); - } - - beanDefinitionRegistry.registerBeanDefinition(scenarioId, beanDefinitionBuilder.getBeanDefinition()); - } else { - logger.info("Register auto generated scenario as singleton: {}", scenarioId); - beanFactory.registerSingleton(scenarioId, createScenario(fullPath, scenarioId, openApiSpecification, oasOperation, httpResponseActionBuilderProvider)); - } - } - } - }); + openApiDocument.paths.accept(new ScenarioRegistrar(beanFactory)); } } + 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.setResponseValidationEnabled(responseValidationEnabled); + openApiSpecification.setRequestValidationEnabled(requestValidationEnabled); + } + private static HttpResponseActionBuilderProvider retrieveOptionalBuilderProvider( ConfigurableListableBeanFactory beanFactory) { HttpResponseActionBuilderProvider httpResponseActionBuilderProvider = null; @@ -180,10 +160,6 @@ protected HttpOperationScenario createScenario(String path, String scenarioId, O return new HttpOperationScenario(path, scenarioId, openApiSpecification, operation, httpResponseActionBuilderProvider); } - public String getContextPath() { - return contextPath; - } - public void setContextPath(String contextPath) { this.contextPath = contextPath; @@ -191,4 +167,63 @@ public void setContextPath(String contextPath) { 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); + + OpenApiScenarioIdGenerationMode openApiScenarioIdGenerationMode = beanFactory.getBean( + SimulatorRestConfigurationProperties.class).getOpenApiScenarioIdGenerationMode(); + + OasDocument oasDocument = openApiSpecification.getOpenApiDoc(null); + String path = oasPathItem.getPath(); + for (Map.Entry operationEntry : OasModelHelper.getOperationMap( + oasPathItem).entrySet()) { + + String fullPath = appendSegmentToUrlPath(appendSegmentToUrlPath(openApiSpecification.getRootContextPath(), OasModelHelper.getBasePath(oasDocument)), path); + OasOperation oasOperation = operationEntry.getValue(); + + String scenarioId = HttpOperationScenario.getUniqueScenarioId(openApiScenarioIdGenerationMode, OasModelHelper.getBasePath(oasDocument) + path, 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"); + } + + 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/SimulatorRestConfigurationProperties.java b/simulator-spring-boot/src/main/java/org/citrusframework/simulator/http/SimulatorRestConfigurationProperties.java index eeaeffd73..7b96620d7 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 @@ -93,12 +93,12 @@ public String toString() { .toString(); } - @Getter @Setter public static class OpenApi { private String api; private String contextPath; private boolean enabled = false; + private String alias; } } 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 index 91f12d81b..5e3b0290e 100644 --- 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 @@ -1,20 +1,38 @@ +/* + * 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 static org.mockito.Mockito.doReturn; -import static org.mockito.Mockito.mock; import io.apicurio.datamodels.openapi.models.OasOperation; +import io.apicurio.datamodels.openapi.models.OasResponse; 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; @@ -23,7 +41,8 @@ import org.citrusframework.openapi.OpenApiRepository; import org.citrusframework.openapi.actions.OpenApiClientResponseActionBuilder; import org.citrusframework.openapi.model.OasModelHelper; -import org.citrusframework.simulator.config.SimulatorConfigurationProperties; +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; @@ -35,68 +54,60 @@ import org.citrusframework.validation.json.JsonMessageValidationContext; import org.citrusframework.validation.json.JsonTextMessageValidator; import org.citrusframework.validation.matcher.DefaultValidationMatcherRegistry; -import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; -import org.mockito.junit.jupiter.MockitoExtension; 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; -@ExtendWith(MockitoExtension.class) +@IntegrationTest +@ContextConfiguration(classes = HttpOperationScenarioTestConfiguration.class) class HttpOperationScenarioIT { private static final Function IDENTITY = (text) -> text; private final DirectScenarioEndpoint scenarioEndpoint = new DirectScenarioEndpoint(); - private static final OpenApiRepository openApiRepository = new OpenApiRepository(); - private static DefaultListableBeanFactory defaultListableBeanFactory; private ScenarioRunner scenarioRunner; private TestContext testContext; - @BeforeAll - static void beforeAll() { - ConfigurableApplicationContext applicationContext = mock(); - defaultListableBeanFactory = new DefaultListableBeanFactory(); - doReturn(defaultListableBeanFactory).when(applicationContext).getBeanFactory(); - SimulatorConfigurationProperties simulatorConfigurationProperties = new SimulatorConfigurationProperties(); - simulatorConfigurationProperties.setApplicationContext(applicationContext); - - defaultListableBeanFactory.registerSingleton("SimulatorRestConfigurationProperties", new SimulatorRestConfigurationProperties()); - - openApiRepository.addRepository(new ClasspathResource("swagger/petstore-v2.json")); - openApiRepository.addRepository(new ClasspathResource("swagger/petstore-v3.json")); - } - @BeforeEach - void beforeEach() { + void beforeEach(ApplicationContext applicationContext) { + defaultListableBeanFactory = (DefaultListableBeanFactory) ((ConfigurableApplicationContext)applicationContext).getBeanFactory(); testContext = new TestContext(); - testContext.setReferenceResolver(mock()); + 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, mock(), testContext); + + scenarioRunner = new ScenarioRunner(scenarioEndpoint, applicationContext, testContext); } static Stream scenarioExecution() { return Stream.of( arguments("v2_addPet_success", "POST_/petstore/v2/pet", "data/addPet.json", IDENTITY, null), arguments("v3_addPet_success", "POST_/petstore/v3/pet", "data/addPet.json", IDENTITY, null), - arguments("v2_addPet_payloadValidationFailure", "POST_/petstore/v2/pet", "data/addPet_incorrect.json", IDENTITY, "Missing JSON entry, expected 'id' to be in '[photoUrls, wrong_id_property, name, category, tags, status]'"), - arguments("v3_addPet_payloadValidationFailure", "POST_/petstore/v3/pet", "data/addPet_incorrect.json", IDENTITY, "Missing JSON entry, expected 'id' to be in '[photoUrls, wrong_id_property, name, category, tags, status]'"), + arguments("v2_addPet_payloadValidationFailure", "POST_/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_/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_/petstore/v2/pet/{petId}", null, (Function)(text) -> text.replace("{petId}", "1234"), null), arguments("v3_getPetById_success", "GET_/petstore/v3/pet/{petId}", null, (Function)(text) -> text.replace("{petId}", "1234"), null), - arguments("v2_getPetById_pathParameterValidationFailure", "GET_/petstore/v2/pet/{petId}", null, (Function)(text) -> text.replace("{petId}", "xxxx"), "MatchesValidationMatcher failed for field 'citrus_http_request_uri'. Received value is '/petstore/v2/pet/xxxx', control value is '/petstore/v2/pet/[0-9]+'"), - arguments("v3_getPetById_pathParameterValidationFailure", "GET_/petstore/v3/pet/{petId}", null, (Function)(text) -> text.replace("{petId}", "xxxx"), "MatchesValidationMatcher failed for field 'citrus_http_request_uri'. Received value is '/petstore/v3/pet/xxxx', control value is '/petstore/v3/pet/[0-9]+'") + arguments("v2_getPetById_pathParameterValidationFailure", "GET_/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_/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\"]): []") ); } @@ -110,7 +121,9 @@ void scenarioExecution(String name, String operationName, String payloadFile, Fu HttpOperationScenario httpOperationScenario = getHttpOperationScenario(operationName); HttpMessage controlMessage = new HttpMessage(); - OpenApiClientResponseActionBuilder.fillMessageFromResponse(httpOperationScenario.getOpenApiSpecification(), testContext, controlMessage, httpOperationScenario.getOperation(), httpOperationScenario.getResponse()); + OasResponse oasResponse = httpOperationScenario.determineResponse(null); + OpenApiClientResponseActionBuilder.fillMessageFromResponse(httpOperationScenario.getOpenApiSpecification(), + testContext, controlMessage, httpOperationScenario.getOperation(), oasResponse); this.scenarioExecution(operationName, payloadFile, urlAdjuster, exceptionMessage, controlMessage); } @@ -120,7 +133,7 @@ void scenarioExecution(String name, String operationName, String payloadFile, Fu void scenarioExecutionWithProvider(String name, String operationName, String payloadFile, Function urlAdjuster, String exceptionMessage) { String payload = "{\"id\":1234}"; - HttpResponseActionBuilderProvider httpResponseActionBuilderProvider = (oasOperation, receivedMessage) -> { + HttpResponseActionBuilderProvider httpResponseActionBuilderProvider = (openApiServerActionBuilder, oasOperation, receivedMessage) -> { HttpServerResponseActionBuilder serverResponseActionBuilder = new HttpServerResponseActionBuilder(); serverResponseActionBuilder .endpoint(scenarioEndpoint) @@ -179,7 +192,7 @@ private void scenarioExecution(String operationName, String payloadFile, Functio if (exceptionMessage != null) { assertThatThrownBy(() -> httpOperationScenario.run(scenarioRunner)).isInstanceOf( - TestCaseFailedException.class).hasMessage(exceptionMessage); + TestCaseFailedException.class).cause().isInstanceOf(ValidationException.class).hasMessage(exceptionMessage); } else { assertThatCode(() -> httpOperationScenario.run(scenarioRunner)).doesNotThrowAnyException(); @@ -206,6 +219,7 @@ private static class DirectScenarioEndpoint extends ScenarioEndpoint { private Message receiveMessage; + @Getter private Message sendMessage; public DirectScenarioEndpoint() { @@ -236,9 +250,16 @@ public void setReceiveMessage(Message receiveMessage) { this.receiveMessage = receiveMessage; } - public Message getSendMessage() { - return sendMessage; - } + } + @TestConfiguration + public static class HttpOperationScenarioTestConfiguration { + + @Bean + public OpenApiRepository repository() { + OpenApiRepository openApiRepository = new OpenApiRepository(); + openApiRepository.setLocations(List.of("swagger/petstore-v2.json", "swagger/petstore-v3.json")); + return openApiRepository; + } } } 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 41682e562..ecc6de497 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 @@ -83,8 +83,6 @@ void testGetMappingKey(String version) { fail("Unexpected version: "+ version); } - doReturn(oasDocument).when(openApiSpecificationMock).getOpenApiDoc(null); - 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),