Skip to content

Commit

Permalink
feat(citrusframework#254): load scenarios at runtime
Browse files Browse the repository at this point in the history
  • Loading branch information
bbortt committed Apr 12, 2024
1 parent 9feb5a3 commit 12d00cf
Show file tree
Hide file tree
Showing 6 changed files with 290 additions and 8 deletions.
Original file line number Diff line number Diff line change
@@ -1,11 +1,27 @@
/*
* Copyright 2024 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.events;

import org.citrusframework.simulator.service.ScenarioLookupService;
import org.springframework.context.ApplicationEvent;

import java.util.Set;

public final class ScenariosReloadedEvent extends ApplicationEvent {
public final class ScenariosReloadedEvent extends ApplicationEvent {

private final Set<String> scenarioNames;
private final Set<String> scenarioStarterNames;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
/*
* Copyright 2024 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 <T> Class<T> 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<T>) inMemoryClassLoader.loadClass(className);
} else {
throw new ClassNotFoundException("Class " + className + " not compiled");
}
}

@Getter
private static class InMemoryJavaFileManager extends ForwardingJavaFileManager<JavaFileManager> implements JavaFileManager {

private final List<InMemoryByteCode> 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;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/*
* Copyright 2024 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.exceptions.CitrusRuntimeException;
import org.citrusframework.simulator.scenario.DynamicClassLoader;
import org.citrusframework.simulator.scenario.SimulatorScenario;
import org.springframework.beans.factory.support.BeanDefinitionRegistry;
import org.springframework.context.ApplicationContext;
import org.springframework.stereotype.Service;

import static org.springframework.beans.factory.support.BeanDefinitionBuilder.genericBeanDefinition;

@Service
public class ScenarioRegistrationService {

private final DynamicClassLoader dynamicClassLoader = new DynamicClassLoader();

private final ApplicationContext applicationContext;
private final ScenarioLookupService scenarioLookupService;

public ScenarioRegistrationService(ApplicationContext applicationContext, ScenarioLookupService scenarioLookupService) {
this.applicationContext = applicationContext;
this.scenarioLookupService = scenarioLookupService;
}

public SimulatorScenario registerScenarioFromJavaSourceCode(String scenarioName, String javaSourceCode) {
try {
Class<SimulatorScenario> loadedClass = dynamicClassLoader.compileAndLoad(scenarioName, javaSourceCode);
SimulatorScenario simulatorScenario = loadedClass.getDeclaredConstructor().newInstance();

registerScenarioBean(scenarioName, loadedClass);

scenarioLookupService.evictAndReloadScenarioCache();

return simulatorScenario;
} catch (Exception e) {
throw new CitrusRuntimeException(e);
}
}

private void registerScenarioBean(String scenarioName, Class<SimulatorScenario> 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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,14 @@
package org.citrusframework.simulator.web.rest;

import jakarta.validation.constraints.NotEmpty;
import lombok.extern.slf4j.Slf4j;
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.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;
Expand All @@ -38,6 +39,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;
Expand All @@ -51,23 +53,27 @@
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.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 List<Scenario> scenarioCache = new ArrayList<>();

public ScenarioResource(ScenarioExecutorService scenarioExecutorService, ScenarioLookupService scenarioLookupService) {
public ScenarioResource(ScenarioExecutorService scenarioExecutorService, ScenarioLookupService scenarioLookupService, ScenarioRegistrationService scenarioRegistrationService) {
this.scenarioExecutorService = scenarioExecutorService;
this.scenarioLookupService = scenarioLookupService;
this.scenarioRegistrationService = scenarioRegistrationService;

evictAndReloadScenarioCache(scenarioLookupService.getScenarioNames(), scenarioLookupService.getStarterNames());
}
Expand Down Expand Up @@ -96,7 +102,7 @@ private synchronized void evictAndReloadScenarioCache(Set<String> 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<List<Scenario>> getScenarios(@RequestParam(name = "nameContains", required = false) Optional<String> 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);
Expand All @@ -113,6 +119,12 @@ public ResponseEntity<List<Scenario>> 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<Scenario> uploadScenario(@PathVariable("scenarioName") String scenarioName, @RequestBody String javaSourceCode) {
var simulatorScenario = scenarioRegistrationService.registerScenarioFromJavaSourceCode(scenarioName, javaSourceCode);
return created(URI.create("/api/scenarios/" + scenarioName)).body(new Scenario(simulatorScenario.getName(), simulatorScenario instanceof ScenarioStarter ? STARTER : MESSAGE_TRIGGERED));
}

/**
* Get the {@link ScenarioParameter}'s for the {@link Scenario} matching the supplied name
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,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;
Expand Down Expand Up @@ -162,6 +163,58 @@ 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.sample.scenario;
import org.citrusframework.simulator.scenario.AbstractSimulatorScenario;
import org.citrusframework.simulator.scenario.Scenario;
import org.citrusframework.simulator.scenario.ScenarioRunner;
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 AbstractSimulatorScenario {
@Override
public void run(ScenarioRunner scenario) {
scenario.$(scenario.http()
.receive()
.post()
.message()
.body("<Hello xmlns=\\"http://citrusframework.org/schemas/hello\\">" +
"Say Hello!" +
"</Hello>")
.extract(fromBody().expression("//hello:Hello", "greeting")));
scenario.$(echo("Received greeting: ${greeting}"));
scenario.$(scenario.http()
.send()
.response(HttpStatus.OK)
.message()
.body("<HelloResponse xmlns=\\"http://citrusframework.org/schemas/hello\\">Hi there!</HelloResponse>"));
}
}
""";

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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import org.citrusframework.simulator.events.ScenariosReloadedEvent;
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.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
Expand Down Expand Up @@ -60,11 +61,14 @@ class ScenarioResourceTest {
@Mock
private ScenarioLookupService scenarioLookupServiceMock;

@Mock
private ScenarioRegistrationService scenarioRegistrationServiceMock;

private ScenarioResource fixture;

@BeforeEach
void beforeEachSetup() {
fixture = new ScenarioResource(scenarioExecutorServiceMock, scenarioLookupServiceMock);
fixture = new ScenarioResource(scenarioExecutorServiceMock, scenarioLookupServiceMock, scenarioRegistrationServiceMock);
}

@Test
Expand Down

0 comments on commit 12d00cf

Please sign in to comment.