From a4d91cdc8ae62181bbd8b3d62501c46d35fce131 Mon Sep 17 00:00:00 2001 From: Thorsten Schlathoelter Date: Tue, 11 Jun 2024 16:01:12 +0200 Subject: [PATCH] feat(#285): support OpenAPI 3.0 from HttpOperationScenario --- .../main/resources/swagger/petstore-api.json | 4 +- simulator-spring-boot/pom.xml | 4 + .../OpenApiScenarioIdGenerationMode.java | 25 + .../SimulatorConfigurationProperties.java | 27 +- .../simulator/http/HttpOperationScenario.java | 459 ++++-------------- .../http/HttpOperationScenarioRegistrar.java | 28 ++ .../http/HttpRequestPathScenarioMapper.java | 21 +- .../HttpResponseActionBuilderProvider.java | 15 + .../simulator/http/HttpScenarioGenerator.java | 153 ++++-- .../SimulatorRestConfigurationProperties.java | 68 +-- .../scenario/mapper/ScenarioMappers.java | 29 +- .../openapi/processor/scenarioRegistrar | 2 + .../http/HttpOperationScenarioIT.java | 244 ++++++++++ .../HttpRequestPathScenarioMapperTest.java | 135 ++++-- .../http/HttpScenarioGeneratorTest.java | 223 +++++---- .../src/test/resources/data/addPet.json | 15 + .../test/resources/data/addPet_incorrect.json | 15 + .../{swagger-api.json => petstore-v2.json} | 6 +- .../test/resources/swagger/petstore-v3.json | 254 ++++++++++ 19 files changed, 1075 insertions(+), 652 deletions(-) create mode 100644 simulator-spring-boot/src/main/java/org/citrusframework/simulator/config/OpenApiScenarioIdGenerationMode.java create mode 100644 simulator-spring-boot/src/main/java/org/citrusframework/simulator/http/HttpOperationScenarioRegistrar.java create mode 100644 simulator-spring-boot/src/main/java/org/citrusframework/simulator/http/HttpResponseActionBuilderProvider.java create mode 100644 simulator-spring-boot/src/main/resources/META-INF/citrus/openapi/processor/scenarioRegistrar create mode 100644 simulator-spring-boot/src/test/java/org/citrusframework/simulator/http/HttpOperationScenarioIT.java create mode 100644 simulator-spring-boot/src/test/resources/data/addPet.json create mode 100644 simulator-spring-boot/src/test/resources/data/addPet_incorrect.json rename simulator-spring-boot/src/test/resources/swagger/{swagger-api.json => petstore-v2.json} (99%) create mode 100644 simulator-spring-boot/src/test/resources/swagger/petstore-v3.json diff --git a/simulator-samples/sample-swagger/src/main/resources/swagger/petstore-api.json b/simulator-samples/sample-swagger/src/main/resources/swagger/petstore-api.json index 5d2e8e3d0..5fc5236b7 100644 --- a/simulator-samples/sample-swagger/src/main/resources/swagger/petstore-api.json +++ b/simulator-samples/sample-swagger/src/main/resources/swagger/petstore-api.json @@ -1,5 +1,5 @@ { - "swagger": "3.0.3", + "swagger": "2.0", "info": { "description": "This is a sample server Petstore server. You can find out more about Swagger at [http://swagger.io](http://swagger.io) or on [irc.freenode.net, #swagger](http://swagger.io/irc/). For this sample, you can use the api key `special-key` to test the authorization filters.", "version": "1.0.0", @@ -1032,4 +1032,4 @@ "description": "Find out more about Swagger", "url": "http://swagger.io" } -} \ No newline at end of file +} diff --git a/simulator-spring-boot/pom.xml b/simulator-spring-boot/pom.xml index aa94c2c5b..82889791f 100644 --- a/simulator-spring-boot/pom.xml +++ b/simulator-spring-boot/pom.xml @@ -130,6 +130,10 @@ org.citrusframework citrus-http + + org.citrusframework + citrus-openapi + org.citrusframework citrus-ws diff --git a/simulator-spring-boot/src/main/java/org/citrusframework/simulator/config/OpenApiScenarioIdGenerationMode.java b/simulator-spring-boot/src/main/java/org/citrusframework/simulator/config/OpenApiScenarioIdGenerationMode.java new file mode 100644 index 000000000..669816336 --- /dev/null +++ b/simulator-spring-boot/src/main/java/org/citrusframework/simulator/config/OpenApiScenarioIdGenerationMode.java @@ -0,0 +1,25 @@ +package org.citrusframework.simulator.config; + +/** + * Enumeration representing the modes for generating scenario IDs in an OpenAPI context. + * This enumeration defines two modes: + * + * The choice of mode affects how scenario IDs are generated, with important implications: + * + *

+ */ +public enum OpenApiScenarioIdGenerationMode { + FULL_PATH, + OPERATION_ID +} 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 5214fa6c9..27cc1e01e 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,13 +16,17 @@ 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; @@ -33,7 +37,7 @@ @Setter @ToString @ConfigurationProperties(prefix = "citrus.simulator") -public class SimulatorConfigurationProperties implements EnvironmentAware, InitializingBean { +public class SimulatorConfigurationProperties implements ApplicationContextAware, EnvironmentAware, InitializingBean { private static final Logger logger = LoggerFactory.getLogger(SimulatorConfigurationProperties.class); @@ -46,6 +50,8 @@ public class SimulatorConfigurationProperties implements EnvironmentAware, Initi 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. */ @@ -104,6 +110,15 @@ public class SimulatorConfigurationProperties implements EnvironmentAware, Initi private SimulationResults simulationResults = new SimulationResults(); + 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)); @@ -117,6 +132,15 @@ 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; + } + @Getter @Setter @ToString @@ -127,4 +151,5 @@ public static class SimulationResults { */ private boolean resetEnabled = true; } + } diff --git a/simulator-spring-boot/src/main/java/org/citrusframework/simulator/http/HttpOperationScenario.java b/simulator-spring-boot/src/main/java/org/citrusframework/simulator/http/HttpOperationScenario.java index 8f4ee0b40..8334077da 100644 --- a/simulator-spring-boot/src/main/java/org/citrusframework/simulator/http/HttpOperationScenario.java +++ b/simulator-spring-boot/src/main/java/org/citrusframework/simulator/http/HttpOperationScenario.java @@ -16,395 +16,132 @@ package org.citrusframework.simulator.http; -import io.swagger.models.ArrayModel; -import io.swagger.models.Model; -import io.swagger.models.Operation; -import io.swagger.models.RefModel; -import io.swagger.models.Response; -import io.swagger.models.parameters.AbstractSerializableParameter; -import io.swagger.models.parameters.BodyParameter; -import io.swagger.models.parameters.HeaderParameter; -import io.swagger.models.parameters.Parameter; -import io.swagger.models.parameters.QueryParameter; -import io.swagger.models.properties.ArrayProperty; -import io.swagger.models.properties.BooleanProperty; -import io.swagger.models.properties.DateProperty; -import io.swagger.models.properties.DateTimeProperty; -import io.swagger.models.properties.DoubleProperty; -import io.swagger.models.properties.FloatProperty; -import io.swagger.models.properties.IntegerProperty; -import io.swagger.models.properties.LongProperty; -import io.swagger.models.properties.Property; -import io.swagger.models.properties.RefProperty; -import io.swagger.models.properties.StringProperty; -import org.citrusframework.http.actions.HttpServerRequestActionBuilder; +import static java.lang.String.format; +import static org.citrusframework.actions.EchoAction.Builder.echo; + +import io.apicurio.datamodels.openapi.models.OasDocument; +import io.apicurio.datamodels.openapi.models.OasOperation; +import io.apicurio.datamodels.openapi.models.OasResponse; +import java.util.concurrent.atomic.AtomicReference; +import lombok.Getter; import org.citrusframework.http.actions.HttpServerResponseActionBuilder; -import org.citrusframework.http.message.HttpMessageHeaders; +import org.citrusframework.message.Message; import org.citrusframework.message.MessageHeaders; -import org.citrusframework.message.MessageType; -import org.citrusframework.simulator.exception.SimulatorException; +import org.citrusframework.openapi.OpenApiSpecification; +import org.citrusframework.openapi.actions.OpenApiActionBuilder; +import org.citrusframework.openapi.actions.OpenApiServerActionBuilder; +import org.citrusframework.openapi.actions.OpenApiServerRequestActionBuilder; +import org.citrusframework.openapi.model.OasModelHelper; +import org.citrusframework.simulator.config.OpenApiScenarioIdGenerationMode; import org.citrusframework.simulator.scenario.AbstractSimulatorScenario; import org.citrusframework.simulator.scenario.ScenarioRunner; import org.citrusframework.variable.dictionary.json.JsonPathMappingDataDictionary; -import org.hamcrest.CustomMatcher; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; -import org.springframework.util.AntPathMatcher; -import org.springframework.util.CollectionUtils; -import org.springframework.util.StringUtils; -import org.springframework.web.bind.annotation.RequestMethod; - -import java.util.Map; -import java.util.stream.Collectors; - -import static org.citrusframework.actions.EchoAction.Builder.echo; +@Getter public class HttpOperationScenario extends AbstractSimulatorScenario { - /** Operation in wsdl */ - private final Operation operation; - - /** Schema model definitions */ - private final Map definitions; + private static final Logger logger = LoggerFactory.getLogger(HttpOperationScenario.class); - /** Request path */ private final String path; - /** Request method */ - private final RequestMethod method; + private final String scenarioId; - /** Response */ - private Response response; + private final OpenApiSpecification openApiSpecification; + + private final OasOperation operation; + + private OasResponse response; - /** Response status code */ private HttpStatus statusCode = HttpStatus.OK; private JsonPathMappingDataDictionary inboundDataDictionary; + private JsonPathMappingDataDictionary outboundDataDictionary; - /** - * Default constructor. - * @param path - * @param method - * @param operation - * @param definitions - */ - public HttpOperationScenario(String path, RequestMethod method, Operation operation, Map definitions) { - this.operation = operation; - this.definitions = definitions; + private final HttpResponseActionBuilderProvider httpResponseActionBuilderProvider; + + public HttpOperationScenario(String path, String scenarioId, OpenApiSpecification openApiSpecification, OasOperation operation, HttpResponseActionBuilderProvider httpResponseActionBuilderProvider) { this.path = path; - this.method = method; + this.scenarioId = scenarioId; + this.openApiSpecification = openApiSpecification; + this.operation = operation; + this.httpResponseActionBuilderProvider = httpResponseActionBuilderProvider; - if (operation.getResponses() != null) { - this.response = operation.getResponses().get("200"); - } + // 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 public void run(ScenarioRunner scenario) { - scenario.name(operation.getOperationId()); - scenario.$(echo("Generated scenario from swagger operation: " + operation.getOperationId())); - - HttpServerRequestActionBuilder requestBuilder = switch (method) { - case GET -> scenario.http() - .receive() - .get(); - case POST -> scenario.http() - .receive() - .post(); - case PUT -> scenario.http() - .receive() - .put(); - case HEAD -> scenario.http() - .receive() - .head(); - case DELETE -> scenario.http() - .receive() - .delete(); - default -> throw new SimulatorException("Unsupported request method: " + method.name()); - }; - - requestBuilder - .message() - .type(MessageType.JSON) - .header(MessageHeaders.MESSAGE_PREFIX + "generated", true) - .header(HttpMessageHeaders.HTTP_REQUEST_URI, new CustomMatcher(String.format("request path matching %s", path)) { - @Override - public boolean matches(Object item) { - return ((item instanceof String) && new AntPathMatcher().match(path, (String) item)); - } - }); - - if (operation.getParameters() != null) { - operation.getParameters().stream() - .filter(p -> p instanceof HeaderParameter) - .filter(Parameter::getRequired) - .forEach(p -> requestBuilder.message().header(p.getName(), createValidationExpression(((HeaderParameter) p)))); - - String queryParams = operation.getParameters().stream() - .filter(param -> param instanceof QueryParameter) - .filter(Parameter::getRequired) - .map(param -> "containsString(" + param.getName() + ")") - .collect(Collectors.joining(", ")); - - if (StringUtils.hasText(queryParams)) { - requestBuilder.message().header(HttpMessageHeaders.HTTP_QUERY_PARAMS, "@assertThat(allOf(" + queryParams + "))@"); - } - - operation.getParameters().stream() - .filter(p -> p instanceof BodyParameter) - .filter(Parameter::getRequired) - .forEach(p -> requestBuilder.message().body(createValidationPayload((BodyParameter) p))); - - if (inboundDataDictionary != null) { - requestBuilder.message().dictionary(inboundDataDictionary); - } - } - - // Verify incoming request - scenario.$(requestBuilder); + scenario.name(operation.operationId); + scenario.$(echo("Generated scenario from swagger operation: " + operation.operationId)); - HttpServerResponseActionBuilder responseBuilder = scenario.http() - .send() - .response(statusCode); + OpenApiServerActionBuilder openApiServerActionBuilder = new OpenApiActionBuilder( + openApiSpecification).server(getScenarioEndpoint()); - responseBuilder.message() - .type(MessageType.JSON) - .header(MessageHeaders.MESSAGE_PREFIX + "generated", true) - .contentType(MediaType.APPLICATION_JSON_VALUE); - - if (response != null) { - if (response.getHeaders() != null) { - for (Map.Entry header : response.getHeaders().entrySet()) { - responseBuilder.message().header(header.getKey(), createRandomValue(header.getValue(), false)); - } - } - - if (response.getSchema() != null) { - if (outboundDataDictionary != null && - (response.getSchema() instanceof RefProperty || response.getSchema() instanceof ArrayProperty)) { - responseBuilder.message().dictionary(outboundDataDictionary); - } - - responseBuilder.message().body(createRandomValue(response.getSchema(), false)); - } - } - - // Return generated response - scenario.$(responseBuilder); + Message receivedMessage = receive(scenario, openApiServerActionBuilder); + respond(scenario, openApiServerActionBuilder, receivedMessage); } - /** - * Create payload from schema with random values. - * @param property - * @param quotes - * @return - */ - private String createRandomValue(Property property, boolean quotes) { - StringBuilder payload = new StringBuilder(); - if (property instanceof RefProperty) { - Model model = definitions.get(((RefProperty) property).getSimpleRef()); - payload.append("{"); - - if (model.getProperties() != null) { - for (Map.Entry entry : model.getProperties().entrySet()) { - payload.append("\"").append(entry.getKey()).append("\": ").append(createRandomValue(entry.getValue(), true)).append(","); - } - } - - if (payload.toString().endsWith(",")) { - payload.replace(payload.length() - 1, payload.length(), ""); - } - - payload.append("}"); - } else if (property instanceof ArrayProperty) { - payload.append("["); - payload.append(createRandomValue(((ArrayProperty) property).getItems(), true)); - payload.append("]"); - } else if (property instanceof StringProperty || property instanceof DateProperty || property instanceof DateTimeProperty) { - if (quotes) { - payload.append("\""); - } - - if (property instanceof DateProperty) { - payload.append("citrus:currentDate()"); - } else if (property instanceof DateTimeProperty) { - payload.append("citrus:currentDate('yyyy-MM-dd'T'hh:mm:ss')"); - } else if (!CollectionUtils.isEmpty(((StringProperty) property).getEnum())) { - payload.append("citrus:randomEnumValue(").append(((StringProperty) property).getEnum().stream().map(value -> "'" + value + "'").collect(Collectors.joining(","))).append(")"); - } else { - payload.append("citrus:randomString(").append(((StringProperty) property).getMaxLength() != null && ((StringProperty) property).getMaxLength() > 0 ? ((StringProperty) property).getMaxLength() : (((StringProperty) property).getMinLength() != null && ((StringProperty) property).getMinLength() > 0 ? ((StringProperty) property).getMinLength() : 10)).append(")"); - } - - if (quotes) { - payload.append("\""); - } - } else if (property instanceof IntegerProperty || property instanceof LongProperty) { - payload.append("citrus:randomNumber(10)"); - } else if (property instanceof FloatProperty || property instanceof DoubleProperty) { - payload.append("citrus:randomNumber(10)"); - } else if (property instanceof BooleanProperty) { - payload.append("citrus:randomEnumValue('true', 'false')"); - } else { - if (quotes) { - payload.append("\"\""); - } else { - payload.append(""); - } - } - - return payload.toString(); - } + private Message receive(ScenarioRunner scenario, + OpenApiServerActionBuilder openApiServerActionBuilder) { - /** - * Creates control payload for validation. - * @param parameter - * @return - */ - private String createValidationPayload(BodyParameter parameter) { - StringBuilder payload = new StringBuilder(); + OpenApiServerRequestActionBuilder requestActionBuilder = openApiServerActionBuilder.receive( + operation.operationId); - Model model = parameter.getSchema(); + requestActionBuilder + .message() + .header(MessageHeaders.MESSAGE_PREFIX + "generated", true); - if (model instanceof RefModel) { - model = definitions.get(((RefModel) model).getSimpleRef()); + if (operation.getParameters() != null && inboundDataDictionary != null) { + requestActionBuilder.message().dictionary(inboundDataDictionary); } - if (model instanceof ArrayModel) { - payload.append("["); - payload.append(createValidationExpression(((ArrayModel) model).getItems())); - payload.append("]"); - } else { - - payload.append("{"); - - if (model.getProperties() != null) { - for (Map.Entry entry : model.getProperties().entrySet()) { - payload.append("\"").append(entry.getKey()).append("\": ").append(createValidationExpression(entry.getValue())).append(","); - } - } + AtomicReference receivedMessage = new AtomicReference<>(); + requestActionBuilder.getMessageProcessors().add( + (message, context) -> receivedMessage.set(message)); - if (payload.toString().endsWith(",")) { - payload.replace(payload.length() - 1, payload.length(), ""); - } - - payload.append("}"); - } + // Verify incoming request + scenario.$(requestActionBuilder); - return payload.toString(); + return receivedMessage.get(); } - /** - * Create validation expression using functions according to parameter type and format. - * @param property - * @return - */ - private String createValidationExpression(Property property) { - StringBuilder payload = new StringBuilder(); - if (property instanceof RefProperty) { - Model model = definitions.get(((RefProperty) property).getSimpleRef()); - payload.append("{"); - - if (model.getProperties() != null) { - for (Map.Entry entry : model.getProperties().entrySet()) { - payload.append("\"").append(entry.getKey()).append("\": ").append(createValidationExpression(entry.getValue())).append(","); - } - } - - if (payload.toString().endsWith(",")) { - payload.replace(payload.length() - 1, payload.length(), ""); - } - - payload.append("}"); - } else if (property instanceof ArrayProperty) { - payload.append("\"@ignore@\""); - } else if (property instanceof StringProperty) { - if (StringUtils.hasText(((StringProperty) property).getPattern())) { - payload.append("\"@matches(").append(((StringProperty) property).getPattern()).append(")@\""); - } else if (!CollectionUtils.isEmpty(((StringProperty) property).getEnum())) { - payload.append("\"@matches(").append(((StringProperty) property).getEnum().stream().collect(Collectors.joining("|"))).append(")@\""); - } else { - payload.append("\"@notEmpty()@\""); - } - } else if (property instanceof DateProperty) { - payload.append("\"@matchesDatePattern('yyyy-MM-dd')@\""); - } else if (property instanceof DateTimeProperty) { - payload.append("\"@matchesDatePattern('yyyy-MM-dd'T'hh:mm:ss')@\""); - } else if (property instanceof IntegerProperty || property instanceof LongProperty) { - payload.append("\"@isNumber()@\""); - } else if (property instanceof FloatProperty || property instanceof DoubleProperty) { - payload.append("\"@isNumber()@\""); - } else if (property instanceof BooleanProperty) { - payload.append("\"@matches(true|false)@\""); - } else { - payload.append("\"@ignore@\""); - } - - return payload.toString(); - } + private void respond(ScenarioRunner scenario, + OpenApiServerActionBuilder openApiServerActionBuilder, Message receivedMessage) { - /** - * Create validation expression using functions according to parameter type and format. - * @param parameter - * @return - */ - private String createValidationExpression(AbstractSerializableParameter parameter) { - switch (parameter.getType()) { - case "integer": - return "@isNumber()@"; - case "string": - if (parameter.getFormat() != null && parameter.getFormat().equals("date")) { - return "\"@matchesDatePattern('yyyy-MM-dd')@\""; - } else if (parameter.getFormat() != null && parameter.getFormat().equals("date-time")) { - return "\"@matchesDatePattern('yyyy-MM-dd'T'hh:mm:ss')@\""; - } else if (StringUtils.hasText(parameter.getPattern())) { - return "\"@matches(" + parameter.getPattern() + ")@\""; - } else if (!CollectionUtils.isEmpty(parameter.getEnum())) { - return "\"@matches(" + (parameter.getEnum().stream().collect(Collectors.joining("|"))) + ")@\""; - } else { - return "@notEmpty()@"; - } - case "boolean": - return "@matches(true|false)@"; - default: - return "@ignore@"; + HttpServerResponseActionBuilder responseBuilder = null; + if (httpResponseActionBuilderProvider != null) { + responseBuilder = httpResponseActionBuilderProvider.provideHttpServerResponseActionBuilder(operation, receivedMessage); } - } - /** - * Gets the operation. - * - * @return - */ - public Operation getOperation() { - return operation; - } + HttpStatus httpStatus = response != null && response.getStatusCode() != null ? HttpStatus.valueOf(Integer.parseInt(response.getStatusCode())) : HttpStatus.OK; + responseBuilder = responseBuilder != null ? responseBuilder : openApiServerActionBuilder.send( + operation.operationId, httpStatus); - /** - * Gets the path. - * - * @return - */ - public String getPath() { - return path; + responseBuilder.message() + .status(httpStatus) + .header(MessageHeaders.MESSAGE_PREFIX + "generated", true); + + // Return generated response + scenario.$(responseBuilder); } /** - * Gets the method. + * Gets the document. * * @return */ - public RequestMethod getMethod() { - return method; + public OasDocument getOasDocument() { + return openApiSpecification.getOpenApiDoc(null); } - /** - * Gets the response. - * - * @return - */ - public Response getResponse() { - return response; + public String getMethod() { + return operation.getMethod() != null ? operation.getMethod().toUpperCase() : null; } /** @@ -412,19 +149,10 @@ public Response getResponse() { * * @param response */ - public void setResponse(Response response) { + public void setResponse(OasResponse response) { this.response = response; } - /** - * Gets the statusCode. - * - * @return - */ - public HttpStatus getStatusCode() { - return statusCode; - } - /** * Sets the statusCode. * @@ -434,15 +162,6 @@ public void setStatusCode(HttpStatus statusCode) { this.statusCode = statusCode; } - /** - * Gets the inboundDataDictionary. - * - * @return - */ - public JsonPathMappingDataDictionary getInboundDataDictionary() { - return inboundDataDictionary; - } - /** * Sets the inboundDataDictionary. * @@ -453,20 +172,28 @@ public void setInboundDataDictionary(JsonPathMappingDataDictionary inboundDataDi } /** - * Gets the outboundDataDictionary. + * Sets the outboundDataDictionary. * - * @return + * @param outboundDataDictionary */ - public JsonPathMappingDataDictionary getOutboundDataDictionary() { - return outboundDataDictionary; + public void setOutboundDataDictionary(JsonPathMappingDataDictionary outboundDataDictionary) { + this.outboundDataDictionary = outboundDataDictionary; } /** - * Sets the outboundDataDictionary. + * Retrieve a unique scenario id for the oas operation. * - * @param outboundDataDictionary + * @param openApiScenarioIdGenerationMode + * @param path + * @param oasOperation + * @return */ - public void setOutboundDataDictionary(JsonPathMappingDataDictionary outboundDataDictionary) { - this.outboundDataDictionary = outboundDataDictionary; + public static String getUniqueScenarioId( + OpenApiScenarioIdGenerationMode openApiScenarioIdGenerationMode, String path, OasOperation oasOperation) { + + return switch(openApiScenarioIdGenerationMode) { + case OPERATION_ID -> oasOperation.operationId; + case FULL_PATH -> format("%s_%s", oasOperation.getMethod().toUpperCase(), path); + }; } } diff --git a/simulator-spring-boot/src/main/java/org/citrusframework/simulator/http/HttpOperationScenarioRegistrar.java b/simulator-spring-boot/src/main/java/org/citrusframework/simulator/http/HttpOperationScenarioRegistrar.java new file mode 100644 index 000000000..5bdc6eec3 --- /dev/null +++ b/simulator-spring-boot/src/main/java/org/citrusframework/simulator/http/HttpOperationScenarioRegistrar.java @@ -0,0 +1,28 @@ +package org.citrusframework.simulator.http; + +import org.citrusframework.openapi.OpenApiSpecification; +import org.citrusframework.openapi.OpenApiSpecificationProcessor; +import org.citrusframework.simulator.config.SimulatorConfigurationProperties; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ConfigurableApplicationContext; + +/** + * Registrar for HTTP operation scenarios based on an OpenAPI specification. + *

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

+ */ +public class HttpOperationScenarioRegistrar implements OpenApiSpecificationProcessor { + + @Override + public void process(OpenApiSpecification openApiSpecification) { + + HttpScenarioGenerator generator = new HttpScenarioGenerator(openApiSpecification); + ApplicationContext applicationContext = SimulatorConfigurationProperties.getApplicationContext(); + + if (applicationContext instanceof ConfigurableApplicationContext configurableApplicationContext) { + generator.postProcessBeanFactory(configurableApplicationContext.getBeanFactory()); + } + } +} diff --git a/simulator-spring-boot/src/main/java/org/citrusframework/simulator/http/HttpRequestPathScenarioMapper.java b/simulator-spring-boot/src/main/java/org/citrusframework/simulator/http/HttpRequestPathScenarioMapper.java index ed4c4a475..1d7c9e0d9 100644 --- a/simulator-spring-boot/src/main/java/org/citrusframework/simulator/http/HttpRequestPathScenarioMapper.java +++ b/simulator-spring-boot/src/main/java/org/citrusframework/simulator/http/HttpRequestPathScenarioMapper.java @@ -16,6 +16,7 @@ package org.citrusframework.simulator.http; +import java.util.Objects; import org.citrusframework.http.message.HttpMessage; import org.citrusframework.message.Message; import org.citrusframework.simulator.config.SimulatorConfigurationProperties; @@ -44,23 +45,19 @@ public class HttpRequestPathScenarioMapper extends AbstractScenarioMapper implem @Override protected String getMappingKey(Message request) { - if (request instanceof HttpMessage) { - String requestPath = ((HttpMessage) request).getPath(); + if (request instanceof HttpMessage httpMessage) { + String requestPath = httpMessage.getPath(); if (requestPath != null) { for (HttpOperationScenario scenario : scenarioList) { - if (scenario.getPath().equals(requestPath)) { - if (scenario.getMethod().name().equals(((HttpMessage) request).getRequestMethod().name())) { - return scenario.getOperation().getOperationId(); - } + if (Objects.equals(scenario.getMethod(), ((HttpMessage) request).getRequestMethod().name()) && Objects.equals(requestPath, scenario.getPath())) { + return scenario.getScenarioId(); } } for (HttpOperationScenario scenario : scenarioList) { - if (pathMatcher.match(scenario.getPath(), requestPath)) { - if (scenario.getMethod().name().equals(((HttpMessage) request).getRequestMethod().name())) { - return scenario.getOperation().getOperationId(); - } + if (Objects.equals(scenario.getMethod(), ((HttpMessage) request).getRequestMethod().name()) && pathMatcher.match(scenario.getPath(), requestPath)) { + return scenario.getScenarioId(); } } } @@ -90,8 +87,8 @@ public void setHttpScenarios(List httpScenarios) { @Override public void setScenarioList(List scenarioList) { this.scenarioList = scenarioList.stream() - .filter(scenario -> scenario instanceof HttpOperationScenario) - .map(scenario -> (HttpOperationScenario) scenario) + .filter(HttpOperationScenario.class::isInstance) + .map(HttpOperationScenario.class::cast) .toList(); } diff --git a/simulator-spring-boot/src/main/java/org/citrusframework/simulator/http/HttpResponseActionBuilderProvider.java b/simulator-spring-boot/src/main/java/org/citrusframework/simulator/http/HttpResponseActionBuilderProvider.java new file mode 100644 index 000000000..a68b230b5 --- /dev/null +++ b/simulator-spring-boot/src/main/java/org/citrusframework/simulator/http/HttpResponseActionBuilderProvider.java @@ -0,0 +1,15 @@ +package org.citrusframework.simulator.http; + +import io.apicurio.datamodels.openapi.models.OasOperation; +import org.citrusframework.http.actions.HttpServerResponseActionBuilder; +import org.citrusframework.message.Message; + +/** + * Interface for providing an {@link HttpServerResponseActionBuilder} based on an OpenAPI operation and a received message. + */ +public interface HttpResponseActionBuilderProvider { + + HttpServerResponseActionBuilder provideHttpServerResponseActionBuilder(OasOperation oasOperation, + Message 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 c39673a1b..d7462bbac 100644 --- a/simulator-spring-boot/src/main/java/org/citrusframework/simulator/http/HttpScenarioGenerator.java +++ b/simulator-spring-boot/src/main/java/org/citrusframework/simulator/http/HttpScenarioGenerator.java @@ -16,17 +16,19 @@ package org.citrusframework.simulator.http; -import static org.citrusframework.util.FileUtils.readToString; import static org.springframework.beans.factory.support.BeanDefinitionBuilder.genericBeanDefinition; -import io.swagger.models.Model; -import io.swagger.models.Operation; -import io.swagger.models.Path; -import io.swagger.models.Swagger; -import io.swagger.parser.SwaggerParser; -import java.io.IOException; +import io.apicurio.datamodels.combined.visitors.CombinedVisitorAdapter; +import io.apicurio.datamodels.openapi.models.OasDocument; +import io.apicurio.datamodels.openapi.models.OasOperation; +import io.apicurio.datamodels.openapi.models.OasPathItem; +import io.apicurio.datamodels.openapi.models.OasPaths; +import jakarta.annotation.Nonnull; import java.util.Map; -import org.citrusframework.simulator.exception.SimulatorException; +import org.citrusframework.context.TestContext; +import org.citrusframework.openapi.OpenApiSpecification; +import org.citrusframework.openapi.model.OasModelHelper; +import org.citrusframework.simulator.config.OpenApiScenarioIdGenerationMode; import org.citrusframework.spi.CitrusResourceWrapper; import org.citrusframework.spi.Resource; import org.slf4j.Logger; @@ -38,7 +40,6 @@ import org.springframework.beans.factory.support.BeanDefinitionRegistry; import org.springframework.core.io.support.PathMatchingResourcePatternResolver; import org.springframework.util.Assert; -import org.springframework.web.bind.annotation.RequestMethod; /** * @author Christoph Deppisch @@ -48,9 +49,11 @@ public class HttpScenarioGenerator implements BeanFactoryPostProcessor { private static final Logger logger = LoggerFactory.getLogger(HttpScenarioGenerator.class); /** - * Target swagger API to generate scenarios from + * Target Open API to generate scenarios from */ - private final Resource swaggerResource; + private final Resource openApiResource; + + private OpenApiSpecification openApiSpecification; /** * Optional context path @@ -61,74 +64,120 @@ public class HttpScenarioGenerator implements BeanFactoryPostProcessor { * Constructor using Spring environment. */ public HttpScenarioGenerator(SimulatorRestConfigurationProperties simulatorRestConfigurationProperties) { - swaggerResource = new CitrusResourceWrapper( + openApiResource = new CitrusResourceWrapper( new PathMatchingResourcePatternResolver() - .getResource(simulatorRestConfigurationProperties.getSwagger().getApi()) + .getResource(simulatorRestConfigurationProperties.getOpenApi().getApi()) ); - contextPath = simulatorRestConfigurationProperties.getSwagger().getContextPath(); + contextPath = simulatorRestConfigurationProperties.getOpenApi().getContextPath(); } /** * Constructor using swagger API file resource. * - * @param swaggerResource + * @param openApiResource */ - public HttpScenarioGenerator(Resource swaggerResource) { - this.swaggerResource = swaggerResource; + public HttpScenarioGenerator(Resource openApiResource) { + this.openApiResource = openApiResource; + } + + public HttpScenarioGenerator(OpenApiSpecification openApiSpecification) { + this.openApiResource = null; + this.openApiSpecification = openApiSpecification; } @Override - public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) throws BeansException { - try { - Assert.notNull(swaggerResource, - "Missing either swagger api system property setting or explicit swagger api resource for scenario auto generation"); + public 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); + } - Swagger swagger = new SwaggerParser().parse(readToString(swaggerResource)); + HttpResponseActionBuilderProvider httpResponseActionBuilderProvider = retrieveOptionalBuilderProvider( + beanFactory); - for (Map.Entry path : swagger.getPaths().entrySet()) { - for (Map.Entry operation : path.getValue().getOperationMap().entrySet()) { + OpenApiScenarioIdGenerationMode openApiScenarioIdGenerationMode = beanFactory.getBean( + SimulatorRestConfigurationProperties.class).getOpenApiScenarioIdGenerationMode(); - if (beanFactory instanceof BeanDefinitionRegistry beanDefinitionRegistry) { - logger.info("Register auto generated scenario as bean definition: {}", operation.getValue().getOperationId()); + TestContext testContext = new TestContext(); + OasDocument openApiDocument = openApiSpecification.getOpenApiDoc(testContext); + if (openApiDocument != null && openApiDocument.paths != null) { + openApiDocument.paths.accept(new CombinedVisitorAdapter() { - BeanDefinitionBuilder beanDefinitionBuilder = genericBeanDefinition(HttpOperationScenario.class) - .addConstructorArgValue((contextPath + (swagger.getBasePath() != null ? swagger.getBasePath() : "")) + path.getKey()) - .addConstructorArgValue(RequestMethod.valueOf(operation.getKey().name())) - .addConstructorArgValue(operation.getValue()) - .addConstructorArgValue(swagger.getDefinitions()); + @Override + public void visitPaths(OasPaths oasPaths) { + oasPaths.getPathItems().forEach(oasPathItem -> oasPathItem.accept(this)); + } - if (beanFactory.containsBeanDefinition("inboundJsonDataDictionary")) { - beanDefinitionBuilder.addPropertyReference("inboundDataDictionary", "inboundJsonDataDictionary"); - } + @Override + public void visitPathItem(OasPathItem oasPathItem) { + String path = oasPathItem.getPath(); + for (Map.Entry operationEntry : OasModelHelper.getOperationMap( + oasPathItem).entrySet()) { - if (beanFactory.containsBeanDefinition("outboundJsonDataDictionary")) { - beanDefinitionBuilder.addPropertyReference("outboundDataDictionary", "outboundJsonDataDictionary"); - } + 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); - beanDefinitionRegistry.registerBeanDefinition(operation.getValue().getOperationId(), beanDefinitionBuilder.getBeanDefinition()); - } else { - logger.info("Register auto generated scenario as singleton: {}", operation.getValue().getOperationId()); - beanFactory.registerSingleton(operation.getValue().getOperationId(), createScenario((contextPath + (swagger.getBasePath() != null ? swagger.getBasePath() : "")) + path.getKey(), RequestMethod.valueOf(operation.getKey().name()), operation.getValue(), swagger.getDefinitions())); + 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)); + } } } - } - } catch (IOException e) { - throw new SimulatorException("Failed to read swagger api resource", e); + }); + } + } + + private static HttpResponseActionBuilderProvider retrieveOptionalBuilderProvider( + ConfigurableListableBeanFactory beanFactory) { + HttpResponseActionBuilderProvider httpResponseActionBuilderProvider = null; + try { + httpResponseActionBuilderProvider = beanFactory.getBean( + HttpResponseActionBuilderProvider.class); + } catch (BeansException e) { + // Ignore non existing optional provider } + return httpResponseActionBuilderProvider; } /** * Creates an HTTP scenario based on the given swagger path and operation information. * - * @param path Request path - * @param method Request method - * @param operation Swagger operation - * @param definitions Additional definitions + * @param path Full request path, including the context + * @param scenarioId Request method + * @param openApiSpecification OpenApiSpecification + * @param operation OpenApi operation * @return a matching HTTP scenario */ - protected HttpOperationScenario createScenario(String path, RequestMethod method, Operation operation, Map definitions) { - return new HttpOperationScenario(path, method, operation, definitions); + protected HttpOperationScenario createScenario(String path, String scenarioId, OpenApiSpecification openApiSpecification, OasOperation operation, HttpResponseActionBuilderProvider httpResponseActionBuilderProvider) { + return new HttpOperationScenario(path, scenarioId, openApiSpecification, operation, httpResponseActionBuilderProvider); } public String getContextPath() { @@ -137,5 +186,9 @@ public String getContextPath() { public void setContextPath(String contextPath) { this.contextPath = contextPath; + + if (openApiSpecification != null) { + openApiSpecification.setRootContextPath(contextPath); + } } } diff --git a/simulator-spring-boot/src/main/java/org/citrusframework/simulator/http/SimulatorRestConfigurationProperties.java b/simulator-spring-boot/src/main/java/org/citrusframework/simulator/http/SimulatorRestConfigurationProperties.java index e962dd213..eeaeffd73 100644 --- a/simulator-spring-boot/src/main/java/org/citrusframework/simulator/http/SimulatorRestConfigurationProperties.java +++ b/simulator-spring-boot/src/main/java/org/citrusframework/simulator/http/SimulatorRestConfigurationProperties.java @@ -16,21 +16,25 @@ package org.citrusframework.simulator.http; +import static java.util.Collections.emptyList; +import static java.util.Collections.unmodifiableList; + import jakarta.validation.constraints.NotNull; +import java.util.List; +import lombok.Getter; +import lombok.Setter; import org.apache.commons.lang3.builder.ToStringBuilder; +import org.citrusframework.simulator.config.OpenApiScenarioIdGenerationMode; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.InitializingBean; import org.springframework.boot.context.properties.ConfigurationProperties; -import java.util.List; - -import static java.util.Collections.emptyList; -import static java.util.Collections.unmodifiableList; - /** * @author Christoph Deppisch */ +@Getter +@Setter @ConfigurationProperties(prefix = "citrus.simulator.rest") public class SimulatorRestConfigurationProperties implements InitializingBean { @@ -47,25 +51,15 @@ public class SimulatorRestConfigurationProperties implements InitializingBean { */ private List urlMappings = List.of("/services/rest/**"); - private Swagger swagger = new Swagger(); - /** - * Gets the enabled. - * - * @return + * The scenario id generation mode for open api scenario generation. */ - public boolean isEnabled() { - return enabled; - } + private OpenApiScenarioIdGenerationMode openApiScenarioIdGenerationMode = OpenApiScenarioIdGenerationMode.FULL_PATH; /** - * Sets the enabled. - * - * @param enabled + * The OpenApi used by the simulator to simulate OpenApi operations. */ - public void setEnabled(boolean enabled) { - this.enabled = enabled; - } + private OpenApi openApi = new OpenApi(); /** * Gets the urlMappings. @@ -86,14 +80,6 @@ public void setUrlMappings(List urlMappings) { this.urlMappings = urlMappings != null ? unmodifiableList(urlMappings) : emptyList(); } - public Swagger getSwagger() { - return swagger; - } - - public void setSwagger(Swagger swagger) { - this.swagger = swagger; - } - @Override public void afterPropertiesSet() throws Exception { logger.info("Using the simulator configuration: {}", this); @@ -107,34 +93,12 @@ public String toString() { .toString(); } - public static class Swagger { + @Getter + @Setter + public static class OpenApi { private String api; private String contextPath; private boolean enabled = false; - - public String getApi() { - return api; - } - - public void setApi(String api) { - this.api = api; - } - - public String getContextPath() { - return contextPath; - } - - public void setContextPath(String contextPath) { - this.contextPath = contextPath; - } - - public boolean isEnabled() { - return enabled; - } - - public void setEnabled(boolean enabled) { - this.enabled = enabled; - } } } diff --git a/simulator-spring-boot/src/main/java/org/citrusframework/simulator/scenario/mapper/ScenarioMappers.java b/simulator-spring-boot/src/main/java/org/citrusframework/simulator/scenario/mapper/ScenarioMappers.java index b0edaa50e..1727deb47 100644 --- a/simulator-spring-boot/src/main/java/org/citrusframework/simulator/scenario/mapper/ScenarioMappers.java +++ b/simulator-spring-boot/src/main/java/org/citrusframework/simulator/scenario/mapper/ScenarioMappers.java @@ -16,7 +16,10 @@ package org.citrusframework.simulator.scenario.mapper; -import io.swagger.models.Operation; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; import org.citrusframework.message.Message; import org.citrusframework.simulator.config.SimulatorConfigurationPropertiesAware; import org.citrusframework.simulator.http.HttpOperationScenario; @@ -28,11 +31,6 @@ import org.springframework.core.annotation.AnnotationUtils; import org.springframework.util.StringUtils; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; -import java.util.Optional; - /** * Scenario mapper chain goes through a list of mappers to find best match of extracted mapping keys. When no suitable * mapping key is found in the list of mappers a default mapping is used based on provided base class evaluation. @@ -67,8 +65,8 @@ public static ScenarioMappers of(ScenarioMapper ... scenarioMappers) { public String getMappingKey(Message message) { return scenarioMapperList.stream() .map(mapper -> { - if (mapper instanceof AbstractScenarioMapper) { - ((AbstractScenarioMapper) mapper).setUseDefaultMapping(false); + if (mapper instanceof AbstractScenarioMapper abstractScenarioMapper) { + abstractScenarioMapper.setUseDefaultMapping(false); } try { @@ -82,11 +80,8 @@ public String getMappingKey(Message message) { .filter(StringUtils::hasLength) .filter(key -> scenarioList.parallelStream() .anyMatch(scenario -> { - if (scenario instanceof HttpOperationScenario) { - return Optional.ofNullable(((HttpOperationScenario) scenario).getOperation()) - .map(Operation::getOperationId) - .orElse("") - .equals(key); + if (scenario instanceof HttpOperationScenario httpOperationScenario) { + return key.equals(httpOperationScenario.getScenarioId()); } return Optional.ofNullable(AnnotationUtils.findAnnotation(scenario.getClass(), Scenario.class)) @@ -101,13 +96,13 @@ public String getMappingKey(Message message) { @Override public void afterPropertiesSet() { scenarioMapperList.stream() - .filter(mapper -> mapper instanceof ScenarioListAware) - .map(mapper -> (ScenarioListAware) mapper) + .filter(ScenarioListAware.class::isInstance) + .map(ScenarioListAware.class::cast) .forEach(mapper -> mapper.setScenarioList(scenarioList)); scenarioMapperList.stream() - .filter(mapper -> mapper instanceof SimulatorConfigurationPropertiesAware) - .map(mapper -> (SimulatorConfigurationPropertiesAware) mapper) + .filter(SimulatorConfigurationPropertiesAware.class::isInstance) + .map(SimulatorConfigurationPropertiesAware.class::cast) .forEach(mapper -> mapper.setSimulatorConfigurationProperties(getSimulatorConfigurationProperties())); } diff --git a/simulator-spring-boot/src/main/resources/META-INF/citrus/openapi/processor/scenarioRegistrar b/simulator-spring-boot/src/main/resources/META-INF/citrus/openapi/processor/scenarioRegistrar new file mode 100644 index 000000000..4c6b54b46 --- /dev/null +++ b/simulator-spring-boot/src/main/resources/META-INF/citrus/openapi/processor/scenarioRegistrar @@ -0,0 +1,2 @@ +name=httpOperationScenarioRegistrar +type=org.citrusframework.simulator.http.HttpOperationScenarioRegistrar diff --git a/simulator-spring-boot/src/test/java/org/citrusframework/simulator/http/HttpOperationScenarioIT.java b/simulator-spring-boot/src/test/java/org/citrusframework/simulator/http/HttpOperationScenarioIT.java new file mode 100644 index 000000000..91f12d81b --- /dev/null +++ b/simulator-spring-boot/src/test/java/org/citrusframework/simulator/http/HttpOperationScenarioIT.java @@ -0,0 +1,244 @@ +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 java.io.IOException; +import java.util.List; +import java.util.function.Function; +import java.util.stream.Stream; +import org.citrusframework.context.TestContext; +import org.citrusframework.exceptions.CitrusRuntimeException; +import org.citrusframework.exceptions.TestCaseFailedException; +import org.citrusframework.functions.DefaultFunctionRegistry; +import org.citrusframework.http.actions.HttpServerResponseActionBuilder; +import org.citrusframework.http.message.HttpMessage; +import org.citrusframework.log.DefaultLogModifier; +import org.citrusframework.message.Message; +import org.citrusframework.openapi.OpenApiRepository; +import org.citrusframework.openapi.actions.OpenApiClientResponseActionBuilder; +import org.citrusframework.openapi.model.OasModelHelper; +import org.citrusframework.simulator.config.SimulatorConfigurationProperties; +import org.citrusframework.simulator.scenario.ScenarioEndpoint; +import org.citrusframework.simulator.scenario.ScenarioEndpointConfiguration; +import org.citrusframework.simulator.scenario.ScenarioRunner; +import org.citrusframework.spi.Resources.ClasspathResource; +import org.citrusframework.util.FileUtils; +import org.citrusframework.validation.DefaultMessageHeaderValidator; +import org.citrusframework.validation.DefaultMessageValidatorRegistry; +import org.citrusframework.validation.context.HeaderValidationContext; +import org.citrusframework.validation.json.JsonMessageValidationContext; +import org.citrusframework.validation.json.JsonTextMessageValidator; +import org.citrusframework.validation.matcher.DefaultValidationMatcherRegistry; +import org.junit.jupiter.api.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.context.ConfigurableApplicationContext; +import org.springframework.http.HttpHeaders; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.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() { + testContext = new TestContext(); + testContext.setReferenceResolver(mock()); + testContext.setMessageValidatorRegistry(new DefaultMessageValidatorRegistry()); + testContext.setFunctionRegistry(new DefaultFunctionRegistry()); + testContext.setValidationMatcherRegistry(new DefaultValidationMatcherRegistry()); + testContext.setLogModifier(new DefaultLogModifier()); + scenarioRunner = new ScenarioRunner(scenarioEndpoint, mock(), 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_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]+'") + ); + } + + @ParameterizedTest(name="{0}") + @MethodSource() + void scenarioExecution(String name, String operationName, String payloadFile, Function urlAdjuster, String exceptionMessage) + throws IOException { + if (defaultListableBeanFactory.containsSingleton("httpResponseActionBuilderProvider")) { + defaultListableBeanFactory.destroySingleton("httpResponseActionBuilderProvider"); + } + + HttpOperationScenario httpOperationScenario = getHttpOperationScenario(operationName); + HttpMessage controlMessage = new HttpMessage(); + OpenApiClientResponseActionBuilder.fillMessageFromResponse(httpOperationScenario.getOpenApiSpecification(), testContext, controlMessage, httpOperationScenario.getOperation(), httpOperationScenario.getResponse()); + + this.scenarioExecution(operationName, payloadFile, urlAdjuster, exceptionMessage, controlMessage); + } + + @ParameterizedTest(name="{0}_custom_payload") + @MethodSource("scenarioExecution") + void scenarioExecutionWithProvider(String name, String operationName, String payloadFile, Function urlAdjuster, String exceptionMessage) { + + String payload = "{\"id\":1234}"; + HttpResponseActionBuilderProvider httpResponseActionBuilderProvider = (oasOperation, receivedMessage) -> { + HttpServerResponseActionBuilder serverResponseActionBuilder = new HttpServerResponseActionBuilder(); + serverResponseActionBuilder + .endpoint(scenarioEndpoint) + .getMessageBuilderSupport() + .body(payload); + return serverResponseActionBuilder; + }; + + if (!defaultListableBeanFactory.containsSingleton("httpResponseActionBuilderProvider")) { + defaultListableBeanFactory.registerSingleton("httpResponseActionBuilderProvider", + httpResponseActionBuilderProvider); + } + + HttpOperationScenario httpOperationScenario = getHttpOperationScenario(operationName); + try { + ReflectionTestUtils.setField(httpOperationScenario, "httpResponseActionBuilderProvider", + httpResponseActionBuilderProvider); + + HttpMessage correctPayloadMessage = new HttpMessage(payload); + assertThatCode(() -> this.scenarioExecution(operationName, payloadFile, urlAdjuster, exceptionMessage, + correctPayloadMessage)).doesNotThrowAnyException(); + + if (exceptionMessage == null) { + String otherPayload = "{\"id\":12345}"; + HttpMessage incorrectPayloadMessage = new HttpMessage(otherPayload); + assertThatThrownBy( + () -> this.scenarioExecution(operationName, payloadFile, urlAdjuster, + exceptionMessage, + incorrectPayloadMessage)).isInstanceOf(CitrusRuntimeException.class); + } + } finally { + ReflectionTestUtils.setField(httpOperationScenario, "httpResponseActionBuilderProvider", + null); + } + } + + private void scenarioExecution(String operationName, String payloadFile, Function urlAdjuster, String exceptionMessage, Message controlMessage) + throws IOException { + HttpOperationScenario httpOperationScenario = getHttpOperationScenario(operationName); + OasOperation oasOperation = httpOperationScenario.getOperation(); + + String payload = payloadFile != null ? FileUtils.readToString(new ClasspathResource(payloadFile)) : null; + + Message receiveMessage = new HttpMessage() + .setPayload(payload) + .setHeader("citrus_http_request_uri", urlAdjuster.apply(httpOperationScenario.getPath())) + .setHeader("citrus_http_method", httpOperationScenario.getMethod().toUpperCase()); + + OasModelHelper.getRequestContentType(oasOperation) + .ifPresent(contentType -> receiveMessage.setHeader(HttpHeaders.CONTENT_TYPE, contentType)); + + scenarioEndpoint.setReceiveMessage(receiveMessage); + + ReflectionTestUtils.setField(httpOperationScenario, "scenarioEndpoint", + scenarioEndpoint); + + if (exceptionMessage != null) { + assertThatThrownBy(() -> httpOperationScenario.run(scenarioRunner)).isInstanceOf( + TestCaseFailedException.class).hasMessage(exceptionMessage); + } else { + assertThatCode(() -> httpOperationScenario.run(scenarioRunner)).doesNotThrowAnyException(); + + Message sendMessage = scenarioEndpoint.getSendMessage(); + + JsonTextMessageValidator jsonTextMessageValidator = new JsonTextMessageValidator(); + jsonTextMessageValidator.validateMessage(sendMessage, controlMessage, testContext, + List.of(new JsonMessageValidationContext())); + DefaultMessageHeaderValidator defaultMessageHeaderValidator = new DefaultMessageHeaderValidator(); + defaultMessageHeaderValidator.validateMessage(sendMessage, controlMessage, testContext, List.of(new HeaderValidationContext())); + } + + } + + private HttpOperationScenario getHttpOperationScenario(String operationName) { + Object bean = defaultListableBeanFactory.getBean(operationName); + + assertThat(bean).isInstanceOf(HttpOperationScenario.class); + + return (HttpOperationScenario) bean; + } + + private static class DirectScenarioEndpoint extends ScenarioEndpoint { + + private Message receiveMessage; + + private Message sendMessage; + + public DirectScenarioEndpoint() { + super(new ScenarioEndpointConfiguration()); + } + + @Override + public void send(Message message, TestContext context) { + this.sendMessage = new HttpMessage(message); + + if (sendMessage.getPayload() instanceof String stringPayload) { + this.sendMessage.setPayload( + context.replaceDynamicContentInString(stringPayload)); + } + } + + @Override + public Message receive(TestContext context) { + return receiveMessage; + } + + @Override + public Message receive(TestContext context, long timeout) { + return receiveMessage; + } + + public void setReceiveMessage(Message receiveMessage) { + this.receiveMessage = receiveMessage; + } + + public Message getSendMessage() { + return sendMessage; + } + + } +} diff --git a/simulator-spring-boot/src/test/java/org/citrusframework/simulator/http/HttpRequestPathScenarioMapperTest.java b/simulator-spring-boot/src/test/java/org/citrusframework/simulator/http/HttpRequestPathScenarioMapperTest.java index 6d4c69e54..41682e562 100644 --- a/simulator-spring-boot/src/test/java/org/citrusframework/simulator/http/HttpRequestPathScenarioMapperTest.java +++ b/simulator-spring-boot/src/test/java/org/citrusframework/simulator/http/HttpRequestPathScenarioMapperTest.java @@ -16,33 +16,46 @@ package org.citrusframework.simulator.http; -import io.swagger.models.Operation; +import static org.assertj.core.api.Assertions.fail; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; + +import io.apicurio.datamodels.openapi.models.OasDocument; +import io.apicurio.datamodels.openapi.models.OasOperation; +import io.apicurio.datamodels.openapi.v2.models.Oas20Document; +import io.apicurio.datamodels.openapi.v2.models.Oas20Operation; +import io.apicurio.datamodels.openapi.v3.models.Oas30Document; +import io.apicurio.datamodels.openapi.v3.models.Oas30Operation; +import java.util.Arrays; import org.citrusframework.exceptions.CitrusRuntimeException; import org.citrusframework.http.message.HttpMessage; +import org.citrusframework.openapi.OpenApiSpecification; import org.citrusframework.simulator.config.SimulatorConfigurationProperties; import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.mockito.Mock; -import org.mockito.Mockito; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.http.HttpMethod; import org.springframework.web.bind.annotation.RequestMethod; -import java.util.Arrays; -import java.util.Collections; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.mockito.Mockito.doReturn; -import static org.mockito.Mockito.when; - /** * @author Christoph Deppisch */ @ExtendWith(MockitoExtension.class) class HttpRequestPathScenarioMapperTest { + public static final String DEFAULT_SCENARIO = "default"; + public static final String FOO_LIST_SCENARIO = "fooListScenario"; + public static final String FOO_LIST_POST_SCENARIO = "fooListPostScenario"; + public static final String BAR_LIST_SCENARIO = "barListScenario"; + public static final String FOO_SCENARIO = "fooScenario"; + public static final String BAR_SCENARIO = "barScenario"; + public static final String FOO_DETAIL_SCENARIO = "fooDetailScenario"; + public static final String BAR_DETAIL_SCENARIO = "barDetailScenario"; @Mock private SimulatorConfigurationProperties simulatorConfigurationMock; @@ -53,42 +66,60 @@ void beforeEachSetup() { fixture = new HttpRequestPathScenarioMapper(); fixture.setConfiguration(simulatorConfigurationMock); - doReturn("default").when(simulatorConfigurationMock).getDefaultScenario(); + doReturn(DEFAULT_SCENARIO).when(simulatorConfigurationMock).getDefaultScenario(); } - @Test - void testGetMappingKey() { - Operation operation = Mockito.mock(Operation.class); - - fixture.setScenarioList(Arrays.asList(new HttpOperationScenario("/issues/foos", RequestMethod.GET, operation, Collections.emptyMap()), - new HttpOperationScenario("/issues/foos", RequestMethod.POST, operation, Collections.emptyMap()), - new HttpOperationScenario("/issues/foo/{id}", RequestMethod.GET, operation, Collections.emptyMap()), - new HttpOperationScenario("/issues/foo/detail", RequestMethod.GET, operation, Collections.emptyMap()), - new HttpOperationScenario("/issues/bars", RequestMethod.GET, operation, Collections.emptyMap()), - new HttpOperationScenario("/issues/bar/{id}", RequestMethod.GET, operation, Collections.emptyMap()), - new HttpOperationScenario("/issues/bar/detail", RequestMethod.GET, operation, Collections.emptyMap()))); - - when(operation.getOperationId()) - .thenReturn("fooListScenario") - .thenReturn("fooListPostScenario") - .thenReturn("barListScenario") - .thenReturn("fooScenario") - .thenReturn("barScenario") - .thenReturn("fooDetailScenario") - .thenReturn("barDetailScenario"); - - assertEquals(fixture.getMappingKey(new HttpMessage().method(HttpMethod.GET)), "default"); - assertEquals(fixture.getMappingKey(new HttpMessage().method(HttpMethod.POST)), "default"); - assertEquals(fixture.getMappingKey(new HttpMessage().method(HttpMethod.GET).path("/issues")), "default"); - assertEquals(fixture.getMappingKey(new HttpMessage().method(HttpMethod.GET).path("/issues/foos")), "fooListScenario"); - assertEquals(fixture.getMappingKey(new HttpMessage().method(HttpMethod.POST).path("/issues/foos")), "fooListPostScenario"); - assertEquals(fixture.getMappingKey(new HttpMessage().method(HttpMethod.PUT).path("/issues/foos")), "default"); - assertEquals(fixture.getMappingKey(new HttpMessage().method(HttpMethod.GET).path("/issues/bars")), "barListScenario"); - assertEquals(fixture.getMappingKey(new HttpMessage().method(HttpMethod.DELETE).path("/issues/bars")), "default"); - assertEquals(fixture.getMappingKey(new HttpMessage().method(HttpMethod.GET).path("/issues/foo/1")), "fooScenario"); - assertEquals(fixture.getMappingKey(new HttpMessage().method(HttpMethod.GET).path("/issues/bar/1")), "barScenario"); - assertEquals(fixture.getMappingKey(new HttpMessage().method(HttpMethod.GET).path("/issues/foo/detail")), "fooDetailScenario"); - assertEquals(fixture.getMappingKey(new HttpMessage().method(HttpMethod.GET).path("/issues/bar/detail")), "barDetailScenario"); + @ParameterizedTest + @ValueSource(strings = {"oas2", "oas3"}) + void testGetMappingKey(String version) { + OpenApiSpecification openApiSpecificationMock = mock(); + + OasDocument oasDocument = null; + if ("oas2".equals(version)) { + oasDocument = mock(Oas20Document.class); + } else if ("oas3".equals(version)) { + oasDocument = mock(Oas30Document.class); + } else { + fail("Unexpected version: "+ version); + } + + 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), + new HttpOperationScenario("/issues/foo/{id}", FOO_SCENARIO, openApiSpecificationMock, mockOperation(oasDocument, RequestMethod.GET), null), + new HttpOperationScenario("/issues/foo/detail", FOO_DETAIL_SCENARIO, openApiSpecificationMock, mockOperation(oasDocument, RequestMethod.GET), null), + new HttpOperationScenario("/issues/bars", BAR_LIST_SCENARIO, openApiSpecificationMock, mockOperation(oasDocument, RequestMethod.GET), null), + new HttpOperationScenario("/issues/bar/{id}", BAR_SCENARIO, openApiSpecificationMock, mockOperation(oasDocument, RequestMethod.GET), null), + new HttpOperationScenario("/issues/bar/detail", BAR_DETAIL_SCENARIO, openApiSpecificationMock, mockOperation(oasDocument, RequestMethod.GET), null))); + + assertEquals(DEFAULT_SCENARIO, + fixture.getMappingKey(new HttpMessage().method(HttpMethod.GET))); + assertEquals(DEFAULT_SCENARIO, + fixture.getMappingKey(new HttpMessage().method(HttpMethod.POST))); + assertEquals(DEFAULT_SCENARIO, + fixture.getMappingKey(new HttpMessage().method(HttpMethod.GET).path("/issues"))); + assertEquals(FOO_LIST_SCENARIO, + fixture.getMappingKey(new HttpMessage().method(HttpMethod.GET).path("/issues/foos"))); + assertEquals(FOO_LIST_POST_SCENARIO, + fixture.getMappingKey(new HttpMessage().method(HttpMethod.POST).path("/issues/foos"))); + assertEquals(DEFAULT_SCENARIO, + fixture.getMappingKey(new HttpMessage().method(HttpMethod.PUT).path("/issues/foos"))); + assertEquals(BAR_LIST_SCENARIO, + fixture.getMappingKey(new HttpMessage().method(HttpMethod.GET).path("/issues/bars"))); + assertEquals(DEFAULT_SCENARIO, + fixture.getMappingKey(new HttpMessage().method(HttpMethod.DELETE).path("/issues/bars"))); + assertEquals(FOO_SCENARIO, + fixture.getMappingKey(new HttpMessage().method(HttpMethod.GET).path("/issues/foo/1"))); + assertEquals(BAR_SCENARIO, + fixture.getMappingKey(new HttpMessage().method(HttpMethod.GET).path("/issues/bar/1"))); + assertEquals(FOO_DETAIL_SCENARIO, + fixture.getMappingKey( + new HttpMessage().method(HttpMethod.GET).path("/issues/foo/detail"))); + assertEquals(BAR_DETAIL_SCENARIO, + fixture.getMappingKey( + new HttpMessage().method(HttpMethod.GET).path("/issues/bar/detail"))); fixture.setUseDefaultMapping(false); @@ -98,4 +129,18 @@ void testGetMappingKey() { HttpMessage httpGetIssuesMessage = new HttpMessage().method(HttpMethod.GET).path("/issues"); assertThrows(CitrusRuntimeException.class, () -> fixture.getMappingKey(httpGetIssuesMessage)); } + + private OasOperation mockOperation(OasDocument oasDocument, RequestMethod requestMethod) { + + OasOperation oasOperationMock = null; + if (oasDocument instanceof Oas20Document) { + oasOperationMock = mock(Oas20Operation.class); + } else if (oasDocument instanceof Oas30Document) { + oasOperationMock = mock(Oas30Operation.class); + } else { + fail("Unexpected version document type!"); + } + doReturn(requestMethod.toString()).when(oasOperationMock).getMethod(); + return oasOperationMock; + } } diff --git a/simulator-spring-boot/src/test/java/org/citrusframework/simulator/http/HttpScenarioGeneratorTest.java b/simulator-spring-boot/src/test/java/org/citrusframework/simulator/http/HttpScenarioGeneratorTest.java index a13140bd7..553c4bb57 100644 --- a/simulator-spring-boot/src/test/java/org/citrusframework/simulator/http/HttpScenarioGeneratorTest.java +++ b/simulator-spring-boot/src/test/java/org/citrusframework/simulator/http/HttpScenarioGeneratorTest.java @@ -16,26 +16,28 @@ package org.citrusframework.simulator.http; -import io.swagger.models.Operation; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +import io.apicurio.datamodels.openapi.models.OasOperation; +import org.citrusframework.openapi.OpenApiSpecification; import org.citrusframework.spi.Resources; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.BeanFactory; import org.springframework.beans.factory.config.BeanDefinition; import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; +import org.springframework.beans.factory.config.ConstructorArgumentValues.ValueHolder; import org.springframework.beans.factory.support.DefaultListableBeanFactory; -import org.springframework.web.bind.annotation.RequestMethod; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.doAnswer; -import static org.mockito.Mockito.doReturn; -import static org.mockito.Mockito.verify; /** * @author Christoph Deppisch @@ -43,148 +45,161 @@ @ExtendWith(MockitoExtension.class) class HttpScenarioGeneratorTest { - @Mock - private ConfigurableListableBeanFactory beanFactoryMock; + private HttpScenarioGenerator fixture; - @Mock - private DefaultListableBeanFactory beanRegistryMock; + @ParameterizedTest + @ValueSource(strings={"v2", "v3"}) + void generateHttpScenarios(String version) { + ConfigurableListableBeanFactory beanFactoryMock = mock(); - private HttpScenarioGenerator fixture; + mockBeanFactory(beanFactoryMock); - @BeforeEach - void beforeEachSetup() { - fixture = new HttpScenarioGenerator(new Resources.ClasspathResource("swagger/swagger-api.json")); - } + fixture = new HttpScenarioGenerator(new Resources.ClasspathResource( + "swagger/petstore-"+version+".json")); + + String addPetScenarioId = "POST_/petstore/"+version+"/pet"; + String getPetScenarioId = "GET_/petstore/"+version+"/pet/{petId}"; + String deletePetScenarioId = "DELETE_/petstore/"+version+"/pet/{petId}"; - @Test - void generateHttpScenarios() { + String context = "/petstore/"+ version ; doAnswer(invocation -> { HttpOperationScenario scenario = (HttpOperationScenario) invocation.getArguments()[1]; - - assertNotNull(scenario.getOperation()); - assertEquals(scenario.getPath(), "/v2/pet"); - assertEquals(scenario.getMethod(), RequestMethod.POST); - + assertScenarioProperties(scenario, context+"/pet", addPetScenarioId, "POST"); return null; - }).when(beanFactoryMock).registerSingleton(eq("addPet"), any(HttpOperationScenario.class)); + }).when(beanFactoryMock).registerSingleton(eq(addPetScenarioId), any(HttpOperationScenario.class)); doAnswer(invocation -> { HttpOperationScenario scenario = (HttpOperationScenario) invocation.getArguments()[1]; - - assertNotNull(scenario.getOperation()); - assertEquals(scenario.getPath(), "/v2/pet/{petId}"); - assertEquals(scenario.getMethod(), RequestMethod.GET); - + assertScenarioProperties(scenario, context+"/pet/{petId}", getPetScenarioId, "GET"); return null; - }).when(beanFactoryMock).registerSingleton(eq("getPetById"), any(HttpOperationScenario.class)); + }).when(beanFactoryMock).registerSingleton(eq(getPetScenarioId), any(HttpOperationScenario.class)); doAnswer(invocation -> { HttpOperationScenario scenario = (HttpOperationScenario) invocation.getArguments()[1]; - - assertNotNull(scenario.getOperation()); - assertEquals(scenario.getPath(), "/v2/pet/{petId}"); - assertEquals(scenario.getMethod(), RequestMethod.DELETE); - + assertScenarioProperties(scenario, context+"/pet/{petId}", deletePetScenarioId, "DELETE"); return null; - }).when(beanFactoryMock).registerSingleton(eq("deletePet"), any(HttpOperationScenario.class)); + }).when(beanFactoryMock).registerSingleton(eq(deletePetScenarioId), any(HttpOperationScenario.class)); fixture.postProcessBeanFactory(beanFactoryMock); - verify(beanFactoryMock).registerSingleton(eq("addPet"), any(HttpOperationScenario.class)); - verify(beanFactoryMock).registerSingleton(eq("getPetById"), any(HttpOperationScenario.class)); - verify(beanFactoryMock).registerSingleton(eq("deletePet"), any(HttpOperationScenario.class)); + verify(beanFactoryMock).registerSingleton(eq(addPetScenarioId), any(HttpOperationScenario.class)); + verify(beanFactoryMock).registerSingleton(eq(getPetScenarioId), any(HttpOperationScenario.class)); + verify(beanFactoryMock).registerSingleton(eq(deletePetScenarioId), any(HttpOperationScenario.class)); } - @Test - void testGenerateScenariosWithBeandDefinitionRegistry() { - doAnswer(invocation -> { - BeanDefinition scenario = (BeanDefinition) invocation.getArguments()[1]; + @ParameterizedTest + @ValueSource(strings={"v2", "v3"}) + void testGenerateScenariosWithBeanDefinitionRegistry(String version) { - assertEquals(scenario.getConstructorArgumentValues().getArgumentValue(0, String.class).getValue(), "/v2/pet"); - assertEquals(scenario.getConstructorArgumentValues().getArgumentValue(1, RequestMethod.class).getValue(), RequestMethod.POST); - assertNotNull(scenario.getConstructorArgumentValues().getArgumentValue(2, Operation.class).getValue()); - assertNull(scenario.getPropertyValues().get("inboundDataDictionary")); - assertNull(scenario.getPropertyValues().get("outboundDataDictionary")); + DefaultListableBeanFactory beanRegistryMock = mock(); + mockBeanFactory(beanRegistryMock); - return null; - }).when(beanRegistryMock).registerBeanDefinition(eq("addPet"), any(BeanDefinition.class)); + fixture = new HttpScenarioGenerator(new Resources.ClasspathResource( + "swagger/petstore-"+version+".json")); + + String context = "/petstore/"+ version ; doAnswer(invocation -> { BeanDefinition scenario = (BeanDefinition) invocation.getArguments()[1]; - - assertEquals(scenario.getConstructorArgumentValues().getArgumentValue(0, String.class).getValue(), "/v2/pet/{petId}"); - assertEquals(scenario.getConstructorArgumentValues().getArgumentValue(1, RequestMethod.class).getValue(), RequestMethod.GET); - assertNotNull(scenario.getConstructorArgumentValues().getArgumentValue(2, Operation.class).getValue()); - assertNull(scenario.getPropertyValues().get("inboundDataDictionary")); - assertNull(scenario.getPropertyValues().get("outboundDataDictionary")); - + assertBeanDefinition(scenario, context+"/pet", "POST_/petstore/"+version+"/pet", "post", false); return null; - }).when(beanRegistryMock).registerBeanDefinition(eq("getPetById"), any(BeanDefinition.class)); + }).when(beanRegistryMock).registerBeanDefinition(eq("POST_/petstore/"+version+"/pet"), any(BeanDefinition.class)); doAnswer(invocation -> { BeanDefinition scenario = (BeanDefinition) invocation.getArguments()[1]; + assertBeanDefinition(scenario, context+"/pet/{petId}", "GET_/petstore/"+version+"/pet/{petId}", "get", false); + return null; + }).when(beanRegistryMock).registerBeanDefinition(eq("GET_/petstore/"+version+"/pet/{petId}"), any(BeanDefinition.class)); - assertEquals(scenario.getConstructorArgumentValues().getArgumentValue(0, String.class).getValue(), "/v2/pet/{petId}"); - assertEquals(scenario.getConstructorArgumentValues().getArgumentValue(1, RequestMethod.class).getValue(), RequestMethod.DELETE); - assertNotNull(scenario.getConstructorArgumentValues().getArgumentValue(2, Operation.class).getValue()); - assertNull(scenario.getPropertyValues().get("inboundDataDictionary")); - assertNull(scenario.getPropertyValues().get("outboundDataDictionary")); - + doAnswer(invocation -> { + BeanDefinition scenario = (BeanDefinition) invocation.getArguments()[1]; + assertBeanDefinition(scenario, context+"/pet/{petId}", "DELETE_/petstore/"+version+"/pet/{petId}", "delete", false); return null; - }).when(beanRegistryMock).registerBeanDefinition(eq("deletePet"), any(BeanDefinition.class)); + }).when(beanRegistryMock).registerBeanDefinition(eq("DELETE_/petstore/"+version+"/pet/{petId}"), any(BeanDefinition.class)); fixture.postProcessBeanFactory(beanRegistryMock); - verify(beanRegistryMock).registerBeanDefinition(eq("addPet"), any(BeanDefinition.class)); - verify(beanRegistryMock).registerBeanDefinition(eq("getPetById"), any(BeanDefinition.class)); - verify(beanRegistryMock).registerBeanDefinition(eq("deletePet"), any(BeanDefinition.class)); + verify(beanRegistryMock).registerBeanDefinition(eq("POST_/petstore/"+version+"/pet"), any(BeanDefinition.class)); + verify(beanRegistryMock).registerBeanDefinition(eq("GET_/petstore/"+version+"/pet/{petId}"), any(BeanDefinition.class)); + verify(beanRegistryMock).registerBeanDefinition(eq("DELETE_/petstore/"+version+"/pet/{petId}"), any(BeanDefinition.class)); } - @Test - void testGenerateScenariosWithDataDictionaries() { + @ParameterizedTest + @ValueSource(strings={"v2", "v3"}) + void testGenerateScenariosWithDataDictionariesAtRootContext(String version) { + DefaultListableBeanFactory beanRegistryMock = mock(); + mockBeanFactory(beanRegistryMock); + + fixture = new HttpScenarioGenerator(new Resources.ClasspathResource( + "swagger/petstore-"+version+".json")); + fixture.setContextPath("/services/rest2"); + + String addPetScenarioId = "POST_/petstore/"+version+"/pet"; + String getPetScenarioId = "GET_/petstore/"+version+"/pet/{petId}"; + String deletePetScenarioId = "DELETE_/petstore/"+version+"/pet/{petId}"; + + String context = fixture.getContextPath()+"/petstore/"+ version ; + doReturn(true).when(beanRegistryMock).containsBeanDefinition("inboundJsonDataDictionary"); doReturn(true).when(beanRegistryMock).containsBeanDefinition("outboundJsonDataDictionary"); doAnswer(invocation -> { BeanDefinition scenario = (BeanDefinition) invocation.getArguments()[1]; - - assertEquals(scenario.getConstructorArgumentValues().getArgumentValue(0, String.class).getValue(), "/v2/pet"); - assertEquals(scenario.getConstructorArgumentValues().getArgumentValue(1, RequestMethod.class).getValue(), RequestMethod.POST); - assertNotNull(scenario.getConstructorArgumentValues().getArgumentValue(2, Operation.class).getValue()); - assertNotNull(scenario.getPropertyValues().get("inboundDataDictionary")); - assertNotNull(scenario.getPropertyValues().get("outboundDataDictionary")); - + assertBeanDefinition(scenario, context+"/pet", addPetScenarioId, "post", true); return null; - }).when(beanRegistryMock).registerBeanDefinition(eq("addPet"), any(BeanDefinition.class)); + }).when(beanRegistryMock).registerBeanDefinition(eq(addPetScenarioId), any(BeanDefinition.class)); doAnswer(invocation -> { BeanDefinition scenario = (BeanDefinition) invocation.getArguments()[1]; - - assertEquals(scenario.getConstructorArgumentValues().getArgumentValue(0, String.class).getValue(), "/v2/pet/{petId}"); - assertEquals(scenario.getConstructorArgumentValues().getArgumentValue(1, RequestMethod.class).getValue(), RequestMethod.GET); - assertNotNull(scenario.getConstructorArgumentValues().getArgumentValue(2, Operation.class).getValue()); - assertNotNull(scenario.getPropertyValues().get("inboundDataDictionary")); - assertNotNull(scenario.getPropertyValues().get("outboundDataDictionary")); - + assertBeanDefinition(scenario, context+"/pet/{petId}", getPetScenarioId, "get", true); return null; - }).when(beanRegistryMock).registerBeanDefinition(eq("getPetById"), any(BeanDefinition.class)); + }).when(beanRegistryMock).registerBeanDefinition(eq(getPetScenarioId), any(BeanDefinition.class)); doAnswer(invocation -> { BeanDefinition scenario = (BeanDefinition) invocation.getArguments()[1]; - - assertEquals(scenario.getConstructorArgumentValues().getArgumentValue(0, String.class).getValue(), "/v2/pet/{petId}"); - assertEquals(scenario.getConstructorArgumentValues().getArgumentValue(1, RequestMethod.class).getValue(), RequestMethod.DELETE); - assertNotNull(scenario.getConstructorArgumentValues().getArgumentValue(2, Operation.class).getValue()); - assertNotNull(scenario.getPropertyValues().get("inboundDataDictionary")); - assertNotNull(scenario.getPropertyValues().get("outboundDataDictionary")); - + assertBeanDefinition(scenario, context+"/pet/{petId}",deletePetScenarioId,"delete", true); return null; - }).when(beanRegistryMock).registerBeanDefinition(eq("deletePet"), any(BeanDefinition.class)); + }).when(beanRegistryMock).registerBeanDefinition(eq(deletePetScenarioId), any(BeanDefinition.class)); fixture.postProcessBeanFactory(beanRegistryMock); - verify(beanRegistryMock).registerBeanDefinition(eq("addPet"), any(BeanDefinition.class)); - verify(beanRegistryMock).registerBeanDefinition(eq("getPetById"), any(BeanDefinition.class)); - verify(beanRegistryMock).registerBeanDefinition(eq("deletePet"), any(BeanDefinition.class)); + verify(beanRegistryMock).registerBeanDefinition(eq(addPetScenarioId), any(BeanDefinition.class)); + verify(beanRegistryMock).registerBeanDefinition(eq(getPetScenarioId), any(BeanDefinition.class)); + verify(beanRegistryMock).registerBeanDefinition(eq(deletePetScenarioId), any(BeanDefinition.class)); + } + + private void mockBeanFactory(BeanFactory beanFactory) { + doReturn(new SimulatorRestConfigurationProperties()).when(beanFactory).getBean(SimulatorRestConfigurationProperties.class); + doThrow(new BeansException("No such bean") { + }).when(beanFactory).getBean(HttpResponseActionBuilderProvider.class); + } + + private void assertBeanDefinition(BeanDefinition scenario, String path, String scenarioId, String method, boolean withDictionaries) { + assertThat(getConstructorArgument(scenario, 0)).isEqualTo( path); + assertThat(getConstructorArgument(scenario, 1)).isEqualTo( scenarioId); + assertThat(getConstructorArgument(scenario, 2)).isInstanceOf(OpenApiSpecification.class); + assertThat(getConstructorArgument(scenario, 3)).isInstanceOf(OasOperation.class); + assertThat(((OasOperation)getConstructorArgument(scenario, 3)).getMethod()).isEqualTo(method); + + if (withDictionaries) { + assertThat(scenario.getPropertyValues().get("inboundDataDictionary")).isNotNull(); + assertThat(scenario.getPropertyValues().get("outboundDataDictionary")).isNotNull(); + } else { + assertThat(scenario.getPropertyValues().get("inboundDataDictionary")).isNull(); + assertThat(scenario.getPropertyValues().get("outboundDataDictionary")).isNull(); + } + } + + private static Object getConstructorArgument(BeanDefinition scenario, int index) { + ValueHolder argumentValue = scenario.getConstructorArgumentValues() + .getArgumentValue(index, String.class); + assertThat(argumentValue).isNotNull(); + return argumentValue.getValue(); + } + + private void assertScenarioProperties(HttpOperationScenario scenario, String path, String operationId, String method) { + assertThat(scenario).extracting(HttpOperationScenario::getPath, HttpOperationScenario::getScenarioId).containsExactly(path, operationId); + assertThat(scenario.getMethod()).isEqualTo(method); + assertThat(scenario.getOperation()).isNotNull().extracting(OasOperation::getMethod).isEqualTo(method.toLowerCase()); } } diff --git a/simulator-spring-boot/src/test/resources/data/addPet.json b/simulator-spring-boot/src/test/resources/data/addPet.json new file mode 100644 index 000000000..fae210be0 --- /dev/null +++ b/simulator-spring-boot/src/test/resources/data/addPet.json @@ -0,0 +1,15 @@ +{ + "id": 0, + "category": { + "id": 0, + "name": "string" + }, + "name": "doggie", + "photoUrls": [ + "string" + ], + "tags": [ + {} + ], + "status": "available" +} diff --git a/simulator-spring-boot/src/test/resources/data/addPet_incorrect.json b/simulator-spring-boot/src/test/resources/data/addPet_incorrect.json new file mode 100644 index 000000000..0e4b8f388 --- /dev/null +++ b/simulator-spring-boot/src/test/resources/data/addPet_incorrect.json @@ -0,0 +1,15 @@ +{ + "wrong_id_property": 0, + "category": { + "id": 0, + "name": "string" + }, + "name": "doggie", + "photoUrls": [ + "string" + ], + "tags": [ + {} + ], + "status": "available" +} diff --git a/simulator-spring-boot/src/test/resources/swagger/swagger-api.json b/simulator-spring-boot/src/test/resources/swagger/petstore-v2.json similarity index 99% rename from simulator-spring-boot/src/test/resources/swagger/swagger-api.json rename to simulator-spring-boot/src/test/resources/swagger/petstore-v2.json index bfd4ee2fb..f1012b5c4 100644 --- a/simulator-spring-boot/src/test/resources/swagger/swagger-api.json +++ b/simulator-spring-boot/src/test/resources/swagger/petstore-v2.json @@ -1,5 +1,5 @@ { - "swagger": "3.0.3", + "swagger": "2.0", "info": { "version": "1.0.0", "title": "Swagger Petstore", @@ -9,7 +9,7 @@ } }, "host": "localhost", - "basePath": "/v2", + "basePath": "/petstore/v2", "schemes": [ "http" ], @@ -281,4 +281,4 @@ } } } -} \ No newline at end of file +} diff --git a/simulator-spring-boot/src/test/resources/swagger/petstore-v3.json b/simulator-spring-boot/src/test/resources/swagger/petstore-v3.json new file mode 100644 index 000000000..6c2b5bdfb --- /dev/null +++ b/simulator-spring-boot/src/test/resources/swagger/petstore-v3.json @@ -0,0 +1,254 @@ +{ + "openapi": "3.0.2", + "info": { + "title": "Swagger Petstore", + "version": "1.0.1", + "description": "This is a sample server Petstore server.", + "license": { + "name": "Apache 2.0", + "url": "http://www.apache.org/licenses/LICENSE-2.0.html" + } + }, + "servers": [ + { + "url": "http://localhost/petstore/v3" + } + ], + "paths": { + "/pet": { + "post": { + "requestBody": { + "description": "Pet object that needs to be added to the store", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + } + }, + "required": true + }, + "tags": [ + "pet" + ], + "responses": { + "201": { + "description": "Created" + }, + "405": { + "description": "Invalid input" + } + }, + "operationId": "addPet", + "summary": "Add a new pet to the store", + "description": "" + } + }, + "/pet/{petId}": { + "get": { + "tags": [ + "pet" + ], + "parameters": [ + { + "name": "petId", + "description": "ID of pet to return", + "schema": { + "format": "int64", + "type": "integer" + }, + "in": "path", + "required": true + }, + { + "name": "verbose", + "description": "Output details", + "schema": { + "type": "boolean" + }, + "in": "query", + "required": false + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + } + }, + "description": "successful operation" + }, + "400": { + "description": "Invalid ID supplied" + }, + "404": { + "description": "Pet not found" + } + }, + "operationId": "getPetById", + "summary": "Find pet by ID", + "description": "Returns a single pet" + }, + "delete": { + "tags": [ + "pet" + ], + "parameters": [ + { + "name": "api_key", + "schema": { + "type": "string" + }, + "in": "header", + "required": false + }, + { + "name": "petId", + "description": "Pet id to delete", + "schema": { + "format": "int64", + "type": "integer" + }, + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No content" + }, + "400": { + "description": "Invalid ID supplied" + }, + "404": { + "description": "Pet not found" + } + }, + "operationId": "deletePet", + "summary": "Deletes a pet", + "description": "" + } + } + }, + "components": { + "schemas": { + "Category": { + "type": "object", + "properties": { + "id": { + "format": "int64", + "type": "integer" + }, + "name": { + "type": "string" + } + }, + "xml": { + "name": "Category" + } + }, + "Tag": { + "type": "object", + "properties": { + "id": { + "format": "int64", + "type": "integer" + }, + "name": { + "type": "string" + } + }, + "xml": { + "name": "Tag" + } + }, + "Pet": { + "required": [ + "category", + "name", + "status" + ], + "type": "object", + "properties": { + "id": { + "format": "int64", + "type": "integer" + }, + "category": { + "$ref": "#/components/schemas/Category" + }, + "name": { + "type": "string", + "example": "doggie" + }, + "photoUrls": { + "type": "array", + "items": { + "type": "string" + }, + "xml": { + "name": "photoUrl", + "wrapped": true + } + }, + "tags": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Tag" + }, + "xml": { + "name": "tag", + "wrapped": true + } + }, + "status": { + "description": "pet status in the store", + "enum": [ + "available", + "pending", + "sold" + ], + "type": "string" + } + }, + "xml": { + "name": "Pet" + } + }, + "ApiResponse": { + "type": "object", + "properties": { + "code": { + "format": "int32", + "type": "integer" + }, + "type": { + "type": "string" + }, + "message": { + "type": "string" + } + } + } + } + }, + "tags": [ + { + "name": "pet", + "description": "Everything about your Pets" + } + ] +}