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 extends JavaFileObject> 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);
+ }
+ }
}