diff --git a/pom.xml b/pom.xml index 58d804c30..a767bedf4 100644 --- a/pom.xml +++ b/pom.xml @@ -27,7 +27,7 @@ 4.3.3 3.3.4 - 6.3.4 + 6.1.13 7.10.2 1.6.3 2.12.2 @@ -68,6 +68,7 @@ + simulator-spring-boot-api simulator-spring-boot simulator-ui simulator-docs @@ -174,6 +175,11 @@ + + org.citrusframework + citrus-spring-boot-simulator-api + ${project.version} + org.citrusframework citrus-spring-boot-simulator diff --git a/simulator-spring-boot-api/pom.xml b/simulator-spring-boot-api/pom.xml new file mode 100644 index 000000000..f3c9817be --- /dev/null +++ b/simulator-spring-boot-api/pom.xml @@ -0,0 +1,82 @@ + + + + + 4.0.0 + + org.citrusframework + citrus-simulator + 3.1.0-SNAPSHOT + + + citrus-spring-boot-simulator-api + + + 17 + 17 + UTF-8 + + + + + + org.citrusframework + citrus-core + + + + xml-apis + xml-apis + + + xerces + xercesImpl + + + + + org.citrusframework + citrus-http + + + org.citrusframework + citrus-ws + provided + true + + + + jakarta.annotation + jakarta.annotation-api + + + + + org.springframework + spring-web + + + + + org.springframework.boot + spring-boot-starter-test + test + + + diff --git a/simulator-spring-boot/src/main/java/org/citrusframework/simulator/correlation/CorrelationHandler.java b/simulator-spring-boot-api/src/main/java/org/citrusframework/simulator/correlation/CorrelationHandler.java similarity index 97% rename from simulator-spring-boot/src/main/java/org/citrusframework/simulator/correlation/CorrelationHandler.java rename to simulator-spring-boot-api/src/main/java/org/citrusframework/simulator/correlation/CorrelationHandler.java index 28e3716d8..f55c44e77 100644 --- a/simulator-spring-boot/src/main/java/org/citrusframework/simulator/correlation/CorrelationHandler.java +++ b/simulator-spring-boot-api/src/main/java/org/citrusframework/simulator/correlation/CorrelationHandler.java @@ -20,9 +20,6 @@ import org.citrusframework.message.Message; import org.citrusframework.simulator.scenario.ScenarioEndpoint; -/** - * @author Christoph Deppisch - */ public interface CorrelationHandler { /** diff --git a/simulator-spring-boot/src/main/java/org/citrusframework/simulator/exception/SimulatorException.java b/simulator-spring-boot-api/src/main/java/org/citrusframework/simulator/exception/SimulatorException.java similarity index 97% rename from simulator-spring-boot/src/main/java/org/citrusframework/simulator/exception/SimulatorException.java rename to simulator-spring-boot-api/src/main/java/org/citrusframework/simulator/exception/SimulatorException.java index 4fd399998..1004b3a03 100644 --- a/simulator-spring-boot/src/main/java/org/citrusframework/simulator/exception/SimulatorException.java +++ b/simulator-spring-boot-api/src/main/java/org/citrusframework/simulator/exception/SimulatorException.java @@ -16,9 +16,6 @@ package org.citrusframework.simulator.exception; -/** - * @author Christoph Deppisch - */ public class SimulatorException extends RuntimeException { private static final long serialVersionUID = 1L; diff --git a/simulator-spring-boot-api/src/main/java/org/citrusframework/simulator/http/HttpScenarioActionBuilder.java b/simulator-spring-boot-api/src/main/java/org/citrusframework/simulator/http/HttpScenarioActionBuilder.java new file mode 100644 index 000000000..c2660ea8d --- /dev/null +++ b/simulator-spring-boot-api/src/main/java/org/citrusframework/simulator/http/HttpScenarioActionBuilder.java @@ -0,0 +1,26 @@ +/* + * Copyright the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.citrusframework.simulator.http; + +import org.citrusframework.http.actions.HttpServerActionBuilder; + +public interface HttpScenarioActionBuilder { + + HttpServerActionBuilder.HttpServerReceiveActionBuilder receive(); + + HttpServerActionBuilder.HttpServerSendActionBuilder send(); +} diff --git a/simulator-spring-boot/src/main/java/org/citrusframework/simulator/scenario/Scenario.java b/simulator-spring-boot-api/src/main/java/org/citrusframework/simulator/scenario/Scenario.java similarity index 96% rename from simulator-spring-boot/src/main/java/org/citrusframework/simulator/scenario/Scenario.java rename to simulator-spring-boot-api/src/main/java/org/citrusframework/simulator/scenario/Scenario.java index 8da23d1ec..52f455a4d 100644 --- a/simulator-spring-boot/src/main/java/org/citrusframework/simulator/scenario/Scenario.java +++ b/simulator-spring-boot-api/src/main/java/org/citrusframework/simulator/scenario/Scenario.java @@ -24,9 +24,6 @@ import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; -/** - * @author Christoph Deppisch - */ @Target(ElementType.TYPE) @Retention(RetentionPolicy.RUNTIME) @Component diff --git a/simulator-spring-boot-api/src/main/java/org/citrusframework/simulator/scenario/ScenarioEndpoint.java b/simulator-spring-boot-api/src/main/java/org/citrusframework/simulator/scenario/ScenarioEndpoint.java new file mode 100644 index 000000000..4c9bc3024 --- /dev/null +++ b/simulator-spring-boot-api/src/main/java/org/citrusframework/simulator/scenario/ScenarioEndpoint.java @@ -0,0 +1,36 @@ +/* + * Copyright the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.citrusframework.simulator.scenario; + +import org.citrusframework.endpoint.AbstractEndpoint; +import org.citrusframework.endpoint.EndpointConfiguration; +import org.citrusframework.message.Message; +import org.citrusframework.messaging.Consumer; +import org.citrusframework.messaging.Producer; + +import java.util.concurrent.CompletableFuture; + +public abstract class ScenarioEndpoint extends AbstractEndpoint implements Producer, Consumer { + + public ScenarioEndpoint(EndpointConfiguration endpointConfiguration) { + super(endpointConfiguration); + } + + public abstract void add(Message request, CompletableFuture responseFuture); + + abstract void fail(Throwable error); +} diff --git a/simulator-spring-boot/src/main/java/org/citrusframework/simulator/scenario/ScenarioEndpointConfiguration.java b/simulator-spring-boot-api/src/main/java/org/citrusframework/simulator/scenario/ScenarioEndpointConfiguration.java similarity index 95% rename from simulator-spring-boot/src/main/java/org/citrusframework/simulator/scenario/ScenarioEndpointConfiguration.java rename to simulator-spring-boot-api/src/main/java/org/citrusframework/simulator/scenario/ScenarioEndpointConfiguration.java index 8c89eda65..ef61ce125 100644 --- a/simulator-spring-boot/src/main/java/org/citrusframework/simulator/scenario/ScenarioEndpointConfiguration.java +++ b/simulator-spring-boot-api/src/main/java/org/citrusframework/simulator/scenario/ScenarioEndpointConfiguration.java @@ -18,8 +18,5 @@ import org.citrusframework.endpoint.AbstractEndpointConfiguration; -/** - * @author Christoph Deppisch - */ public class ScenarioEndpointConfiguration extends AbstractEndpointConfiguration { } diff --git a/simulator-spring-boot-api/src/main/java/org/citrusframework/simulator/scenario/ScenarioRunner.java b/simulator-spring-boot-api/src/main/java/org/citrusframework/simulator/scenario/ScenarioRunner.java new file mode 100644 index 000000000..d424f04ca --- /dev/null +++ b/simulator-spring-boot-api/src/main/java/org/citrusframework/simulator/scenario/ScenarioRunner.java @@ -0,0 +1,28 @@ +/* + * Copyright the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.citrusframework.simulator.scenario; + +import org.citrusframework.GherkinTestActionRunner; +import org.citrusframework.simulator.http.HttpScenarioActionBuilder; +import org.citrusframework.simulator.soap.SoapScenarioActionBuilder; + +public interface ScenarioRunner extends GherkinTestActionRunner { + + HttpScenarioActionBuilder http(); + + SoapScenarioActionBuilder soap(); +} diff --git a/simulator-spring-boot/src/main/java/org/citrusframework/simulator/scenario/ScenarioUtils.java b/simulator-spring-boot-api/src/main/java/org/citrusframework/simulator/scenario/ScenarioUtils.java similarity index 98% rename from simulator-spring-boot/src/main/java/org/citrusframework/simulator/scenario/ScenarioUtils.java rename to simulator-spring-boot-api/src/main/java/org/citrusframework/simulator/scenario/ScenarioUtils.java index 2261b0131..4d9886f41 100644 --- a/simulator-spring-boot/src/main/java/org/citrusframework/simulator/scenario/ScenarioUtils.java +++ b/simulator-spring-boot-api/src/main/java/org/citrusframework/simulator/scenario/ScenarioUtils.java @@ -26,7 +26,7 @@ import static org.springframework.aop.framework.AopProxyUtils.ultimateTargetClass; @NoArgsConstructor(access = PRIVATE) -public class ScenarioUtils { +public final class ScenarioUtils { /** * Retrieves the specified annotation from the class hierarchy of the given scenario object. diff --git a/simulator-spring-boot-api/src/main/java/org/citrusframework/simulator/scenario/SimpleInitializedSimulatorScenario.java b/simulator-spring-boot-api/src/main/java/org/citrusframework/simulator/scenario/SimpleInitializedSimulatorScenario.java new file mode 100644 index 000000000..9dac12d34 --- /dev/null +++ b/simulator-spring-boot-api/src/main/java/org/citrusframework/simulator/scenario/SimpleInitializedSimulatorScenario.java @@ -0,0 +1,48 @@ +/* + * Copyright the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.citrusframework.simulator.scenario; + +import jakarta.annotation.Nullable; +import org.citrusframework.TestCaseRunner; +import org.citrusframework.simulator.correlation.CorrelationHandler; + +public abstract class SimpleInitializedSimulatorScenario implements CorrelationHandler, SimulatorScenarioWithEndpoint { + + private @Nullable ScenarioEndpoint scenarioEndpoint; + + private @Nullable TestCaseRunner testCaseRunner; + + @Override + public @Nullable ScenarioEndpoint getScenarioEndpoint() { + return scenarioEndpoint; + } + + @Override + public void setScenarioEndpoint(@Nullable ScenarioEndpoint scenarioEndpoint) { + this.scenarioEndpoint = scenarioEndpoint; + } + + @Override + public @Nullable TestCaseRunner getTestCaseRunner() { + return testCaseRunner; + } + + @Override + public void setTestCaseRunner(TestCaseRunner testCaseRunner) { + this.testCaseRunner = testCaseRunner; + } +} diff --git a/simulator-spring-boot/src/main/java/org/citrusframework/simulator/scenario/SimulatorScenario.java b/simulator-spring-boot-api/src/main/java/org/citrusframework/simulator/scenario/SimulatorScenario.java similarity index 96% rename from simulator-spring-boot/src/main/java/org/citrusframework/simulator/scenario/SimulatorScenario.java rename to simulator-spring-boot-api/src/main/java/org/citrusframework/simulator/scenario/SimulatorScenario.java index b939e377b..9107e703a 100644 --- a/simulator-spring-boot/src/main/java/org/citrusframework/simulator/scenario/SimulatorScenario.java +++ b/simulator-spring-boot-api/src/main/java/org/citrusframework/simulator/scenario/SimulatorScenario.java @@ -14,9 +14,7 @@ * limitations under the License. */ -package org.citrusframework.simulator.scenario; - -import jakarta.annotation.Nullable; +package org.citrusframework.simulator.scenario;import jakarta.annotation.Nullable; import org.citrusframework.DefaultTestCaseRunner; import org.citrusframework.TestCaseRunner; import org.citrusframework.exceptions.CitrusRuntimeException; diff --git a/simulator-spring-boot-api/src/main/java/org/citrusframework/simulator/scenario/SimulatorScenarioWithEndpoint.java b/simulator-spring-boot-api/src/main/java/org/citrusframework/simulator/scenario/SimulatorScenarioWithEndpoint.java new file mode 100644 index 000000000..f1e145898 --- /dev/null +++ b/simulator-spring-boot-api/src/main/java/org/citrusframework/simulator/scenario/SimulatorScenarioWithEndpoint.java @@ -0,0 +1,35 @@ +/* + * Copyright the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.citrusframework.simulator.scenario; + +import jakarta.annotation.Nullable; + +/** + * Classes implementing this interface will have the {@link ScenarioEndpoint} injected after instantiation at runtime. + * Similar to an initialized Spring bean, but controlled by the simulator. + * + * + * @Override public void afterPropertiesSet() { + * scenarioEndpoint = new ScenarioEndpoint(new ScenarioEndpointConfiguration()); + * } + * + * @see org.springframework.beans.factory.InitializingBean + */ +public interface SimulatorScenarioWithEndpoint extends SimulatorScenario { + + void setScenarioEndpoint(@Nullable ScenarioEndpoint scenarioEndpoint); +} diff --git a/simulator-spring-boot-api/src/main/java/org/citrusframework/simulator/soap/SoapScenarioActionBuilder.java b/simulator-spring-boot-api/src/main/java/org/citrusframework/simulator/soap/SoapScenarioActionBuilder.java new file mode 100644 index 000000000..2a7d4e89a --- /dev/null +++ b/simulator-spring-boot-api/src/main/java/org/citrusframework/simulator/soap/SoapScenarioActionBuilder.java @@ -0,0 +1,27 @@ +/* + * Copyright the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.citrusframework.simulator.soap; + +import org.citrusframework.ws.actions.ReceiveSoapMessageAction; +import org.citrusframework.ws.actions.SendSoapMessageAction; + +public interface SoapScenarioActionBuilder { + + ReceiveSoapMessageAction.Builder receive(); + + SendSoapMessageAction.Builder send(); +} diff --git a/simulator-spring-boot-api/src/test/java/org/citrusframework/simulator/scenario/ScenarioEndpointTest.java b/simulator-spring-boot-api/src/test/java/org/citrusframework/simulator/scenario/ScenarioEndpointTest.java new file mode 100644 index 000000000..89c3ea48d --- /dev/null +++ b/simulator-spring-boot-api/src/test/java/org/citrusframework/simulator/scenario/ScenarioEndpointTest.java @@ -0,0 +1,77 @@ +/* + * Copyright the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.citrusframework.simulator.scenario; + +import org.citrusframework.context.TestContext; +import org.citrusframework.endpoint.EndpointConfiguration; +import org.citrusframework.message.Message; +import org.citrusframework.messaging.Consumer; +import org.citrusframework.messaging.Producer; +import org.junit.jupiter.api.Test; + +import java.util.concurrent.CompletableFuture; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +class ScenarioEndpointTest { + + @Test + void constructorConfiguresEndpointConfiguration() { + var endpointConfigurationMock = mock(EndpointConfiguration.class); + + var fixture = new ScenarioEndpoint(endpointConfigurationMock) { + @Override + public void add(Message request, CompletableFuture responseFuture) { + + } + + @Override + void fail(Throwable error) { + + } + + @Override + public Producer createProducer() { + return null; + } + + @Override + public Consumer createConsumer() { + return null; + } + + @Override + public Message receive(TestContext testContext) { + return null; + } + + @Override + public Message receive(TestContext testContext, long l) { + return null; + } + + @Override + public void send(Message message, TestContext testContext) { + + } + }; + + assertThat(fixture.getEndpointConfiguration()) + .isEqualTo(endpointConfigurationMock); + } +} diff --git a/simulator-spring-boot-api/src/test/java/org/citrusframework/simulator/scenario/SimpleInitializedSimulatorScenarioTest.java b/simulator-spring-boot-api/src/test/java/org/citrusframework/simulator/scenario/SimpleInitializedSimulatorScenarioTest.java new file mode 100644 index 000000000..763bee060 --- /dev/null +++ b/simulator-spring-boot-api/src/test/java/org/citrusframework/simulator/scenario/SimpleInitializedSimulatorScenarioTest.java @@ -0,0 +1,57 @@ +/* + * Copyright the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.citrusframework.simulator.scenario; + +import org.citrusframework.TestCaseRunner; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.assertj.core.api.Assertions.assertThat; + +@ExtendWith({MockitoExtension.class}) +class SimpleInitializedSimulatorScenarioTest { + + @Mock + private TestCaseRunner testCaseRunnerMock; + + @Mock + private ScenarioEndpoint scenarioEndpointMock; + + @Test + void isTestCaseRunnerAware() { + var fixture = new SimpleInitializedSimulatorScenario() { + }; + + fixture.setTestCaseRunner(testCaseRunnerMock); + + assertThat(fixture.getTestCaseRunner()) + .isEqualTo(testCaseRunnerMock); + } + + @Test + void isScenarioEndpointAware() { + var fixture = new SimpleInitializedSimulatorScenario() { + }; + + fixture.setScenarioEndpoint(scenarioEndpointMock); + + assertThat(fixture.getScenarioEndpoint()) + .isEqualTo(scenarioEndpointMock); + } +} diff --git a/simulator-spring-boot-api/src/test/java/org/citrusframework/simulator/scenario/StandaloneHelloScenario.java b/simulator-spring-boot-api/src/test/java/org/citrusframework/simulator/scenario/StandaloneHelloScenario.java new file mode 100644 index 000000000..53b2d692a --- /dev/null +++ b/simulator-spring-boot-api/src/test/java/org/citrusframework/simulator/scenario/StandaloneHelloScenario.java @@ -0,0 +1,49 @@ +/* + * Copyright the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.citrusframework.simulator.scenario; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; + +import static org.citrusframework.actions.EchoAction.Builder.echo; +import static org.citrusframework.dsl.MessageSupport.MessageBodySupport.fromBody; + +@Scenario("StandaloneHelloScenario") +@RequestMapping(value = "/services/rest/simulator/hello", method = RequestMethod.POST) +public class StandaloneHelloScenario extends SimpleInitializedSimulatorScenario { + + @Override + public void run(ScenarioRunner scenario) { + scenario.$(scenario.http() + .receive() + .post() + .message() + .body("" + + "Say Hello!" + + "") + .extract(fromBody().expression("//hello:Hello", "greeting"))); + + scenario.$(echo("Received greeting: ${greeting}")); + + scenario.$(scenario.http() + .send() + .response(HttpStatus.OK) + .message() + .body("Hi there!")); + } +} diff --git a/simulator-spring-boot/pom.xml b/simulator-spring-boot/pom.xml index aa94c2c5b..b862debff 100644 --- a/simulator-spring-boot/pom.xml +++ b/simulator-spring-boot/pom.xml @@ -21,6 +21,12 @@ + + + org.citrusframework + citrus-spring-boot-simulator-api + + org.springframework.boot @@ -49,7 +55,7 @@ ${org.mapstruct.version} - + org.springframework.boot spring-boot-configuration-processor @@ -61,7 +67,7 @@ provided - + com.h2database h2 @@ -84,11 +90,24 @@ springdoc-openapi-starter-webmvc-api 2.3.0 + + io.swagger.parser.v3 + swagger-parser + + - wsdl4j - wsdl4j + dev.openfeature + sdk + 1.12.2 + + dev.openfeature.contrib.providers + flagd + 0.9.3 + + + org.apache.xmlbeans xmlbeans @@ -100,36 +119,7 @@ - - io.swagger.parser.v3 - swagger-parser - - - - org.springframework - spring-web - - - - org.citrusframework - citrus-core - - - - xml-apis - xml-apis - - - xerces - xercesImpl - - - - - org.citrusframework - citrus-http - org.citrusframework citrus-ws @@ -150,11 +140,6 @@ 1.3.0 test - - org.mockito - mockito-core - test - org.springframework.boot spring-boot-starter-test diff --git a/simulator-spring-boot/src/main/java/org/citrusframework/simulator/SimulatorAutoConfiguration.java b/simulator-spring-boot/src/main/java/org/citrusframework/simulator/SimulatorAutoConfiguration.java index a46672871..52fb71795 100644 --- a/simulator-spring-boot/src/main/java/org/citrusframework/simulator/SimulatorAutoConfiguration.java +++ b/simulator-spring-boot/src/main/java/org/citrusframework/simulator/SimulatorAutoConfiguration.java @@ -20,12 +20,13 @@ import org.citrusframework.Citrus; import org.citrusframework.CitrusSpringContextProvider; import org.citrusframework.config.CitrusSpringConfig; +import org.citrusframework.simulator.config.OpenFeatureConfig; +import org.citrusframework.simulator.config.RepositoryConfig; import org.citrusframework.simulator.config.SimulatorConfigurationProperties; import org.citrusframework.simulator.config.SimulatorImportSelector; import org.citrusframework.simulator.correlation.CorrelationHandlerRegistry; import org.citrusframework.simulator.dictionary.InboundXmlDataDictionary; import org.citrusframework.simulator.dictionary.OutboundXmlDataDictionary; -import org.citrusframework.simulator.repository.RepositoryConfig; import org.citrusframework.simulator.scenario.ScenarioBeanNameGenerator; import org.citrusframework.spi.CitrusResourceWrapper; import org.citrusframework.variable.dictionary.json.JsonPathMappingDataDictionary; @@ -61,7 +62,7 @@ "org.citrusframework.simulator.service", "org.citrusframework.simulator.endpoint", }, nameGenerator = ScenarioBeanNameGenerator.class) -@Import(value = {CitrusSpringConfig.class, SimulatorImportSelector.class, RepositoryConfig.class}) +@Import(value = {CitrusSpringConfig.class, OpenFeatureConfig.class, RepositoryConfig.class, SimulatorImportSelector.class}) @ImportResource( locations = { "classpath*:citrus-simulator-context.xml", diff --git a/simulator-spring-boot/src/main/java/org/citrusframework/simulator/common/FeatureFlagNotEnabledException.java b/simulator-spring-boot/src/main/java/org/citrusframework/simulator/common/FeatureFlagNotEnabledException.java new file mode 100644 index 000000000..0aff4e300 --- /dev/null +++ b/simulator-spring-boot/src/main/java/org/citrusframework/simulator/common/FeatureFlagNotEnabledException.java @@ -0,0 +1,33 @@ +/* + * Copyright the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.citrusframework.simulator.common; + +import lombok.Getter; + +import static java.lang.String.format; + +@Getter +public class FeatureFlagNotEnabledException extends Exception { + + private final String flag; + + public FeatureFlagNotEnabledException(String flag) { + super(format("Feature flag '%s' not enabeld!", flag)); + + this.flag = flag; + } +} diff --git a/simulator-spring-boot/src/main/java/org/citrusframework/simulator/config/OpenFeatureConfig.java b/simulator-spring-boot/src/main/java/org/citrusframework/simulator/config/OpenFeatureConfig.java new file mode 100644 index 000000000..ae2fb93b4 --- /dev/null +++ b/simulator-spring-boot/src/main/java/org/citrusframework/simulator/config/OpenFeatureConfig.java @@ -0,0 +1,64 @@ +/* + * Copyright the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.citrusframework.simulator.config; + +import dev.openfeature.sdk.FeatureProvider; +import dev.openfeature.sdk.OpenFeatureAPI; +import dev.openfeature.sdk.providers.memory.Flag; +import dev.openfeature.sdk.providers.memory.InMemoryProvider; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.NoSuchBeanDefinitionException; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.Map; + +@Slf4j +@Configuration +public class OpenFeatureConfig { + + public static final String EXPERIMENTAL_SCENARIO_LOADING_AT_RUNTIME_ENABLED = "org.citrusframework.simulator.scenario.loading_at_runtime_enabled"; + + @Bean + public OpenFeatureAPI openFeatureAPI(ApplicationContext applicationContext) { + OpenFeatureAPI openFeatureAPI = OpenFeatureAPI.getInstance(); + openFeatureAPI.setProviderAndWait(getFeatureProviderOrDefault(applicationContext)); + return openFeatureAPI; + } + + private FeatureProvider getFeatureProviderOrDefault(ApplicationContext applicationContext) { + try { + return applicationContext.getBean(FeatureProviderFactory.class).getFeatureProvider(); + } catch (NoSuchBeanDefinitionException e) { + logger.warn("No feature flag provider configured, using default settings!"); + } + + return new InMemoryProvider(Map.of( + EXPERIMENTAL_SCENARIO_LOADING_AT_RUNTIME_ENABLED, Flag.builder() + .variant("on", true) + .variant("off", false) + .defaultVariant("off") + .build() + )); + } + + public interface FeatureProviderFactory { + + FeatureProvider getFeatureProvider(); + } +} diff --git a/simulator-spring-boot/src/main/java/org/citrusframework/simulator/repository/RepositoryConfig.java b/simulator-spring-boot/src/main/java/org/citrusframework/simulator/config/RepositoryConfig.java similarity index 95% rename from simulator-spring-boot/src/main/java/org/citrusframework/simulator/repository/RepositoryConfig.java rename to simulator-spring-boot/src/main/java/org/citrusframework/simulator/config/RepositoryConfig.java index c139e7525..5062bc30d 100644 --- a/simulator-spring-boot/src/main/java/org/citrusframework/simulator/repository/RepositoryConfig.java +++ b/simulator-spring-boot/src/main/java/org/citrusframework/simulator/config/RepositoryConfig.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.citrusframework.simulator.repository; +package org.citrusframework.simulator.config; import org.springframework.boot.autoconfigure.domain.EntityScan; import org.springframework.context.annotation.Configuration; diff --git a/simulator-spring-boot/src/main/java/org/citrusframework/simulator/events/ScenariosReloadedEvent.java b/simulator-spring-boot/src/main/java/org/citrusframework/simulator/events/ScenariosReloadedEvent.java index 95346eaf1..84de8abb3 100644 --- a/simulator-spring-boot/src/main/java/org/citrusframework/simulator/events/ScenariosReloadedEvent.java +++ b/simulator-spring-boot/src/main/java/org/citrusframework/simulator/events/ScenariosReloadedEvent.java @@ -21,7 +21,7 @@ import java.util.Set; -public final class ScenariosReloadedEvent extends ApplicationEvent { +public final class ScenariosReloadedEvent extends ApplicationEvent { private final Set scenarioNames; private final Set scenarioStarterNames; diff --git a/simulator-spring-boot/src/main/java/org/citrusframework/simulator/scenario/AbstractSimulatorScenario.java b/simulator-spring-boot/src/main/java/org/citrusframework/simulator/scenario/AbstractSimulatorScenario.java index 9a81ab32b..e2972d583 100644 --- a/simulator-spring-boot/src/main/java/org/citrusframework/simulator/scenario/AbstractSimulatorScenario.java +++ b/simulator-spring-boot/src/main/java/org/citrusframework/simulator/scenario/AbstractSimulatorScenario.java @@ -16,42 +16,25 @@ package org.citrusframework.simulator.scenario; -import jakarta.annotation.Nullable; -import jakarta.annotation.PostConstruct; -import org.citrusframework.TestCaseRunner; -import org.citrusframework.simulator.correlation.CorrelationHandler; import org.citrusframework.simulator.correlation.CorrelationHandlerBuilder; +import org.springframework.beans.factory.InitializingBean; -public abstract class AbstractSimulatorScenario implements CorrelationHandler, SimulatorScenario { +import static java.util.Objects.isNull; - private ScenarioEndpoint scenarioEndpoint; - - private @Nullable TestCaseRunner testCaseRunner; - - @PostConstruct - public void init() { - scenarioEndpoint = new ScenarioEndpoint(new ScenarioEndpointConfiguration()); - } - - @Override - public ScenarioEndpoint getScenarioEndpoint() { - return scenarioEndpoint; - } - - @Override - public @Nullable TestCaseRunner getTestCaseRunner() { - return testCaseRunner; - } +@Deprecated(forRemoval = true) +public abstract class AbstractSimulatorScenario extends SimpleInitializedSimulatorScenario implements InitializingBean { @Override - public void setTestCaseRunner(TestCaseRunner testCaseRunner) { - this.testCaseRunner = testCaseRunner; + public void afterPropertiesSet() { + if (isNull(getScenarioEndpoint())) { + setScenarioEndpoint(new DefaultScenarioEndpoint(new ScenarioEndpointConfiguration())); + } } /** * Start new message correlation so scenario is provided with additional inbound messages. */ public CorrelationHandlerBuilder correlation() { - return new CorrelationHandlerBuilder(scenarioEndpoint); + return new CorrelationHandlerBuilder(getScenarioEndpoint()); } } diff --git a/simulator-spring-boot/src/main/java/org/citrusframework/simulator/scenario/ScenarioEndpoint.java b/simulator-spring-boot/src/main/java/org/citrusframework/simulator/scenario/DefaultScenarioEndpoint.java similarity index 93% rename from simulator-spring-boot/src/main/java/org/citrusframework/simulator/scenario/ScenarioEndpoint.java rename to simulator-spring-boot/src/main/java/org/citrusframework/simulator/scenario/DefaultScenarioEndpoint.java index c9a7a270c..7e67716fe 100644 --- a/simulator-spring-boot/src/main/java/org/citrusframework/simulator/scenario/ScenarioEndpoint.java +++ b/simulator-spring-boot/src/main/java/org/citrusframework/simulator/scenario/DefaultScenarioEndpoint.java @@ -17,7 +17,6 @@ package org.citrusframework.simulator.scenario; import org.citrusframework.context.TestContext; -import org.citrusframework.endpoint.AbstractEndpoint; import org.citrusframework.message.Message; import org.citrusframework.messaging.Consumer; import org.citrusframework.messaging.Producer; @@ -33,7 +32,7 @@ import static java.util.Objects.isNull; import static java.util.concurrent.TimeUnit.MILLISECONDS; -public class ScenarioEndpoint extends AbstractEndpoint implements Producer, Consumer { +public class DefaultScenarioEndpoint extends ScenarioEndpoint { /** * Internal im memory message channel @@ -50,7 +49,7 @@ public class ScenarioEndpoint extends AbstractEndpoint implements Producer, Cons * * @param endpointConfiguration */ - public ScenarioEndpoint(ScenarioEndpointConfiguration endpointConfiguration) { + public DefaultScenarioEndpoint(ScenarioEndpointConfiguration endpointConfiguration) { super(endpointConfiguration); } @@ -59,6 +58,7 @@ public ScenarioEndpoint(ScenarioEndpointConfiguration endpointConfiguration) { * * @param request */ + @Override public void add(Message request, CompletableFuture future) { responseFutures.push(future); channel.add(request); @@ -103,7 +103,8 @@ public void send(Message message, TestContext context) { completeNextResponseFuture(message); } - void fail(Throwable e) { + @Override + public void fail(Throwable e) { completeNextResponseFuture(new SimulationFailedUnexpectedlyException(e)); } diff --git a/simulator-spring-boot/src/main/java/org/citrusframework/simulator/scenario/DynamicClassLoader.java b/simulator-spring-boot/src/main/java/org/citrusframework/simulator/scenario/DynamicClassLoader.java new file mode 100644 index 000000000..a53d9772d --- /dev/null +++ b/simulator-spring-boot/src/main/java/org/citrusframework/simulator/scenario/DynamicClassLoader.java @@ -0,0 +1,133 @@ +/* + * Copyright the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.citrusframework.simulator.scenario; + +import lombok.Getter; + +import javax.tools.FileObject; +import javax.tools.ForwardingJavaFileManager; +import javax.tools.JavaCompiler; +import javax.tools.JavaFileManager; +import javax.tools.JavaFileObject; +import javax.tools.SimpleJavaFileObject; +import javax.tools.StandardJavaFileManager; +import java.io.ByteArrayOutputStream; +import java.io.OutputStream; +import java.net.URI; +import java.util.ArrayList; +import java.util.List; + +import static java.util.Arrays.asList; +import static javax.tools.ToolProvider.getSystemJavaCompiler; + +public class DynamicClassLoader { + + private static final JavaCompiler JAVA_COMPILER = getSystemJavaCompiler(); + private static final StandardJavaFileManager STD_FILE_MANAGER = JAVA_COMPILER.getStandardFileManager(null, null, null); + + public static Class compileAndLoad(String className, String sourceCodeInText) throws Exception { + JavaFileObject source = new InMemoryJavaFileObject(className, sourceCodeInText); + Iterable compilationUnits = asList(source); + + // Prepare the in-memory file manager + var inMemoryJavaFileManager = new InMemoryJavaFileManager(STD_FILE_MANAGER); + + // Compile the source code + JavaCompiler.CompilationTask task = JAVA_COMPILER.getTask(null, inMemoryJavaFileManager, null, null, null, compilationUnits); + if (task.call()) { + // Load the class from the byte code stored in memory + ClassLoader inMemoryClassLoader = new ClassLoader() { + @Override + protected Class findClass(String name) throws ClassNotFoundException { + for (var byteCode : inMemoryJavaFileManager.getByteCodes()) { + if (getSimpleClassName(byteCode).equals(name)) { + byte[] bytes = byteCode.getByteCode(); + return defineClass(getFullyQualifiedName(byteCode), bytes, 0, bytes.length); + } + } + + return super.findClass(name); + } + + private static String getFullyQualifiedName(InMemoryByteCode inMemoryByteCode) { + return inMemoryByteCode.getName().replaceAll("/", ".").replace(JavaFileObject.Kind.CLASS.extension, "").substring(1); + } + + private static String getSimpleClassName(InMemoryByteCode inMemoryByteCode) { + var parts = inMemoryByteCode.getName().split("/"); + return parts[parts.length - 1].replace(JavaFileObject.Kind.CLASS.extension, ""); + } + }; + + return (Class) inMemoryClassLoader.loadClass(className); + } else { + throw new ClassNotFoundException("Class " + className + " not compiled"); + } + } + + @Getter + private static class InMemoryJavaFileManager extends ForwardingJavaFileManager implements JavaFileManager { + + private final List byteCodes = new ArrayList<>(); + + public InMemoryJavaFileManager(StandardJavaFileManager stdFileManager) { + super(stdFileManager); + } + + @Override + public JavaFileObject getJavaFileForOutput(JavaFileManager.Location location, String className, JavaFileObject.Kind kind, FileObject sibling) { + InMemoryByteCode byteCode = new InMemoryByteCode(className); + byteCodes.add(byteCode); + return byteCode; + } + + } + + private static class InMemoryJavaFileObject extends SimpleJavaFileObject { + + private final String content; + + public InMemoryJavaFileObject(String name, String content) { + super(URI.create("string:///" + name.replace('.', '/') + Kind.SOURCE.extension), Kind.SOURCE); + this.content = content; + } + + @Override + public CharSequence getCharContent(boolean ignoreEncodingErrors) { + return content; + } + } + + @Getter + private static class InMemoryByteCode extends SimpleJavaFileObject { + + private final ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + + public InMemoryByteCode(String className) { + super(URI.create("byte:///" + className.replace('.', '/') + Kind.CLASS.extension), Kind.CLASS); + } + + public byte[] getByteCode() { + return outputStream.toByteArray(); + } + + @Override + public OutputStream openOutputStream() { + return outputStream; + } + } +} diff --git a/simulator-spring-boot/src/main/java/org/citrusframework/simulator/scenario/ScenarioRunner.java b/simulator-spring-boot/src/main/java/org/citrusframework/simulator/scenario/ScenarioRunner.java index b1d218a7a..ea2ff08b0 100644 --- a/simulator-spring-boot/src/main/java/org/citrusframework/simulator/scenario/ScenarioRunner.java +++ b/simulator-spring-boot/src/main/java/org/citrusframework/simulator/scenario/ScenarioRunner.java @@ -73,7 +73,7 @@ public SoapScenarioActionBuilder soap() { public T run(TestActionBuilder builder) { if (builder instanceof CorrelationHandlerBuilder correlationHandlerBuilder) { correlationHandlerBuilder.setApplicationContext(applicationContext); - delegate.run(doFinally().actions(((CorrelationHandlerBuilder) builder).stop())); + delegate.run(doFinally().actions(correlationHandlerBuilder.stop())); } return delegate.run(builder); diff --git a/simulator-spring-boot/src/main/java/org/citrusframework/simulator/service/ScenarioRegistrationService.java b/simulator-spring-boot/src/main/java/org/citrusframework/simulator/service/ScenarioRegistrationService.java new file mode 100644 index 000000000..d5c91f353 --- /dev/null +++ b/simulator-spring-boot/src/main/java/org/citrusframework/simulator/service/ScenarioRegistrationService.java @@ -0,0 +1,78 @@ +/* + * Copyright the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.citrusframework.simulator.service; + +import dev.openfeature.sdk.OpenFeatureAPI; +import org.citrusframework.exceptions.CitrusRuntimeException; +import org.citrusframework.simulator.common.FeatureFlagNotEnabledException; +import org.citrusframework.simulator.scenario.DefaultScenarioEndpoint; +import org.citrusframework.simulator.scenario.ScenarioEndpointConfiguration; +import org.citrusframework.simulator.scenario.SimulatorScenario; +import org.citrusframework.simulator.scenario.SimulatorScenarioWithEndpoint; +import org.springframework.beans.factory.support.BeanDefinitionRegistry; +import org.springframework.context.ApplicationContext; +import org.springframework.stereotype.Service; + +import static org.citrusframework.simulator.config.OpenFeatureConfig.EXPERIMENTAL_SCENARIO_LOADING_AT_RUNTIME_ENABLED; +import static org.citrusframework.simulator.scenario.DynamicClassLoader.compileAndLoad; +import static org.springframework.beans.factory.support.BeanDefinitionBuilder.genericBeanDefinition; + +@Service +public class ScenarioRegistrationService { + + private final ApplicationContext applicationContext; + private final OpenFeatureAPI openFeatureAPI; + private final ScenarioLookupService scenarioLookupService; + + public ScenarioRegistrationService(ApplicationContext applicationContext, OpenFeatureAPI openFeatureAPI, ScenarioLookupService scenarioLookupService) { + this.applicationContext = applicationContext; + this.openFeatureAPI = openFeatureAPI; + this.scenarioLookupService = scenarioLookupService; + } + + public SimulatorScenario registerScenarioFromJavaSourceCode(String scenarioName, String javaSourceCode) throws FeatureFlagNotEnabledException { + if (!openFeatureAPI.getClient().getBooleanValue(EXPERIMENTAL_SCENARIO_LOADING_AT_RUNTIME_ENABLED, false)) { + throw new FeatureFlagNotEnabledException(EXPERIMENTAL_SCENARIO_LOADING_AT_RUNTIME_ENABLED); + } + + try { + Class loadedClass = compileAndLoad(scenarioName, javaSourceCode); + SimulatorScenario simulatorScenario = loadedClass.getDeclaredConstructor().newInstance(); + + if (simulatorScenario instanceof SimulatorScenarioWithEndpoint simulatorScenarioWithEndpoint) { + simulatorScenarioWithEndpoint.setScenarioEndpoint(new DefaultScenarioEndpoint(new ScenarioEndpointConfiguration())); + } + + registerScenarioBean(scenarioName, loadedClass); + + scenarioLookupService.evictAndReloadScenarioCache(); + + return simulatorScenario; + } catch (Exception e) { + throw new CitrusRuntimeException(e); + } + } + + private void registerScenarioBean(String scenarioName, Class loadedClass) { + if (!(applicationContext instanceof BeanDefinitionRegistry beanDefinitionRegistry)) { + throw new IllegalArgumentException("Cannot register simulation into bean registry, application context is not of type BeanDefinitionRegistry!"); + } + + var beanDefinition = genericBeanDefinition(loadedClass).getBeanDefinition(); + beanDefinitionRegistry.registerBeanDefinition(scenarioName, beanDefinition); + } +} diff --git a/simulator-spring-boot/src/main/java/org/citrusframework/simulator/web/rest/ScenarioResource.java b/simulator-spring-boot/src/main/java/org/citrusframework/simulator/web/rest/ScenarioResource.java index c0f466c46..d0c765cc1 100644 --- a/simulator-spring-boot/src/main/java/org/citrusframework/simulator/web/rest/ScenarioResource.java +++ b/simulator-spring-boot/src/main/java/org/citrusframework/simulator/web/rest/ScenarioResource.java @@ -16,16 +16,19 @@ package org.citrusframework.simulator.web.rest; +import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.validation.constraints.NotEmpty; +import lombok.extern.slf4j.Slf4j; +import org.citrusframework.simulator.common.FeatureFlagNotEnabledException; import org.citrusframework.simulator.events.ScenariosReloadedEvent; import org.citrusframework.simulator.model.ScenarioParameter; +import org.citrusframework.simulator.scenario.ScenarioStarter; import org.citrusframework.simulator.service.ScenarioExecutorService; import org.citrusframework.simulator.service.ScenarioLookupService; +import org.citrusframework.simulator.service.ScenarioRegistrationService; import org.citrusframework.simulator.web.rest.dto.ScenarioParameterDTO; import org.citrusframework.simulator.web.rest.dto.mapper.ScenarioParameterMapper; import org.citrusframework.simulator.web.rest.pagination.ScenarioComparator; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import org.springdoc.core.annotations.ParameterObject; import org.springframework.context.event.EventListener; import org.springframework.data.domain.Page; @@ -40,6 +43,7 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import java.net.URI; import java.util.ArrayList; import java.util.Collection; import java.util.List; @@ -49,29 +53,35 @@ import static java.net.URLDecoder.decode; import static java.nio.charset.StandardCharsets.UTF_8; import static java.util.Comparator.comparing; +import static org.citrusframework.simulator.config.OpenFeatureConfig.EXPERIMENTAL_SCENARIO_LOADING_AT_RUNTIME_ENABLED; import static org.citrusframework.simulator.web.rest.ScenarioResource.Scenario.ScenarioType.MESSAGE_TRIGGERED; import static org.citrusframework.simulator.web.rest.ScenarioResource.Scenario.ScenarioType.STARTER; import static org.citrusframework.simulator.web.util.PaginationUtil.createPage; import static org.citrusframework.simulator.web.util.PaginationUtil.generatePaginationHttpHeaders; +import static org.springframework.http.HttpStatus.NOT_IMPLEMENTED; +import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE; +import static org.springframework.http.MediaType.TEXT_PLAIN_VALUE; +import static org.springframework.http.ResponseEntity.created; import static org.springframework.http.ResponseEntity.ok; import static org.springframework.web.servlet.support.ServletUriComponentsBuilder.fromCurrentRequest; +@Slf4j @RestController @RequestMapping("api") public class ScenarioResource { - private static final Logger logger = LoggerFactory.getLogger(ScenarioResource.class); - private final ScenarioExecutorService scenarioExecutorService; private final ScenarioLookupService scenarioLookupService; + private final ScenarioRegistrationService scenarioRegistrationService; private final ScenarioParameterMapper scenarioParameterMapper; private final List scenarioCache = new ArrayList<>(); - public ScenarioResource(ScenarioExecutorService scenarioExecutorService, ScenarioLookupService scenarioLookupService, ScenarioParameterMapper scenarioParameterMapper) { + public ScenarioResource(ScenarioExecutorService scenarioExecutorService, ScenarioLookupService scenarioLookupService, ScenarioRegistrationService scenarioRegistrationService, ScenarioParameterMapper scenarioParameterMapper) { this.scenarioExecutorService = scenarioExecutorService; this.scenarioLookupService = scenarioLookupService; + this.scenarioRegistrationService = scenarioRegistrationService; this.scenarioParameterMapper = scenarioParameterMapper; evictAndReloadScenarioCache(scenarioLookupService.getScenarioNames(), scenarioLookupService.getStarterNames()); @@ -101,7 +111,7 @@ private synchronized void evictAndReloadScenarioCache(Set scenarioNames, * @param pageable the pagination information. * @return the {@link ResponseEntity} with status {@code 200 (OK)} and the list of scenarios in body. */ - @GetMapping("/scenarios") + @GetMapping(value = "/scenarios", produces = {APPLICATION_JSON_VALUE}) public ResponseEntity> getScenarios(@RequestParam(name = "nameContains", required = false) Optional nameContains, @ParameterObject Pageable pageable) { var nameFilter = nameContains.map(contains -> decode(contains, UTF_8)).orElse("*"); logger.debug("REST request get registered Scenarios, where name contains: {}", nameFilter); @@ -118,6 +128,20 @@ public ResponseEntity> getScenarios(@RequestParam(name = "nameCon return ok().headers(headers).body(page.getContent()); } + @PostMapping(value = "/scenarios/{scenarioName}", consumes = {TEXT_PLAIN_VALUE}, produces = {APPLICATION_JSON_VALUE}) + public ResponseEntity uploadScenario(@PathVariable("scenarioName") String scenarioName, @RequestBody String javaSourceCode) { + try { + var simulatorScenario = scenarioRegistrationService.registerScenarioFromJavaSourceCode(scenarioName, javaSourceCode); + return created(URI.create("/api/scenarios/" + scenarioName)).body(new Scenario(simulatorScenario.getName(), simulatorScenario instanceof ScenarioStarter ? STARTER : MESSAGE_TRIGGERED)); + } catch (FeatureFlagNotEnabledException e) { + var responseNode = new ObjectMapper().createObjectNode(); + responseNode.put("message", "Feature flag not enabled."); + responseNode.put("flag", EXPERIMENTAL_SCENARIO_LOADING_AT_RUNTIME_ENABLED); + return ResponseEntity.status(NOT_IMPLEMENTED) + .body(responseNode); + } + } + /** * Get the {@link ScenarioParameter}'s for the {@link Scenario} matching the supplied name * diff --git a/simulator-spring-boot/src/test/java/org/citrusframework/simulator/config/OpenFeatureConfigTest.java b/simulator-spring-boot/src/test/java/org/citrusframework/simulator/config/OpenFeatureConfigTest.java new file mode 100644 index 000000000..81f5aba5e --- /dev/null +++ b/simulator-spring-boot/src/test/java/org/citrusframework/simulator/config/OpenFeatureConfigTest.java @@ -0,0 +1,118 @@ +/* + * Copyright the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.citrusframework.simulator.config; + +import dev.openfeature.sdk.FeatureProvider; +import dev.openfeature.sdk.OpenFeatureAPI; +import dev.openfeature.sdk.providers.memory.InMemoryProvider; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.beans.factory.NoSuchBeanDefinitionException; +import org.springframework.context.ApplicationContext; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.citrusframework.simulator.config.OpenFeatureConfig.EXPERIMENTAL_SCENARIO_LOADING_AT_RUNTIME_ENABLED; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.doThrow; + +@ExtendWith({MockitoExtension.class}) +class OpenFeatureConfigTest { + + @Mock + private ApplicationContext applicationContextMock; + + @Mock + private OpenFeatureConfig.FeatureProviderFactory featureProviderFactoryMock; + + @Mock + private FeatureProvider customFeatureProviderMock; + + private OpenFeatureConfig openFeatureConfig; + private OpenFeatureAPI originalInstance; + + @BeforeEach + void setUp() { + openFeatureConfig = new OpenFeatureConfig(); + // Store the original instance to restore it after tests + originalInstance = OpenFeatureAPI.getInstance(); + } + + @AfterEach + void tearDown() { + // Reset the OpenFeatureAPI singleton to its original state + OpenFeatureAPI.getInstance().setProvider(originalInstance.getProvider()); + } + + @Test + void shouldUseCustomFeatureProviderWhenFactoryExists() { + doReturn(featureProviderFactoryMock) + .when(applicationContextMock) + .getBean(OpenFeatureConfig.FeatureProviderFactory.class); + doReturn(customFeatureProviderMock) + .when(featureProviderFactoryMock) + .getFeatureProvider(); + + OpenFeatureAPI result = openFeatureConfig.openFeatureAPI(applicationContextMock); + + assertThat(result.getProvider()) + .isSameAs(customFeatureProviderMock); + } + + @Test + void shouldUseInMemoryProviderWhenNoFactoryExists() { + doThrow(new NoSuchBeanDefinitionException("FeatureProviderFactory")) + .when(applicationContextMock) + .getBean(OpenFeatureConfig.FeatureProviderFactory.class); + + OpenFeatureAPI result = openFeatureConfig.openFeatureAPI(applicationContextMock); + + assertThat(result.getProvider()) + .isInstanceOf(InMemoryProvider.class); + } + + @Test + void shouldConfigureDefaultFlagCorrectlyWithInMemoryProvider() { + doThrow(new NoSuchBeanDefinitionException("FeatureProviderFactory")) + .when(applicationContextMock) + .getBean(OpenFeatureConfig.FeatureProviderFactory.class); + + OpenFeatureAPI result = openFeatureConfig.openFeatureAPI(applicationContextMock); + InMemoryProvider provider = (InMemoryProvider) result.getProvider(); + + // Verify the flag exists and has correct configuration + assertThat(provider.getBooleanEvaluation(EXPERIMENTAL_SCENARIO_LOADING_AT_RUNTIME_ENABLED, null, null).getValue()) + .isFalse(); // Default should be "off" (false) + } + + @Test + void shouldReturnSingletonInstance() { + doThrow(new NoSuchBeanDefinitionException("FeatureProviderFactory")) + .when(applicationContextMock) + .getBean(OpenFeatureConfig.FeatureProviderFactory.class); + + OpenFeatureAPI result1 = openFeatureConfig.openFeatureAPI(applicationContextMock); + OpenFeatureAPI result2 = openFeatureConfig.openFeatureAPI(applicationContextMock); + + assertThat(result1) + .isSameAs(result2) + .isSameAs(OpenFeatureAPI.getInstance()); + } +} diff --git a/simulator-spring-boot/src/test/java/org/citrusframework/simulator/dictionary/InboundXmlDataDictionaryTest.java b/simulator-spring-boot/src/test/java/org/citrusframework/simulator/dictionary/InboundXmlDataDictionaryTest.java index 58edacfa4..74fab7dc7 100644 --- a/simulator-spring-boot/src/test/java/org/citrusframework/simulator/dictionary/InboundXmlDataDictionaryTest.java +++ b/simulator-spring-boot/src/test/java/org/citrusframework/simulator/dictionary/InboundXmlDataDictionaryTest.java @@ -39,12 +39,15 @@ @ExtendWith(MockitoExtension.class) class InboundXmlDataDictionaryTest { - private static final String MESSAGE_INPUT = String.format("%n" + - " string%n" + - " 100%n" + - " true%n" + - " stringstri%n" + - ""); + private static final String MESSAGE_INPUT = """ + + + string + 100 + true + stringstri + + """; @Mock private TestContext testContextMock; @@ -63,15 +66,18 @@ void testInboundDictionary() { doReturn(new NamespaceContextBuilder()).when(testContextMock).getNamespaceContextBuilder(); doAnswer(invocation -> invocation.getArguments()[0]).when(testContextMock).replaceDynamicContentInString(anyString()); - Message request = new DefaultMessage(MESSAGE_INPUT); + var request = new DefaultMessage(MESSAGE_INPUT); + Message translated = fixture.transform(request, testContextMock); - assertEquals(translated.getPayload(String.class), String.format("" + - "%n" + - " @ignore@%n" + - " @ignore@%n" + - " @ignore@%n" + - " @ignore@%n" + - "%n").replace("\r", "")); + assertEquals(""" + + + @ignore@ + @ignore@ + @ignore@ + @ignore@ + + """, translated.getPayload(String.class)); } } diff --git a/simulator-spring-boot/src/test/java/org/citrusframework/simulator/scenario/AbstractSimulatorScenarioTest.java b/simulator-spring-boot/src/test/java/org/citrusframework/simulator/scenario/AbstractSimulatorScenarioTest.java index dad66f12f..370640b23 100644 --- a/simulator-spring-boot/src/test/java/org/citrusframework/simulator/scenario/AbstractSimulatorScenarioTest.java +++ b/simulator-spring-boot/src/test/java/org/citrusframework/simulator/scenario/AbstractSimulatorScenarioTest.java @@ -17,6 +17,8 @@ package org.citrusframework.simulator.scenario; import org.citrusframework.TestCaseRunner; +import org.citrusframework.simulator.correlation.CorrelationHandlerBuilder; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; @@ -30,6 +32,9 @@ class AbstractSimulatorScenarioTest { @Mock private TestCaseRunner testCaseRunnerMock; + @Mock + private ScenarioEndpoint scenarioEndpointMock; + @Test void isTestCaseRunnerAware() { var fixture = new AbstractSimulatorScenario() { @@ -40,4 +45,57 @@ void isTestCaseRunnerAware() { assertThat(fixture.getTestCaseRunner()) .isEqualTo(testCaseRunnerMock); } + + @Test + void isScenarioEndpointAware() { + var fixture = new AbstractSimulatorScenario() { + }; + + fixture.setScenarioEndpoint(scenarioEndpointMock); + + assertThat(fixture.getScenarioEndpoint()) + .isEqualTo(scenarioEndpointMock); + } + + @Test + void providesCorrelationHandler() { + var fixture = new AbstractSimulatorScenario() { + }; + fixture.setScenarioEndpoint(scenarioEndpointMock); + + assertThat(fixture.correlation()) + .isInstanceOf(CorrelationHandlerBuilder.class) + .hasFieldOrPropertyWithValue("scenarioEndpoint", scenarioEndpointMock); + } + + @Nested + class AfterPropertiesSet { + + @Test + void initializesScenarioEndpointIfNull() { + var fixture = new AbstractSimulatorScenario() { + }; + + assertThat(fixture.getScenarioEndpoint()) + .isNull(); + + fixture.afterPropertiesSet(); + + assertThat(fixture.getScenarioEndpoint()) + .isNotNull() + .isInstanceOf(DefaultScenarioEndpoint.class); + } + + @Test + void doesNothingIfScenarioEndpointAlreadyInitialized() { + var fixture = new AbstractSimulatorScenario() { + }; + fixture.setScenarioEndpoint(scenarioEndpointMock); + + fixture.afterPropertiesSet(); + + assertThat(fixture.getScenarioEndpoint()) + .isEqualTo(scenarioEndpointMock); + } + } } diff --git a/simulator-spring-boot/src/test/java/org/citrusframework/simulator/scenario/ScenarioEndpointTest.java b/simulator-spring-boot/src/test/java/org/citrusframework/simulator/scenario/DefaultScenarioEndpointTest.java similarity index 96% rename from simulator-spring-boot/src/test/java/org/citrusframework/simulator/scenario/ScenarioEndpointTest.java rename to simulator-spring-boot/src/test/java/org/citrusframework/simulator/scenario/DefaultScenarioEndpointTest.java index c290e6f83..28d342fe7 100644 --- a/simulator-spring-boot/src/test/java/org/citrusframework/simulator/scenario/ScenarioEndpointTest.java +++ b/simulator-spring-boot/src/test/java/org/citrusframework/simulator/scenario/DefaultScenarioEndpointTest.java @@ -29,13 +29,13 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.mock; -class ScenarioEndpointTest { +class DefaultScenarioEndpointTest { private ScenarioEndpoint fixture; @BeforeEach void beforeEachSetup() { - fixture = new ScenarioEndpoint(null); + fixture = new DefaultScenarioEndpoint(null); } @Nested diff --git a/simulator-spring-boot/src/test/java/org/citrusframework/simulator/service/ScenarioRegistrationServiceTest.java b/simulator-spring-boot/src/test/java/org/citrusframework/simulator/service/ScenarioRegistrationServiceTest.java new file mode 100644 index 000000000..c12d318c3 --- /dev/null +++ b/simulator-spring-boot/src/test/java/org/citrusframework/simulator/service/ScenarioRegistrationServiceTest.java @@ -0,0 +1,142 @@ +/* + * Copyright the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.citrusframework.simulator.service; + +import dev.openfeature.sdk.Client; +import dev.openfeature.sdk.OpenFeatureAPI; +import org.citrusframework.exceptions.CitrusRuntimeException; +import org.citrusframework.simulator.common.FeatureFlagNotEnabledException; +import org.citrusframework.simulator.scenario.DefaultScenarioEndpoint; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.context.ApplicationContext; +import org.springframework.context.support.GenericApplicationContext; + +import java.io.IOException; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.citrusframework.simulator.config.OpenFeatureConfig.EXPERIMENTAL_SCENARIO_LOADING_AT_RUNTIME_ENABLED; +import static org.citrusframework.simulator.service.TestClassLoader.loadTestClass; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +@ExtendWith({MockitoExtension.class}) +class ScenarioRegistrationServiceTest { + + @Mock + private GenericApplicationContext applicationContextMock; + + @Mock + private OpenFeatureAPI openFeatureAPIMock; + + @Mock + private Client clientMock; + + @Mock + private ScenarioLookupService scenarioLookupServiceMock; + + private ScenarioRegistrationService fixture; + + @BeforeEach + void beforeEachSetup() { + doReturn(clientMock).when(openFeatureAPIMock).getClient(); + fixture = new ScenarioRegistrationService(applicationContextMock, openFeatureAPIMock, scenarioLookupServiceMock); + } + + @Nested + class RegisterScenarioFromJavaSourceCode { + + @Test + void loadScenarioWithEndpoint() throws IOException, FeatureFlagNotEnabledException { + String scenarioName = "ScenarioWithEndpoint"; + var scenarioWithEndpoint = loadTestClass("org.citrusframework.simulator.service.ScenarioWithEndpoint"); + + doReturn(true).when(clientMock).getBooleanValue(EXPERIMENTAL_SCENARIO_LOADING_AT_RUNTIME_ENABLED, false); + + var simulatorScenario = fixture.registerScenarioFromJavaSourceCode(scenarioName, scenarioWithEndpoint); + + assertThat(simulatorScenario.getScenarioEndpoint()) + .isNotNull() + .isInstanceOf(DefaultScenarioEndpoint.class); + + verify(applicationContextMock).registerBeanDefinition(eq(scenarioName), any(BeanDefinition.class)); + verify(scenarioLookupServiceMock).evictAndReloadScenarioCache(); + } + + @Test + void loadScenarioWithoutEndpoint() throws IOException, FeatureFlagNotEnabledException { + String scenarioName = "ScenarioWithoutEndpoint"; + var scenarioWithoutEndpoint = loadTestClass("org.citrusframework.simulator.service.ScenarioWithoutEndpoint"); + + doReturn(true).when(clientMock).getBooleanValue(EXPERIMENTAL_SCENARIO_LOADING_AT_RUNTIME_ENABLED, false); + + var simulatorScenario = fixture.registerScenarioFromJavaSourceCode(scenarioName, scenarioWithoutEndpoint); + + assertThat(simulatorScenario.getScenarioEndpoint()) + .isNull(); + + verify(applicationContextMock).registerBeanDefinition(eq(scenarioName), any(BeanDefinition.class)); + verify(scenarioLookupServiceMock).evictAndReloadScenarioCache(); + } + + @Test + void scenarioNameMustMatchClassName() throws IOException { + var scenarioWithEndpoint = loadTestClass("org.citrusframework.simulator.service.ScenarioWithEndpoint"); + + doReturn(true).when(clientMock).getBooleanValue(EXPERIMENTAL_SCENARIO_LOADING_AT_RUNTIME_ENABLED, false); + + assertThatThrownBy(() -> fixture.registerScenarioFromJavaSourceCode("ScenarioWithoutEndpoint", scenarioWithEndpoint)) + .isInstanceOf(CitrusRuntimeException.class) + .hasRootCauseInstanceOf(ClassNotFoundException.class) + .hasMessageEndingWith("Class ScenarioWithoutEndpoint not compiled"); + } + + @Test + void requiresBeanDefinitionRegistry() throws IOException { + fixture = new ScenarioRegistrationService(mock(ApplicationContext.class), openFeatureAPIMock, scenarioLookupServiceMock); + + doReturn(true).when(clientMock).getBooleanValue(EXPERIMENTAL_SCENARIO_LOADING_AT_RUNTIME_ENABLED, false); + + var scenarioWithoutEndpoint = loadTestClass("org.citrusframework.simulator.service.ScenarioWithoutEndpoint"); + + assertThatThrownBy(() -> fixture.registerScenarioFromJavaSourceCode("ScenarioWithoutEndpoint", scenarioWithoutEndpoint)) + .isInstanceOf(CitrusRuntimeException.class) + .hasRootCauseInstanceOf(IllegalArgumentException.class) + .hasMessageEndingWith("Cannot register simulation into bean registry, application context is not of type BeanDefinitionRegistry!"); + } + + @Test + void throwsExceptionWhenFeatureFlagNotEnabled() throws IOException { + var scenarioWithoutEndpoint = loadTestClass("org.citrusframework.simulator.service.ScenarioWithoutEndpoint"); + + doReturn(false).when(clientMock).getBooleanValue(EXPERIMENTAL_SCENARIO_LOADING_AT_RUNTIME_ENABLED, false); + + assertThatThrownBy(() -> fixture.registerScenarioFromJavaSourceCode("ScenarioWithoutEndpoint", scenarioWithoutEndpoint)) + .isInstanceOf(FeatureFlagNotEnabledException.class) + .hasMessage("Feature flag 'org.citrusframework.simulator.scenario.loading_at_runtime_enabled' not enabeld!"); + } + } +} diff --git a/simulator-spring-boot/src/test/java/org/citrusframework/simulator/service/ScenarioWithEndpoint.java b/simulator-spring-boot/src/test/java/org/citrusframework/simulator/service/ScenarioWithEndpoint.java new file mode 100644 index 000000000..dbee05549 --- /dev/null +++ b/simulator-spring-boot/src/test/java/org/citrusframework/simulator/service/ScenarioWithEndpoint.java @@ -0,0 +1,36 @@ +/* + * Copyright the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.citrusframework.simulator.service; + +import org.citrusframework.simulator.scenario.ScenarioRunner; +import org.citrusframework.simulator.scenario.SimpleInitializedSimulatorScenario; + +import java.util.concurrent.atomic.AtomicBoolean; + +public class ScenarioWithEndpoint extends SimpleInitializedSimulatorScenario { + + private final AtomicBoolean hasRun = new AtomicBoolean(false); + + @Override + public void run(ScenarioRunner scenario) { + hasRun.set(true); + } + + public AtomicBoolean hasRun() { + return hasRun; + } +} diff --git a/simulator-spring-boot/src/test/java/org/citrusframework/simulator/service/ScenarioWithoutEndpoint.java b/simulator-spring-boot/src/test/java/org/citrusframework/simulator/service/ScenarioWithoutEndpoint.java new file mode 100644 index 000000000..4eba4d65b --- /dev/null +++ b/simulator-spring-boot/src/test/java/org/citrusframework/simulator/service/ScenarioWithoutEndpoint.java @@ -0,0 +1,64 @@ +/* + * Copyright the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.citrusframework.simulator.service; + +import jakarta.annotation.Nullable; +import org.citrusframework.TestCaseRunner; +import org.citrusframework.simulator.scenario.ScenarioEndpoint; +import org.citrusframework.simulator.scenario.ScenarioRunner; +import org.citrusframework.simulator.scenario.SimulatorScenario; + +import java.util.concurrent.atomic.AtomicBoolean; + +public class ScenarioWithoutEndpoint implements SimulatorScenario { + + private @Nullable ScenarioEndpoint scenarioEndpoint; + + private @Nullable TestCaseRunner testCaseRunner; + + private final AtomicBoolean hasRun = new AtomicBoolean(false); + + @Override + public void run(ScenarioRunner scenario) { + hasRun.set(true); + } + + @Override + @Nullable + public ScenarioEndpoint getScenarioEndpoint() { + return scenarioEndpoint; + } + + public void setScenarioEndpoint(@Nullable ScenarioEndpoint scenarioEndpoint) { + this.scenarioEndpoint = scenarioEndpoint; + } + + @Nullable + @Override + public TestCaseRunner getTestCaseRunner() { + return testCaseRunner; + } + + @Override + public void setTestCaseRunner(@Nullable TestCaseRunner testCaseRunner) { + this.testCaseRunner = testCaseRunner; + } + + public AtomicBoolean hasRun() { + return hasRun; + } +} diff --git a/simulator-spring-boot/src/test/java/org/citrusframework/simulator/service/TestClassLoader.java b/simulator-spring-boot/src/test/java/org/citrusframework/simulator/service/TestClassLoader.java new file mode 100644 index 000000000..963121aca --- /dev/null +++ b/simulator-spring-boot/src/test/java/org/citrusframework/simulator/service/TestClassLoader.java @@ -0,0 +1,65 @@ +/* + * Copyright the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.citrusframework.simulator.service; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +class TestClassLoader { + + /** + * Loads a Java class file from src/test/java as a string. + * + * @param packageName The package name with dots (e.g., "com.example.test") + * @param className The class name including .java extension (e.g., "MyTest.java") + * @return The contents of the class file as a string + * @throws IOException If the file cannot be read + */ + static String loadTestClass(String packageName, String className) throws IOException { + // Convert package name to path format + String packagePath = packageName.replace('.', '/'); + + // Build the full path + Path classPath = Paths.get( + "src/test/java", + packagePath, + className + ); + + // Read and return the file contents + return Files.readString(classPath, StandardCharsets.UTF_8); + } + + /** + * Alternative method that takes the full class name + * + * @param fullClassName The fully qualified class name (e.g., "com.example.test.MyTest") + * @return The contents of the class file as a string + * @throws IOException If the file cannot be read + */ + static String loadTestClass(String fullClassName) throws IOException { + // Split the full class name into package and class + int lastDot = fullClassName.lastIndexOf('.'); + String packageName = fullClassName.substring(0, lastDot); + String className = fullClassName.substring(lastDot + 1) + ".java"; + + return loadTestClass(packageName, className); + } +} diff --git a/simulator-spring-boot/src/test/java/org/citrusframework/simulator/web/rest/ScenarioResourceIT.java b/simulator-spring-boot/src/test/java/org/citrusframework/simulator/web/rest/ScenarioResourceIT.java index 2edeaa6bb..f3595a1ed 100644 --- a/simulator-spring-boot/src/test/java/org/citrusframework/simulator/web/rest/ScenarioResourceIT.java +++ b/simulator-spring-boot/src/test/java/org/citrusframework/simulator/web/rest/ScenarioResourceIT.java @@ -16,16 +16,25 @@ package org.citrusframework.simulator.web.rest; +import dev.openfeature.sdk.providers.memory.Flag; +import dev.openfeature.sdk.providers.memory.InMemoryProvider; import org.citrusframework.simulator.IntegrationTest; +import org.citrusframework.simulator.config.OpenFeatureConfig; import org.citrusframework.simulator.service.impl.ScenarioLookupServiceImplTest; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; +import java.util.Map; + import static java.net.URLEncoder.encode; import static java.nio.charset.StandardCharsets.UTF_8; +import static org.citrusframework.simulator.config.OpenFeatureConfig.EXPERIMENTAL_SCENARIO_LOADING_AT_RUNTIME_ENABLED; import static org.citrusframework.simulator.service.impl.ScenarioLookupServiceImplTest.SCENARIO_NAME; import static org.citrusframework.simulator.service.impl.ScenarioLookupServiceImplTest.STARTER_NAME; import static org.citrusframework.simulator.web.rest.ScenarioResource.Scenario.ScenarioType.MESSAGE_TRIGGERED; @@ -37,6 +46,7 @@ import static org.hamcrest.Matchers.hasItem; import static org.hamcrest.Matchers.hasItems; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -49,6 +59,7 @@ */ @IntegrationTest @AutoConfigureMockMvc +@Import(ScenarioResourceIT.ScenarioRegistrationConfig.class) class ScenarioResourceIT { private static final String ENTITY_API_URL = "/api/scenarios"; @@ -162,6 +173,53 @@ void getMultipleScenariosWithNameContains() throws Exception { ))); } + @Test + void uploadDynamicScenarioAtRuntime() throws Exception { + // Standalone version of the HelloScenario in REST Sample + var scenarioName = "StandaloneHelloScenario"; + var javaSourceCode = """ + package org.citrusframework.simulator.scenario; + + import org.springframework.http.HttpStatus; + import org.springframework.web.bind.annotation.RequestMapping; + import org.springframework.web.bind.annotation.RequestMethod; + + import static org.citrusframework.actions.EchoAction.Builder.echo; + import static org.citrusframework.dsl.MessageSupport.MessageBodySupport.fromBody; + + @Scenario("StandaloneHelloScenario") + @RequestMapping(value = "/services/rest/simulator/hello", method = RequestMethod.POST) + public class StandaloneHelloScenario extends SimpleInitializedSimulatorScenario { + + @Override + public void run(ScenarioRunner scenario) { + scenario.$(scenario.http() + .receive() + .post() + .message() + .body("" + + "Say Hello!" + + "") + .extract(fromBody().expression("//hello:Hello", "greeting"))); + scenario.$(echo("Received greeting: ${greeting}")); + scenario.$(scenario.http() + .send() + .response(HttpStatus.OK) + .message() + .body("Hi there!")); + } + } + """; + + restScenarioParameterMockMvc + .perform(post(ENTITY_API_URL_SCENARIO_NAME, scenarioName) + .contentType(MediaType.TEXT_PLAIN) + .content(javaSourceCode)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.name").value(equalTo(scenarioName))) + .andExpect(jsonPath("$.type").value(equalTo("MESSAGE_TRIGGERED"))); + } + @Test void getAllScenarioStarterParameters() throws Exception { restScenarioParameterMockMvc @@ -172,4 +230,19 @@ void getAllScenarioStarterParameters() throws Exception { .andExpect(jsonPath("$.[0].name").value(equalTo("parameter-name"))) .andExpect(jsonPath("$.[0].value").value(equalTo("parameter-value"))); } + + @TestConfiguration + static class ScenarioRegistrationConfig { + + @Bean + public OpenFeatureConfig.FeatureProviderFactory featureProviderFactory() { + return () -> new InMemoryProvider(Map.of( + EXPERIMENTAL_SCENARIO_LOADING_AT_RUNTIME_ENABLED, Flag.builder() + .variant("on", true) + .variant("off", false) + .defaultVariant("on") + .build() + )); + } + } } diff --git a/simulator-spring-boot/src/test/java/org/citrusframework/simulator/web/rest/ScenarioResourceTest.java b/simulator-spring-boot/src/test/java/org/citrusframework/simulator/web/rest/ScenarioResourceTest.java index eee032cf7..631e2c834 100644 --- a/simulator-spring-boot/src/test/java/org/citrusframework/simulator/web/rest/ScenarioResourceTest.java +++ b/simulator-spring-boot/src/test/java/org/citrusframework/simulator/web/rest/ScenarioResourceTest.java @@ -16,9 +16,17 @@ package org.citrusframework.simulator.web.rest; +import com.fasterxml.jackson.databind.node.ObjectNode; +import jakarta.annotation.Nullable; +import org.citrusframework.TestCaseRunner; +import org.citrusframework.simulator.common.FeatureFlagNotEnabledException; import org.citrusframework.simulator.events.ScenariosReloadedEvent; +import org.citrusframework.simulator.scenario.ScenarioEndpoint; +import org.citrusframework.simulator.scenario.ScenarioStarter; +import org.citrusframework.simulator.scenario.SimulatorScenario; import org.citrusframework.simulator.service.ScenarioExecutorService; import org.citrusframework.simulator.service.ScenarioLookupService; +import org.citrusframework.simulator.service.ScenarioRegistrationService; import org.citrusframework.simulator.web.rest.ScenarioResource.Scenario; import org.citrusframework.simulator.web.rest.dto.mapper.ScenarioParameterMapper; import org.junit.jupiter.api.AfterEach; @@ -44,10 +52,17 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.InstanceOfAssertFactories.LIST; import static org.assertj.core.api.InstanceOfAssertFactories.type; +import static org.citrusframework.simulator.config.OpenFeatureConfig.EXPERIMENTAL_SCENARIO_LOADING_AT_RUNTIME_ENABLED; +import static org.citrusframework.simulator.web.rest.ScenarioResource.Scenario.ScenarioType.MESSAGE_TRIGGERED; import static org.citrusframework.simulator.web.rest.ScenarioResource.Scenario.ScenarioType.STARTER; import static org.junit.jupiter.params.provider.Arguments.arguments; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.doThrow; import static org.springframework.data.domain.Pageable.unpaged; +import static org.springframework.http.HttpStatus.CREATED; +import static org.springframework.http.HttpStatus.NOT_IMPLEMENTED; import static org.springframework.test.util.ReflectionTestUtils.getField; import static org.springframework.test.util.ReflectionTestUtils.setField; import static org.springframework.web.context.request.RequestContextHolder.resetRequestAttributes; @@ -62,6 +77,9 @@ class ScenarioResourceTest { @Mock private ScenarioLookupService scenarioLookupServiceMock; + @Mock + private ScenarioRegistrationService scenarioRegistrationServiceMock; + @Mock private ScenarioParameterMapper scenarioParameterMapperMock; @@ -69,7 +87,7 @@ class ScenarioResourceTest { @BeforeEach void beforeEachSetup() { - fixture = new ScenarioResource(scenarioExecutorServiceMock, scenarioLookupServiceMock, scenarioParameterMapperMock); + fixture = new ScenarioResource(scenarioExecutorServiceMock, scenarioLookupServiceMock, scenarioRegistrationServiceMock, scenarioParameterMapperMock); } @Test @@ -171,4 +189,130 @@ void afterEachTeardown() { resetRequestAttributes(); } } + + @Nested + class UploadScenario { + + private static final String SCENARIO_NAME = "TestScenario"; + private static final String JAVA_SOURCE_CODE = "public class TestScenario implements ScenarioStarter { }"; + + @Test + void shouldCreateStarterScenarioSuccessfully() throws FeatureFlagNotEnabledException { + var simulatorScenario = new ScenarioStarter() { + @Override + public ScenarioEndpoint getScenarioEndpoint() { + return null; + } + + @Override + public String getName() { + return SCENARIO_NAME; + } + + @Nullable + @Override + public TestCaseRunner getTestCaseRunner() { + return null; + } + + @Override + public void setTestCaseRunner(TestCaseRunner testCaseRunner) { + + } + }; + + doReturn(simulatorScenario) + .when(scenarioRegistrationServiceMock) + .registerScenarioFromJavaSourceCode( + eq(SCENARIO_NAME), + eq(JAVA_SOURCE_CODE)); + + // When + ResponseEntity response = fixture.uploadScenario(SCENARIO_NAME, JAVA_SOURCE_CODE); + + // Then + assertThat(response.getStatusCode()).isEqualTo(CREATED); + assertThat(response.getHeaders().getLocation()) + .hasToString("/api/scenarios/" + SCENARIO_NAME); + + Scenario responseBody = (Scenario) response.getBody(); + assertThat(responseBody) + .isNotNull() + .satisfies( + scenario -> assertThat(scenario.name()).isEqualTo(SCENARIO_NAME), + scenario -> assertThat(scenario.type()).isEqualTo(STARTER) + ); + } + + @Test + void shouldCreateMessageTriggeredScenarioSuccessfully() throws FeatureFlagNotEnabledException { + var simulatorScenario = new SimulatorScenario() { + @Override + public ScenarioEndpoint getScenarioEndpoint() { + return null; + } + + @Override + public String getName() { + return SCENARIO_NAME; + } + + @Nullable + @Override + public TestCaseRunner getTestCaseRunner() { + return null; + } + + @Override + public void setTestCaseRunner(TestCaseRunner testCaseRunner) { + + } + }; + + doReturn(simulatorScenario) + .when(scenarioRegistrationServiceMock) + .registerScenarioFromJavaSourceCode( + eq(SCENARIO_NAME), + eq(JAVA_SOURCE_CODE) + ); + + // When + ResponseEntity response = fixture.uploadScenario(SCENARIO_NAME, JAVA_SOURCE_CODE); + + // Then + assertThat(response.getStatusCode()).isEqualTo(CREATED); + assertThat(response.getHeaders().getLocation()) + .hasToString("/api/scenarios/" + SCENARIO_NAME); + + Scenario responseBody = (Scenario) response.getBody(); + assertThat(responseBody) + .isNotNull() + .satisfies(scenario -> assertThat(scenario.name()).isEqualTo(SCENARIO_NAME), + scenario -> assertThat(scenario.type()).isEqualTo(MESSAGE_TRIGGERED) + ); + } + + @Test + void shouldReturnNotImplementedWhenFeatureFlagNotEnabled() throws FeatureFlagNotEnabledException { + doThrow(new FeatureFlagNotEnabledException(EXPERIMENTAL_SCENARIO_LOADING_AT_RUNTIME_ENABLED)) + .when(scenarioRegistrationServiceMock) + .registerScenarioFromJavaSourceCode( + anyString(), + anyString() + ); + + ResponseEntity response = fixture.uploadScenario(SCENARIO_NAME, JAVA_SOURCE_CODE); + + assertThat(response.getStatusCode()).isEqualTo(NOT_IMPLEMENTED); + + ObjectNode responseBody = (ObjectNode) response.getBody(); + assertThat(responseBody) + .isNotNull(); + + assertThat(responseBody.get("message").asText()) + .isEqualTo("Feature flag not enabled."); + assertThat(responseBody.get("flag").asText()) + .isEqualTo(EXPERIMENTAL_SCENARIO_LOADING_AT_RUNTIME_ENABLED); + } + } }