diff --git a/experimental/operaton-engine-bpmn/build.gradle b/experimental/operaton-engine-bpmn/build.gradle new file mode 100644 index 000000000..1bc74e59d --- /dev/null +++ b/experimental/operaton-engine-bpmn/build.gradle @@ -0,0 +1,21 @@ +apply from: "${project.rootDir}/gradle/in-test-generated.gradle" + +dependencies { + annotationProcessor project(":config:config-annotation-processor") + + compileOnly project(":database:database-jdbc") + + implementation project(":config:config-common") + implementation libs.fasterxml.uuidgenerator + + api project(":common") + api(libs.operaton.engine) { + exclude group: 'org.springframework', module: 'spring-beans' + exclude group: 'org.apache.tomcat', module: 'catalina' + } + + testImplementation libs.jdbc.postgresql + testImplementation project(":database:database-jdbc") + testImplementation project(":internal:test-logging") + testImplementation project(":internal:test-postgres") +} diff --git a/experimental/operaton-engine-bpmn/src/main/java/io/koraframework/bpmn/operaton/engine/JobExecutorReadinessProbe.java b/experimental/operaton-engine-bpmn/src/main/java/io/koraframework/bpmn/operaton/engine/JobExecutorReadinessProbe.java new file mode 100644 index 000000000..404536f1d --- /dev/null +++ b/experimental/operaton-engine-bpmn/src/main/java/io/koraframework/bpmn/operaton/engine/JobExecutorReadinessProbe.java @@ -0,0 +1,25 @@ +package io.koraframework.bpmn.operaton.engine; + +import org.operaton.bpm.engine.impl.jobexecutor.JobExecutor; +import org.jspecify.annotations.Nullable; +import io.koraframework.common.readiness.ReadinessProbe; +import io.koraframework.common.readiness.ReadinessProbeFailure; + +public final class JobExecutorReadinessProbe implements ReadinessProbe { + + private final JobExecutor jobExecutor; + + public JobExecutorReadinessProbe(JobExecutor jobExecutor) { + this.jobExecutor = jobExecutor; + } + + @Nullable + @Override + public ReadinessProbeFailure probe() { + if (jobExecutor.isAutoActivate()) { + return null; + } else { + return new ReadinessProbeFailure("Operaton BPMN Engine JobExecutor is not active"); + } + } +} diff --git a/experimental/operaton-engine-bpmn/src/main/java/io/koraframework/bpmn/operaton/engine/KoraArtifactFactory.java b/experimental/operaton-engine-bpmn/src/main/java/io/koraframework/bpmn/operaton/engine/KoraArtifactFactory.java new file mode 100644 index 000000000..3194ca434 --- /dev/null +++ b/experimental/operaton-engine-bpmn/src/main/java/io/koraframework/bpmn/operaton/engine/KoraArtifactFactory.java @@ -0,0 +1,40 @@ +package io.koraframework.bpmn.operaton.engine; + +import org.operaton.bpm.engine.ArtifactFactory; +import org.operaton.bpm.engine.delegate.JavaDelegate; +import org.operaton.bpm.engine.impl.DefaultArtifactFactory; + +import java.util.HashMap; +import java.util.Map; + +public final class KoraArtifactFactory implements ArtifactFactory { + + private final ArtifactFactory defaultArtifactFactory = new DefaultArtifactFactory(); + private final Map componentByKey; + + public KoraArtifactFactory(KoraDelegateWrapperFactory wrapperFactory, + Iterable koraDelegates, + Iterable javaDelegates) { + this.componentByKey = new HashMap<>(); + for (JavaDelegate delegate : javaDelegates) { + JavaDelegate wrapped = wrapperFactory.wrap(delegate); + this.componentByKey.put(delegate.getClass().getCanonicalName(), wrapped); + } + + for (JavaDelegate delegate : koraDelegates) { + JavaDelegate wrapped = wrapperFactory.wrap(delegate); + this.componentByKey.put(delegate.getClass().getCanonicalName(), wrapped); + } + } + + @Override + public T getArtifact(Class clazz) { + @SuppressWarnings("unchecked") + var artifact = (T) componentByKey.get(clazz.getCanonicalName()); + if (artifact != null) { + return artifact; + } + + return defaultArtifactFactory.getArtifact(clazz); + } +} diff --git a/experimental/operaton-engine-bpmn/src/main/java/io/koraframework/bpmn/operaton/engine/KoraDelegate.java b/experimental/operaton-engine-bpmn/src/main/java/io/koraframework/bpmn/operaton/engine/KoraDelegate.java new file mode 100644 index 000000000..fdb665323 --- /dev/null +++ b/experimental/operaton-engine-bpmn/src/main/java/io/koraframework/bpmn/operaton/engine/KoraDelegate.java @@ -0,0 +1,12 @@ +package io.koraframework.bpmn.operaton.engine; + +import org.operaton.bpm.engine.delegate.JavaDelegate; +import org.jspecify.annotations.NonNull; + +public interface KoraDelegate extends JavaDelegate { + + @NonNull + default String key() { + return getClass().getCanonicalName(); + } +} diff --git a/experimental/operaton-engine-bpmn/src/main/java/io/koraframework/bpmn/operaton/engine/KoraDelegateWrapperFactory.java b/experimental/operaton-engine-bpmn/src/main/java/io/koraframework/bpmn/operaton/engine/KoraDelegateWrapperFactory.java new file mode 100644 index 000000000..45018b51a --- /dev/null +++ b/experimental/operaton-engine-bpmn/src/main/java/io/koraframework/bpmn/operaton/engine/KoraDelegateWrapperFactory.java @@ -0,0 +1,8 @@ +package io.koraframework.bpmn.operaton.engine; + +import org.operaton.bpm.engine.delegate.JavaDelegate; + +public interface KoraDelegateWrapperFactory { + + JavaDelegate wrap(JavaDelegate delegate); +} diff --git a/experimental/operaton-engine-bpmn/src/main/java/io/koraframework/bpmn/operaton/engine/KoraELResolver.java b/experimental/operaton-engine-bpmn/src/main/java/io/koraframework/bpmn/operaton/engine/KoraELResolver.java new file mode 100644 index 000000000..1fcfbbff3 --- /dev/null +++ b/experimental/operaton-engine-bpmn/src/main/java/io/koraframework/bpmn/operaton/engine/KoraELResolver.java @@ -0,0 +1,75 @@ +package io.koraframework.bpmn.operaton.engine; + +import jakarta.el.ELContext; +import jakarta.el.ELResolver; +import org.operaton.bpm.engine.ProcessEngineException; +import org.operaton.bpm.engine.delegate.JavaDelegate; + +import java.beans.FeatureDescriptor; +import java.util.HashMap; +import java.util.Iterator; +import java.util.Map; + +public class KoraELResolver extends ELResolver { + + private final Map componentByKey; + + public KoraELResolver(KoraDelegateWrapperFactory wrapperFactory, + Iterable koraDelegates, + Iterable javaDelegates) { + this.componentByKey = new HashMap<>(); + for (JavaDelegate delegate : javaDelegates) { + JavaDelegate wrapped = wrapperFactory.wrap(delegate); + this.componentByKey.put(delegate.getClass().getSimpleName(), wrapped); + this.componentByKey.put(delegate.getClass().getCanonicalName(), wrapped); + } + + for (KoraDelegate delegate : koraDelegates) { + JavaDelegate wrapped = wrapperFactory.wrap(delegate); + this.componentByKey.put(delegate.key(), wrapped); + this.componentByKey.put(delegate.getClass().getSimpleName(), wrapped); + this.componentByKey.put(delegate.getClass().getCanonicalName(), wrapped); + } + } + + @Override + public Object getValue(ELContext context, Object base, Object property) { + if (base == null) { + // according to javadoc, can only be a String + String key = (String) property; + Object value = componentByKey.get(key); + if (value != null) { + context.setPropertyResolved(true); + return value; + } + } + return null; + } + + @Override + public boolean isReadOnly(ELContext context, Object base, Object property) { + return true; + } + + @Override + public void setValue(ELContext context, Object base, Object property, Object value) { + if (base == null) { + String key = (String) property; + if (componentByKey.containsKey(key)) { + throw new ProcessEngineException( + "Cannot set value of '" + property + "', it resolves to a CamundaComponent defined in the Kora application." + ); + } + } + } + + @Override + public Class getCommonPropertyType(ELContext context, Object base) { + return Object.class; + } + + @Override + public Class getType(ELContext context, Object base, Object property) { + return Object.class; + } +} diff --git a/experimental/operaton-engine-bpmn/src/main/java/io/koraframework/bpmn/operaton/engine/KoraExpressionManager.java b/experimental/operaton-engine-bpmn/src/main/java/io/koraframework/bpmn/operaton/engine/KoraExpressionManager.java new file mode 100644 index 000000000..6af5cc86a --- /dev/null +++ b/experimental/operaton-engine-bpmn/src/main/java/io/koraframework/bpmn/operaton/engine/KoraExpressionManager.java @@ -0,0 +1,29 @@ +package io.koraframework.bpmn.operaton.engine; + +import jakarta.el.*; +import org.operaton.bpm.engine.impl.el.JuelExpressionManager; +import org.operaton.bpm.engine.impl.el.VariableContextElResolver; +import org.operaton.bpm.engine.impl.el.VariableScopeElResolver; +import org.operaton.bpm.engine.impl.mock.MockElResolver; + +public final class KoraExpressionManager extends JuelExpressionManager { + + private final ELResolver koraELResolver; + + public KoraExpressionManager(ELResolver koraELResolver) { + this.koraELResolver = koraELResolver; + } + + @Override + protected ELResolver createElResolver() { + CompositeELResolver resolver = new CompositeELResolver(); + resolver.add(koraELResolver); + resolver.add(new VariableScopeElResolver()); + resolver.add(new VariableContextElResolver()); + resolver.add(new MockElResolver()); + resolver.add(new ArrayELResolver()); + resolver.add(new ListELResolver()); + resolver.add(new MapELResolver()); + return resolver; + } +} diff --git a/experimental/operaton-engine-bpmn/src/main/java/io/koraframework/bpmn/operaton/engine/KoraProcessEngine.java b/experimental/operaton-engine-bpmn/src/main/java/io/koraframework/bpmn/operaton/engine/KoraProcessEngine.java new file mode 100644 index 000000000..40f8c4d8f --- /dev/null +++ b/experimental/operaton-engine-bpmn/src/main/java/io/koraframework/bpmn/operaton/engine/KoraProcessEngine.java @@ -0,0 +1,105 @@ +package io.koraframework.bpmn.operaton.engine; + +import io.koraframework.application.graph.Lifecycle; +import io.koraframework.application.graph.Wrapped; +import io.koraframework.bpmn.operaton.engine.configurator.ProcessEngineConfigurator; +import io.koraframework.common.util.TimeUtils; +import org.jspecify.annotations.Nullable; +import org.operaton.bpm.engine.ProcessEngine; +import org.operaton.bpm.engine.ProcessEngineConfiguration; +import org.operaton.bpm.engine.ProcessEngines; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.concurrent.CompletableFuture; + +public final class KoraProcessEngine implements Lifecycle, Wrapped { + + private static final Logger logger = LoggerFactory.getLogger(KoraProcessEngine.class); + + private final ProcessEngineConfiguration engineConfiguration; + private final OperatonEngineBpmnConfig engineConfig; + private final Iterable engineConfigurators; + + private volatile ProcessEngine processEngine; + + public KoraProcessEngine(ProcessEngineConfiguration engineConfiguration, + OperatonEngineBpmnConfig engineConfig, + Iterable engineConfigurators) { + this.engineConfig = engineConfig; + this.engineConfiguration = engineConfiguration; + this.engineConfigurators = engineConfigurators; + } + + @Override + public void init() { + try { + for (ProcessEngineConfigurator configurator : engineConfigurators) { + configurator.prepare(engineConfiguration); + } + + if (engineConfig.parallelInitialization().enabled() && engineConfiguration instanceof KoraProcessEngineConfiguration) { + logger.info("Operaton BPMN Engine parallel initialization enabled"); + + logger.debug("Operaton BPMN Engine starting first stage..."); + final long started = TimeUtils.started(); + + this.processEngine = engineConfiguration.buildProcessEngine(); + ProcessEngines.registerProcessEngine(processEngine); + logger.info("Operaton BPMN Engine started first stage in {}", TimeUtils.tookForLogging(started)); + } else { + logger.debug("Operaton BPMN Engine starting..."); + final long started = TimeUtils.started(); + + this.processEngine = engineConfiguration.buildProcessEngine(); + ProcessEngines.registerProcessEngine(processEngine); + logger.info("Operaton BPMN Engine started in {}", TimeUtils.tookForLogging(started)); + + logger.debug("Operaton BPMN Engine configuring..."); + final long startedConfiguring = TimeUtils.started(); + + var configurators = new ArrayList(); + for (var configurator : this.engineConfigurators) { + configurators.add(configurator); + } + var setups = new CompletableFuture[configurators.size()]; + for (var i = 0; i < configurators.size(); i++) { + var engineConfigurator = configurators.get(i); + var future = new CompletableFuture<@Nullable Void>(); + Thread.ofVirtual().name("operaton-process-engine-config-" + i).start(() -> { + try { + engineConfigurator.setup(processEngine); + future.complete(null); + } catch (Throwable t) { + future.completeExceptionally(t); + } + }); + setups[i] = future; + } + CompletableFuture.allOf(setups).join(); + logger.info("Operaton BPMN Engine configured in {}", TimeUtils.tookForLogging(startedConfiguring)); + } + } catch (Exception e) { + throw new RuntimeException("Operaton BPMN Engine failed to start, due to: " + e.getMessage(), e); + } + } + + @Override + public void release() { + if (processEngine != null) { + logger.debug("Operaton BPMN Engine stopping..."); + final long started = TimeUtils.started(); + + ProcessEngines.unregister(processEngine); + processEngine.close(); + + logger.info("Operaton BPMN Engine stopped in {}", TimeUtils.tookForLogging(started)); + } + } + + @Override + public ProcessEngine value() { + return processEngine; + } +} diff --git a/experimental/operaton-engine-bpmn/src/main/java/io/koraframework/bpmn/operaton/engine/KoraProcessEngineConfiguration.java b/experimental/operaton-engine-bpmn/src/main/java/io/koraframework/bpmn/operaton/engine/KoraProcessEngineConfiguration.java new file mode 100644 index 000000000..09f8d4ec6 --- /dev/null +++ b/experimental/operaton-engine-bpmn/src/main/java/io/koraframework/bpmn/operaton/engine/KoraProcessEngineConfiguration.java @@ -0,0 +1,384 @@ +package io.koraframework.bpmn.operaton.engine; + +import io.koraframework.bpmn.operaton.engine.transaction.KoraTransactionContextFactory; +import io.koraframework.bpmn.operaton.engine.transaction.KoraTransactionInterceptor; +import org.apache.ibatis.builder.xml.XMLConfigBuilder; +import org.operaton.bpm.engine.ArtifactFactory; +import org.operaton.bpm.engine.ProcessEngineConfiguration; +import org.operaton.bpm.engine.history.*; +import org.operaton.bpm.engine.impl.*; +import org.operaton.bpm.engine.impl.cfg.IdGenerator; +import org.operaton.bpm.engine.impl.cfg.ProcessEngineConfigurationImpl; +import org.operaton.bpm.engine.impl.cfg.ProcessEnginePlugin; +import org.operaton.bpm.engine.impl.cmmn.CaseServiceImpl; +import org.operaton.bpm.engine.impl.cmmn.entity.repository.CaseDefinitionQueryImpl; +import org.operaton.bpm.engine.impl.cmmn.entity.runtime.CaseExecutionQueryImpl; +import org.operaton.bpm.engine.impl.cmmn.entity.runtime.CaseInstanceQueryImpl; +import org.operaton.bpm.engine.impl.el.JuelExpressionManager; +import org.operaton.bpm.engine.impl.interceptor.*; +import org.operaton.bpm.engine.impl.jobexecutor.JobExecutor; +import org.operaton.bpm.engine.repository.CaseDefinition; +import org.operaton.bpm.engine.repository.CaseDefinitionQuery; +import org.operaton.bpm.engine.runtime.CaseExecution; +import org.operaton.bpm.engine.runtime.CaseExecutionQuery; +import org.operaton.bpm.engine.runtime.CaseInstance; +import org.operaton.bpm.engine.runtime.CaseInstanceQuery; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.util.*; + +import static java.util.Collections.emptyList; +import static org.operaton.bpm.engine.impl.history.HistoryLevel.HISTORY_LEVEL_FULL; + +public class KoraProcessEngineConfiguration extends ProcessEngineConfigurationImpl { + + private static final Logger logger = LoggerFactory.getLogger(KoraProcessEngineConfiguration.class); + + private final JobExecutor jobExecutor; + private final KoraResolverFactory componentResolverFactory; + private final OperatonEngineDataSource engineDataSource; + private final OperatonEngineBpmnConfig engineConfig; + private final Iterable plugins; + + public KoraProcessEngineConfiguration(JobExecutor jobExecutor, + IdGenerator idGenerator, + JuelExpressionManager koraExpressionManager, + ArtifactFactory artifactFactory, + Iterable plugins, + OperatonEngineDataSource engineDataSource, + OperatonEngineBpmnConfig engineConfig, + KoraResolverFactory componentResolverFactory) { + this.jobExecutor = jobExecutor; + this.componentResolverFactory = componentResolverFactory; + this.idGenerator = idGenerator; + this.artifactFactory = artifactFactory; + this.plugins = plugins; + this.engineConfig = engineConfig; + this.engineDataSource = engineDataSource; + + setDefaultCharset(StandardCharsets.UTF_8); + + setDataSource(engineDataSource.dataSource()); + setTransactionsExternallyManaged(true); + setIdGenerator(idGenerator); + setExpressionManager(koraExpressionManager); + setArtifactFactory(artifactFactory); + + configureDefaultValues(); + configureMetricsAndTelemetry(); + registerProcessEnginePlugins(); + + mockUnsupportedCmmnMethods(); + } + + @Override + protected void initTransactionContextFactory() { + if (transactionContextFactory == null) { + transactionContextFactory = new KoraTransactionContextFactory(engineDataSource.transactionManager()); + } + } + + @Override + protected void initJobExecutor() { + setJobExecutor(jobExecutor); + super.initJobExecutor(); + } + + @Override + protected void initScripting() { + super.initScripting(); + getResolverFactories().add(componentResolverFactory); + } + + @Override + protected Collection getDefaultCommandInterceptorsTxRequired() { + return getCommandInterceptors(false); + } + + @Override + protected Collection getDefaultCommandInterceptorsTxRequiresNew() { + return getCommandInterceptors(true); + } + + protected List getCommandInterceptors(boolean requiresNew) { + return Arrays.asList( + new LogInterceptor(), + new CommandCounterInterceptor(this), + new ProcessApplicationContextInterceptor(this), + new KoraTransactionInterceptor(engineDataSource.transactionManager(), requiresNew), + new CommandContextInterceptor(commandContextFactory, this, requiresNew) + ); + } + + protected void configureMetricsAndTelemetry() { + if (engineConfig.telemetry().metrics().engineMetrics()) { + setMetricsEnabled(true); + setTaskMetricsEnabled(true); + } else { + setMetricsEnabled(false); + setTaskMetricsEnabled(false); + } + } + + protected void configureDefaultValues() { + setJobExecutorActivate(true); + setDatabaseSchemaUpdate(ProcessEngineConfiguration.DB_SCHEMA_UPDATE_TRUE); + setEnforceHistoryTimeToLive(false); + } + + protected void registerProcessEnginePlugins() { + var pluginsList = new ArrayList(); + for (var plugin : plugins) { + pluginsList.add(plugin); + } + + + if (!pluginsList.isEmpty()) { + logger.info("Registering process engine plugins: {}", plugins); + setProcessEnginePlugins(pluginsList); + } + } + + /** + * Mocks methods which must work although we removed all references to CMMN. + */ + protected void mockUnsupportedCmmnMethods() { + repositoryService = new RepositoryServiceImpl() { + @Override + public CaseDefinitionQuery createCaseDefinitionQuery() { + return new CaseDefinitionQueryImpl(commandExecutor) { + @Override + public long executeCount(CommandContext commandContext) { + // This method is called by the Cockpit's start page + return 0; + } + + @Override + public List executeList(CommandContext commandContext, Page page) { + return emptyList(); + } + }; + } + }; + caseService = new CaseServiceImpl() { + @Override + public CaseInstanceQuery createCaseInstanceQuery() { + return new CaseInstanceQueryImpl(commandExecutor) { + @Override + public long executeCount(CommandContext commandContext) { + return 0; + } + + @Override + public List executeList(CommandContext commandContext, Page page) { + return emptyList(); + } + }; + } + + @Override + public CaseExecutionQuery createCaseExecutionQuery() { + return new CaseExecutionQueryImpl(commandExecutor) { + @Override + public long executeCount(CommandContext commandContext) { + return 0; + } + + @Override + public List executeList(CommandContext commandContext, Page page) { + return emptyList(); + } + }; + } + }; + historyService = new HistoryServiceImpl() { + @Override + public HistoricCaseInstanceQuery createHistoricCaseInstanceQuery() { + return new HistoricCaseInstanceQueryImpl(commandExecutor) { + @Override + public long executeCount(CommandContext commandContext) { + return 0; + } + + @Override + public List executeList(CommandContext commandContext, Page page) { + return emptyList(); + } + }; + } + + @Override + public HistoricCaseActivityInstanceQuery createHistoricCaseActivityInstanceQuery() { + return new org.operaton.bpm.engine.impl.HistoricCaseActivityInstanceQueryImpl(commandExecutor) { + @Override + public long executeCount(CommandContext commandContext) { + return 0; + } + + @Override + public List executeList(CommandContext commandContext, Page page) { + return emptyList(); + } + }; + } + + @Override + public HistoricCaseActivityStatisticsQuery createHistoricCaseActivityStatisticsQuery(String caseDefinitionId) { + return new HistoricCaseActivityStatisticsQueryImpl(caseDefinitionId, commandExecutor) { + @Override + public long executeCount(CommandContext commandContext) { + return 0; + } + + @Override + public List executeList(CommandContext commandContext, Page page) { + return emptyList(); + } + }; + } + + @Override + public CleanableHistoricCaseInstanceReport createCleanableHistoricCaseInstanceReport() { + return new CleanableHistoricCaseInstanceReportImpl(commandExecutor) { + @Override + public long executeCount(CommandContext commandContext) { + return 0; + } + + @Override + public List executeList(CommandContext commandContext, Page page) { + return emptyList(); + } + }; + } + }; + } + + @Override + protected InputStream getMyBatisXmlConfigurationSteam() { + if (engineConfig.parallelInitialization().enabled()) { + return getMyBatisXmlConfigurationSteamStageOne(); + } else { + BufferedReader reader = new BufferedReader(new InputStreamReader(super.getMyBatisXmlConfigurationSteam())); + try { + StringBuilder sb = new StringBuilder(); + while (reader.ready()) { + String line = reader.readLine(); + if (!line.contains("\n" + + "\n" + + "\n\n" + + "\t\n" + + "\t\t\n" + + "\t\n" + + "\t\n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + // e.g. New process definition is deployed which replaces a previous version that included a Timer Start Event + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + // e.g. Message Start Events are registered during deployment + " \n" + // FilterAllTasksCreator + " \n" + // Engine metrics can be flushed before stage two + " \n" + // DbMetricsReporter can flush on stage one close + " \n" + //AdminUserCreator + " \n" + //AdminUserCreator + " \n" + //AdminUserCreator + " \n" + //Deployment BPMN + " \n" + //Deployment DMN + " " + // Deployment DMN + "\n%s".formatted((getHistoryLevel() == HISTORY_LEVEL_FULL ? " \n" : "")) + + "\t\n" + + "\n"; + return new ByteArrayInputStream(s.getBytes()); + } + + public org.apache.ibatis.session.Configuration createConfigurationStageTwo() { + // This code is based on {@link ProcessEngineConfigurationImpl::initSqlSessionFactory} + Reader reader = new InputStreamReader(getMyBatisXmlConfigurationSteamStageTwo()); + Properties properties = new Properties(); + if (isUseSharedSqlSessionFactory) { + properties.put("prefix", "${@org.operaton.bpm.engine.impl.context.Context@getProcessEngineConfiguration().databaseTablePrefix}"); + } else { + properties.put("prefix", databaseTablePrefix); + } + initSqlSessionFactoryProperties(properties, databaseTablePrefix, databaseType); + + XMLConfigBuilder parser = new XMLConfigBuilder(reader, "", properties); + org.apache.ibatis.session.Configuration parserConfiguration = parser.getConfiguration(); + + // Add existing sql fragments from stage 1 in case they are referenced in stage 2 + parserConfiguration.getSqlFragments().putAll(getSqlSessionFactory().getConfiguration().getSqlFragments()); + parser.parse(); + return parserConfiguration; + } + + protected InputStream getMyBatisXmlConfigurationSteamStageTwo() { + String s = """ + + + + \t + \t\t + \t + \t + + + + + + + + + + + + + + + %s + + + + + + + + + + + + + + + + + \t + + """.formatted(getHistoryLevel() != HISTORY_LEVEL_FULL ? "\n \n" : ""); + return new ByteArrayInputStream(s.getBytes()); + } +} diff --git a/experimental/operaton-engine-bpmn/src/main/java/io/koraframework/bpmn/operaton/engine/KoraProcessEngineParallelInitializer.java b/experimental/operaton-engine-bpmn/src/main/java/io/koraframework/bpmn/operaton/engine/KoraProcessEngineParallelInitializer.java new file mode 100644 index 000000000..06b7cc976 --- /dev/null +++ b/experimental/operaton-engine-bpmn/src/main/java/io/koraframework/bpmn/operaton/engine/KoraProcessEngineParallelInitializer.java @@ -0,0 +1,67 @@ +package io.koraframework.bpmn.operaton.engine; + +import io.koraframework.application.graph.Lifecycle; +import io.koraframework.bpmn.operaton.engine.configurator.ProcessEngineConfigurator; +import io.koraframework.common.util.TimeUtils; +import org.operaton.bpm.engine.ProcessEngine; +import org.operaton.bpm.engine.ProcessEngineConfiguration; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.concurrent.CompletableFuture; + +public final class KoraProcessEngineParallelInitializer implements Lifecycle { + + private static final Logger logger = LoggerFactory.getLogger(KoraProcessEngineParallelInitializer.class); + + private final ProcessEngine processEngine; + private final OperatonEngineBpmnConfig engineConfig; + private final ProcessEngineConfiguration engineConfiguration; + + private final Iterable engineConfigurators; + + public KoraProcessEngineParallelInitializer(ProcessEngine processEngine, + OperatonEngineBpmnConfig engineConfig, + ProcessEngineConfiguration engineConfiguration, + Iterable engineConfigurators) { + this.processEngine = processEngine; + this.engineConfig = engineConfig; + this.engineConfiguration = engineConfiguration; + this.engineConfigurators = engineConfigurators; + } + + @Override + public void init() { + if (engineConfig.parallelInitialization().enabled() && engineConfiguration instanceof KoraProcessEngineConfiguration) { + logger.debug("Operaton BPMN Engine parallel configuring..."); + final long started = TimeUtils.started(); + var configurators = new ArrayList(); + for (var engineConfigurator : this.engineConfigurators) { + configurators.add(engineConfigurator); + } + var setups = new CompletableFuture[configurators.size()]; + for (var i = 0; i < configurators.size(); i++) { + var engineConfigurator = configurators.get(i); + var future = new CompletableFuture<@Nullable Void>(); + Thread.ofVirtual().name("operaton-process-engine-config-" + i).start(() -> { + try { + engineConfigurator.setup(processEngine); + future.complete(null); + } catch (Throwable t) { + future.completeExceptionally(t); + } + }); + setups[i] = future; + } + CompletableFuture.allOf(setups).join(); + logger.info("Operaton BPMN Engine parallel configured in {}", TimeUtils.tookForLogging(started)); + } + } + + @Override + public void release() { + // do nothing + } +} diff --git a/experimental/operaton-engine-bpmn/src/main/java/io/koraframework/bpmn/operaton/engine/KoraResolverFactory.java b/experimental/operaton-engine-bpmn/src/main/java/io/koraframework/bpmn/operaton/engine/KoraResolverFactory.java new file mode 100644 index 000000000..9ce80b778 --- /dev/null +++ b/experimental/operaton-engine-bpmn/src/main/java/io/koraframework/bpmn/operaton/engine/KoraResolverFactory.java @@ -0,0 +1,56 @@ +package io.koraframework.bpmn.operaton.engine; + +import org.operaton.bpm.engine.delegate.JavaDelegate; +import org.operaton.bpm.engine.delegate.VariableScope; +import org.operaton.bpm.engine.impl.scripting.engine.Resolver; +import org.operaton.bpm.engine.impl.scripting.engine.ResolverFactory; + +import java.util.HashMap; +import java.util.Map; +import java.util.Set; + +public final class KoraResolverFactory implements ResolverFactory, Resolver { + + private final Map componentByKey; + + public KoraResolverFactory(KoraDelegateWrapperFactory wrapperFactory, + Iterable koraDelegates, + Iterable javaDelegates) { + this.componentByKey = new HashMap<>(); + for (JavaDelegate delegate : javaDelegates) { + JavaDelegate wrapped = wrapperFactory.wrap(delegate); + this.componentByKey.put(delegate.getClass().getSimpleName(), wrapped); + this.componentByKey.put(delegate.getClass().getCanonicalName(), wrapped); + } + + for (KoraDelegate delegate : koraDelegates) { + JavaDelegate wrapped = wrapperFactory.wrap(delegate); + this.componentByKey.put(delegate.key(), wrapped); + this.componentByKey.put(delegate.getClass().getSimpleName(), wrapped); + this.componentByKey.put(delegate.getClass().getCanonicalName(), wrapped); + } + } + + @Override + public boolean containsKey(Object key) { + return key instanceof String k && this.componentByKey.containsKey(k); + } + + @Override + public Object get(Object key) { + if (key instanceof String k) { + return componentByKey.get(k); + } + return null; + } + + @Override + public Set keySet() { + return componentByKey.keySet(); + } + + @Override + public Resolver createResolver(VariableScope variableScope) { + return this; + } +} diff --git a/experimental/operaton-engine-bpmn/src/main/java/io/koraframework/bpmn/operaton/engine/KoraThreadPoolJobExecutor.java b/experimental/operaton-engine-bpmn/src/main/java/io/koraframework/bpmn/operaton/engine/KoraThreadPoolJobExecutor.java new file mode 100644 index 000000000..ee934c9c2 --- /dev/null +++ b/experimental/operaton-engine-bpmn/src/main/java/io/koraframework/bpmn/operaton/engine/KoraThreadPoolJobExecutor.java @@ -0,0 +1,56 @@ +package io.koraframework.bpmn.operaton.engine; + +import org.operaton.bpm.engine.impl.ProcessEngineImpl; +import org.operaton.bpm.engine.impl.jobexecutor.DefaultJobExecutor; + +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +public final class KoraThreadPoolJobExecutor extends DefaultJobExecutor { + + private final OperatonEngineBpmnConfig engineConfig; + + public KoraThreadPoolJobExecutor(OperatonEngineBpmnConfig engineConfig) { + this.engineConfig = engineConfig; + + var jobExecutorConfig = engineConfig.jobExecutor(); + setQueueSize(jobExecutorConfig.queueSize()); + setCorePoolSize(jobExecutorConfig.corePoolSize()); + setMaxPoolSize(jobExecutorConfig.maxPoolSize()); + setMaxJobsPerAcquisition(jobExecutorConfig.maxJobsPerAcquisition()); + } + + @Override + protected void startExecutingJobs() { + if (threadPoolExecutor == null || threadPoolExecutor.isShutdown()) { + final BlockingQueue threadPoolQueue = new ArrayBlockingQueue(queueSize); + final AtomicInteger threadNumber = new AtomicInteger(1); + threadPoolExecutor = new ThreadPoolExecutor(corePoolSize, maxPoolSize, 0L, TimeUnit.MILLISECONDS, threadPoolQueue, + r -> { + var name = "operaton-worker-" + threadNumber.incrementAndGet(); + Thread t = new Thread(r, name); + if (t.isDaemon()) + t.setDaemon(false); + if (t.getPriority() != Thread.NORM_PRIORITY) + t.setPriority(Thread.NORM_PRIORITY); + return t; + }); + threadPoolExecutor.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy()); + } + + super.startExecutingJobs(); + } + + @Override + public synchronized void registerProcessEngine(ProcessEngineImpl processEngine) { + if (engineConfig.parallelInitialization().enabled()) { + processEngines.add(processEngine); + // JobExecutor is started in ParallelInitializationService + } else { + super.registerProcessEngine(processEngine); + } + } +} diff --git a/experimental/operaton-engine-bpmn/src/main/java/io/koraframework/bpmn/operaton/engine/KoraVirtualThreadJobExecutor.java b/experimental/operaton-engine-bpmn/src/main/java/io/koraframework/bpmn/operaton/engine/KoraVirtualThreadJobExecutor.java new file mode 100644 index 000000000..46b05f97f --- /dev/null +++ b/experimental/operaton-engine-bpmn/src/main/java/io/koraframework/bpmn/operaton/engine/KoraVirtualThreadJobExecutor.java @@ -0,0 +1,47 @@ +package io.koraframework.bpmn.operaton.engine; + +import org.operaton.bpm.engine.impl.ProcessEngineImpl; +import org.operaton.bpm.engine.impl.jobexecutor.JobExecutor; + +import java.util.List; +import java.util.concurrent.Executor; +import java.util.concurrent.RejectedExecutionException; + +public final class KoraVirtualThreadJobExecutor extends JobExecutor { + + private final Executor executor; + private final OperatonEngineBpmnConfig engineConfig; + + public KoraVirtualThreadJobExecutor(OperatonEngineBpmnConfig engineConfig) { + var factory = Thread.ofVirtual().name("operaton-job-executor-", 1).factory(); + this.executor = r -> factory.newThread(r).start(); + this.engineConfig = engineConfig; + } + + protected void startExecutingJobs() { + startJobAcquisitionThread(); + } + + protected void stopExecutingJobs() { + stopJobAcquisitionThread(); + } + + public void executeJobs(List jobIds, ProcessEngineImpl processEngine) { + try { + executor.execute(getExecuteJobsRunnable(jobIds, processEngine)); + } catch (RejectedExecutionException e) { + logRejectedExecution(processEngine, jobIds.size()); + rejectedJobsHandler.jobsRejected(jobIds, processEngine, this); + } + } + + @Override + public synchronized void registerProcessEngine(ProcessEngineImpl processEngine) { + if (engineConfig.parallelInitialization().enabled()) { + processEngines.add(processEngine); + // JobExecutor is started in ParallelInitializationService + } else { + super.registerProcessEngine(processEngine); + } + } +} diff --git a/experimental/operaton-engine-bpmn/src/main/java/io/koraframework/bpmn/operaton/engine/OperatonEngine.java b/experimental/operaton-engine-bpmn/src/main/java/io/koraframework/bpmn/operaton/engine/OperatonEngine.java new file mode 100644 index 000000000..b15daf426 --- /dev/null +++ b/experimental/operaton-engine-bpmn/src/main/java/io/koraframework/bpmn/operaton/engine/OperatonEngine.java @@ -0,0 +1,6 @@ +package io.koraframework.bpmn.operaton.engine; + +public final class OperatonEngine { + + private OperatonEngine() {} +} diff --git a/experimental/operaton-engine-bpmn/src/main/java/io/koraframework/bpmn/operaton/engine/OperatonEngineBpmnConfig.java b/experimental/operaton-engine-bpmn/src/main/java/io/koraframework/bpmn/operaton/engine/OperatonEngineBpmnConfig.java new file mode 100644 index 000000000..8338458fe --- /dev/null +++ b/experimental/operaton-engine-bpmn/src/main/java/io/koraframework/bpmn/operaton/engine/OperatonEngineBpmnConfig.java @@ -0,0 +1,103 @@ +package io.koraframework.bpmn.operaton.engine; + +import io.koraframework.bpmn.operaton.engine.telemetry.OperatonEngineTelemetryConfig; +import io.koraframework.config.common.annotation.ConfigValueExtractor; +import org.jspecify.annotations.Nullable; + +import java.time.Duration; +import java.util.List; + +@ConfigValueExtractor +public interface OperatonEngineBpmnConfig { + + ParallelInitConfig parallelInitialization(); + + JobExecutorConfig jobExecutor(); + + @Nullable + DeploymentConfig deployment(); + + @Nullable + AdminConfig admin(); + + OperatonEngineTelemetryConfig telemetry(); + + @ConfigValueExtractor + interface ParallelInitConfig { + + default boolean enabled() { + return true; + } + + default boolean validateIncompleteStatements() { + return true; + } + } + + @ConfigValueExtractor + interface AdminConfig { + + String id(); + + String password(); + + @Nullable + String firstname(); + + @Nullable + String lastname(); + + @Nullable + String email(); + } + + @ConfigValueExtractor + interface FilterConfig { + + String create(); + } + + @ConfigValueExtractor + interface DeploymentConfig { + + @Nullable + String tenantId(); + + default String name() { + return "KoraEngineAutoDeployment"; + } + + default boolean deployChangedOnly() { + return true; + } + + List resources(); + + @Nullable + Duration delay(); + } + + @ConfigValueExtractor + interface JobExecutorConfig { + + default Integer corePoolSize() { + return 5; + } + + default Integer maxPoolSize() { + return 25; + } + + default Integer queueSize() { + return 25; + } + + default Integer maxJobsPerAcquisition() { + return Runtime.getRuntime().availableProcessors() * 2; + } + + default boolean virtualThreadsEnabled() { + return false; + } + } +} diff --git a/experimental/operaton-engine-bpmn/src/main/java/io/koraframework/bpmn/operaton/engine/OperatonEngineBpmnModule.java b/experimental/operaton-engine-bpmn/src/main/java/io/koraframework/bpmn/operaton/engine/OperatonEngineBpmnModule.java new file mode 100644 index 000000000..21f25c4ec --- /dev/null +++ b/experimental/operaton-engine-bpmn/src/main/java/io/koraframework/bpmn/operaton/engine/OperatonEngineBpmnModule.java @@ -0,0 +1,225 @@ +package io.koraframework.bpmn.operaton.engine; + +import io.koraframework.application.graph.All; +import io.koraframework.application.graph.Lifecycle; +import io.koraframework.common.DefaultComponent; +import io.koraframework.common.Tag; +import io.koraframework.common.annotation.Root; +import io.koraframework.common.readiness.ReadinessProbe; +import io.koraframework.common.telemetry.Observation; +import io.koraframework.config.common.Config; +import io.koraframework.config.common.extractor.ConfigValueExtractor; +import io.koraframework.logging.common.MDC; +import io.koraframework.bpmn.operaton.engine.configurator.AdminUserProcessEngineConfigurator; +import io.koraframework.bpmn.operaton.engine.configurator.DeploymentProcessEngineConfigurator; +import io.koraframework.bpmn.operaton.engine.configurator.ProcessEngineConfigurator; +import io.koraframework.bpmn.operaton.engine.configurator.SecondStageKoraProcessEngineConfigurator; +import io.koraframework.bpmn.operaton.engine.telemetry.OperatonEngineTelemetryFactory; +import io.koraframework.bpmn.operaton.engine.telemetry.impl.DefaultOperatonEngineLoggerFactory; +import io.koraframework.bpmn.operaton.engine.telemetry.impl.DefaultOperatonEngineMetricsFactory; +import io.koraframework.bpmn.operaton.engine.telemetry.impl.DefaultOperatonEngineTelemetryFactory; +import io.koraframework.bpmn.operaton.engine.transaction.OperatonTransactionManager; +import io.koraframework.bpmn.operaton.engine.transaction.JdbcOperatonTransactionManager; +import io.micrometer.core.instrument.MeterRegistry; +import io.opentelemetry.api.trace.Tracer; +import jakarta.el.ELResolver; +import org.jspecify.annotations.Nullable; +import org.operaton.bpm.engine.*; +import org.operaton.bpm.engine.delegate.JavaDelegate; +import org.operaton.bpm.engine.impl.cfg.IdGenerator; +import org.operaton.bpm.engine.impl.cfg.ProcessEnginePlugin; +import org.operaton.bpm.engine.impl.el.JuelExpressionManager; +import org.operaton.bpm.engine.impl.jobexecutor.JobExecutor; +import org.operaton.bpm.engine.impl.persistence.StrongUuidGenerator; + +import javax.sql.DataSource; + +public interface OperatonEngineBpmnModule { + + default OperatonEngineBpmnConfig operatonEngineBpmnConfig(Config config, ConfigValueExtractor extractor) { + return extractor.extract(config.get("operaton.engine.bpmn")); + } + + @Tag(OperatonEngine.class) + @DefaultComponent + default DataSource operatonDataSource(DataSource dataSource) { + return dataSource; + } + + @DefaultComponent + default OperatonEngineDataSource operatonKoraDataSource(@Tag(OperatonEngine.class) DataSource dataSource) { + return new OperatonEngineDataSource() { + @Override + public OperatonTransactionManager transactionManager() { + return new JdbcOperatonTransactionManager(dataSource); + } + + @Override + public DataSource dataSource() { + return dataSource; + } + }; + } + + @DefaultComponent + default IdGenerator operatonEngineBpmnIdGenerator() { + return new StrongUuidGenerator(); + } + + @DefaultComponent + default JobExecutor operatonEngineBpmnKoraJobExecutor(OperatonEngineBpmnConfig engineConfig) { + if (engineConfig.jobExecutor().virtualThreadsEnabled()) { + return new KoraVirtualThreadJobExecutor(engineConfig); + } else { + return new KoraThreadPoolJobExecutor(engineConfig); + } + } + + default ReadinessProbe operatonEngineBpmnReadinessProbe(JobExecutor jobExecutor) { + return new JobExecutorReadinessProbe(jobExecutor); + } + + + @DefaultComponent + default OperatonEngineTelemetryFactory operatonEngineBpmnTelemetryFactory(@Nullable Tracer tracer, + @Nullable MeterRegistry meterRegistry, + @Nullable DefaultOperatonEngineLoggerFactory loggerFactory, + @Nullable DefaultOperatonEngineMetricsFactory metricsFactory) { + return new DefaultOperatonEngineTelemetryFactory(tracer, meterRegistry, loggerFactory, metricsFactory); + } + + @DefaultComponent + default KoraDelegateWrapperFactory koraJavaDelegateTelemetryWrapper(OperatonEngineTelemetryFactory telemetryFactory, + OperatonEngineBpmnConfig operatonEngineBpmnConfig) { + return delegate -> { + var telemetry = telemetryFactory.get(operatonEngineBpmnConfig.telemetry()); + return execution -> { + var observation = telemetry.observe(delegate.getClass().getCanonicalName()); + Observation.scoped(observation) + .where(MDC.VALUE, new MDC()) + .call(() -> { + try { + observation.observeExecution(execution); + delegate.execute(execution); + return null; + } catch (Throwable e) { + observation.observeError(e); + throw e; + } + }); + }; + }; + } + + @DefaultComponent + default ELResolver operatonEngineBpmnKoraELResolver(KoraDelegateWrapperFactory wrapperFactory, + All koraDelegates, + All javaDelegates) { + return new KoraELResolver(wrapperFactory, koraDelegates, javaDelegates); + } + + @DefaultComponent + default JuelExpressionManager operatonEngineBpmnKoraExpressionManager(ELResolver koraELResolver) { + return new KoraExpressionManager(koraELResolver); + } + + @DefaultComponent + default ArtifactFactory operatonEngineBpmnKoraArtifactFactory(KoraDelegateWrapperFactory wrapperFactory, + All koraDelegates, + All javaDelegates) { + return new KoraArtifactFactory(wrapperFactory, koraDelegates, javaDelegates); + } + + @DefaultComponent + default KoraResolverFactory operatonEngineBpmnKoraComponentResolverFactory(KoraDelegateWrapperFactory wrapperFactory, + All koraDelegates, + All javaDelegates) { + return new KoraResolverFactory(wrapperFactory, koraDelegates, javaDelegates); + } + + @DefaultComponent + default ProcessEngineConfiguration operatonEngineBpmnKoraProcessEngineConfiguration(JobExecutor jobExecutor, + IdGenerator idGenerator, + JuelExpressionManager koraExpressionManager, + ArtifactFactory artifactFactory, + All plugins, + OperatonEngineDataSource engineDataSource, + OperatonEngineBpmnConfig operatonEngineBpmnConfig, + KoraResolverFactory componentResolverFactory) { + return new KoraProcessEngineConfiguration(jobExecutor, idGenerator, koraExpressionManager, artifactFactory, plugins, engineDataSource, operatonEngineBpmnConfig, componentResolverFactory); + } + + @Root + @DefaultComponent + default KoraProcessEngine operatonEngineBpmnKoraProcessEngine(ProcessEngineConfiguration processEngineConfiguration, + OperatonEngineBpmnConfig operatonEngineBpmnConfig, + All engineConfigurators) { + return new KoraProcessEngine(processEngineConfiguration, operatonEngineBpmnConfig, engineConfigurators); + } + + default ProcessEngineConfigurator operatonEngineBpmnKoraAdminUserConfigurator(OperatonEngineBpmnConfig operatonEngineBpmnConfig, OperatonEngineDataSource engineDataSource) { + return new AdminUserProcessEngineConfigurator(operatonEngineBpmnConfig, engineDataSource); + } + + default ProcessEngineConfigurator operatonEngineBpmnKoraResourceDeploymentConfigurator(OperatonEngineBpmnConfig operatonEngineBpmnConfig) { + return new DeploymentProcessEngineConfigurator(operatonEngineBpmnConfig); + } + + default ProcessEngineConfigurator operatonEngineBpmnKoraProcessEngineTwoStageOperatonConfigurator(ProcessEngineConfiguration engineConfiguration, + OperatonEngineBpmnConfig operatonEngineBpmnConfig, + JobExecutor jobExecutor) { + return new SecondStageKoraProcessEngineConfigurator(engineConfiguration, operatonEngineBpmnConfig, jobExecutor); + } + + @Root + default Lifecycle operatonKoraProcessEngineParallelInitializer(ProcessEngine processEngine, + OperatonEngineBpmnConfig operatonEngineBpmnConfig, + ProcessEngineConfiguration processEngineConfiguration, + All engineConfigurators) { + return new KoraProcessEngineParallelInitializer(processEngine, operatonEngineBpmnConfig, processEngineConfiguration, engineConfigurators); + } + + default RuntimeService operatonEngineBpmnRuntimeService(ProcessEngine processEngine) { + return processEngine.getRuntimeService(); + } + + default RepositoryService operatonEngineBpmnRepositoryService(ProcessEngine processEngine) { + return processEngine.getRepositoryService(); + } + + default ManagementService operatonEngineBpmnManagementService(ProcessEngine processEngine) { + return processEngine.getManagementService(); + } + + default AuthorizationService operatonEngineBpmnAuthorizationService(ProcessEngine processEngine) { + return processEngine.getAuthorizationService(); + } + + default DecisionService operatonEngineBpmnDecisionService(ProcessEngine processEngine) { + return processEngine.getDecisionService(); + } + + default ExternalTaskService operatonEngineBpmnExternalTaskService(ProcessEngine processEngine) { + return processEngine.getExternalTaskService(); + } + + default FilterService operatonEngineBpmnFilterService(ProcessEngine processEngine) { + return processEngine.getFilterService(); + } + + default FormService operatonEngineBpmnFormService(ProcessEngine processEngine) { + return processEngine.getFormService(); + } + + default TaskService operatonEngineBpmnTaskService(ProcessEngine processEngine) { + return processEngine.getTaskService(); + } + + default HistoryService operatonEngineBpmnHistoryService(ProcessEngine processEngine) { + return processEngine.getHistoryService(); + } + + default IdentityService operatonEngineBpmnIdentityService(ProcessEngine processEngine) { + return processEngine.getIdentityService(); + } +} diff --git a/experimental/operaton-engine-bpmn/src/main/java/io/koraframework/bpmn/operaton/engine/OperatonEngineDataSource.java b/experimental/operaton-engine-bpmn/src/main/java/io/koraframework/bpmn/operaton/engine/OperatonEngineDataSource.java new file mode 100644 index 000000000..c88953a74 --- /dev/null +++ b/experimental/operaton-engine-bpmn/src/main/java/io/koraframework/bpmn/operaton/engine/OperatonEngineDataSource.java @@ -0,0 +1,12 @@ +package io.koraframework.bpmn.operaton.engine; + +import io.koraframework.bpmn.operaton.engine.transaction.OperatonTransactionManager; + +import javax.sql.DataSource; + +public interface OperatonEngineDataSource { + + OperatonTransactionManager transactionManager(); + + DataSource dataSource(); +} diff --git a/experimental/operaton-engine-bpmn/src/main/java/io/koraframework/bpmn/operaton/engine/configurator/AdminUserProcessEngineConfigurator.java b/experimental/operaton-engine-bpmn/src/main/java/io/koraframework/bpmn/operaton/engine/configurator/AdminUserProcessEngineConfigurator.java new file mode 100644 index 000000000..72ddd6f96 --- /dev/null +++ b/experimental/operaton-engine-bpmn/src/main/java/io/koraframework/bpmn/operaton/engine/configurator/AdminUserProcessEngineConfigurator.java @@ -0,0 +1,99 @@ +package io.koraframework.bpmn.operaton.engine.configurator; + +import io.koraframework.bpmn.operaton.engine.OperatonEngineBpmnConfig; +import io.koraframework.bpmn.operaton.engine.OperatonEngineDataSource; +import io.koraframework.common.util.TimeUtils; +import org.operaton.bpm.engine.AuthorizationService; +import org.operaton.bpm.engine.IdentityService; +import org.operaton.bpm.engine.ProcessEngine; +import org.operaton.bpm.engine.authorization.Resource; +import org.operaton.bpm.engine.authorization.Resources; +import org.operaton.bpm.engine.identity.Group; +import org.operaton.bpm.engine.identity.User; +import org.operaton.bpm.engine.impl.persistence.entity.AuthorizationEntity; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import static org.operaton.bpm.engine.authorization.Authorization.ANY; +import static org.operaton.bpm.engine.authorization.Authorization.AUTH_TYPE_GRANT; +import static org.operaton.bpm.engine.authorization.Groups.GROUP_TYPE_SYSTEM; +import static org.operaton.bpm.engine.authorization.Groups.OPERATON_ADMIN; +import static org.operaton.bpm.engine.authorization.Permissions.ALL; + +public final class AdminUserProcessEngineConfigurator implements ProcessEngineConfigurator { + + private static final Logger logger = LoggerFactory.getLogger(AdminUserProcessEngineConfigurator.class); + + private final OperatonEngineBpmnConfig.AdminConfig adminConfig; + private final OperatonEngineDataSource engineDataSource; + + public AdminUserProcessEngineConfigurator(OperatonEngineBpmnConfig engineConfig, + OperatonEngineDataSource engineDataSource) { + this.adminConfig = engineConfig.admin(); + this.engineDataSource = engineDataSource; + } + + @Override + public void setup(ProcessEngine engine) { + if (adminConfig != null) { + logger.debug("Operaton Configurator Admin user creating..."); + final long started = TimeUtils.started(); + + IdentityService identityService = engine.getIdentityService(); + AuthorizationService authorizationService = engine.getAuthorizationService(); + engineDataSource.transactionManager().inNewTx(() -> { + if (!userAlreadyExists(identityService, adminConfig.id())) { + createUser(identityService); + + if (!adminGroupAlreadyExists(identityService)) { + createAdminGroup(identityService); + } + + createAdminGroupAuthorizations(authorizationService); + identityService.createMembership(adminConfig.id(), OPERATON_ADMIN); + logger.info("Operaton Configurator Admin user created in {}", TimeUtils.tookForLogging(started)); + } else { + logger.debug("Operaton Configurator Admin user already exist"); + } + }); + } + } + + private boolean userAlreadyExists(IdentityService identityService, String userId) { + return identityService.createUserQuery().userId(userId).singleResult() != null; + } + + private boolean adminGroupAlreadyExists(IdentityService identityService) { + return identityService.createGroupQuery().groupId(OPERATON_ADMIN).count() > 0; + } + + private void createUser(IdentityService identityService) { + User newUser = identityService.newUser(adminConfig.id()); + newUser.setPassword(adminConfig.password()); + newUser.setFirstName(adminConfig.firstname() == null ? adminConfig.id().toUpperCase() : adminConfig.firstname()); + newUser.setLastName(adminConfig.lastname() == null ? adminConfig.id().toUpperCase() : adminConfig.lastname()); + newUser.setEmail(adminConfig.email() == null ? adminConfig.id() + "@localhost" : adminConfig.email()); + + identityService.saveUser(newUser); + } + + private void createAdminGroup(IdentityService identityService) { + Group adminGroup = identityService.newGroup(OPERATON_ADMIN); + adminGroup.setName("Operaton Administrators"); + adminGroup.setType(GROUP_TYPE_SYSTEM); + identityService.saveGroup(adminGroup); + } + + private void createAdminGroupAuthorizations(AuthorizationService authorizationService) { + for (Resource resource : Resources.values()) { + if (authorizationService.createAuthorizationQuery().groupIdIn(OPERATON_ADMIN).resourceType(resource).resourceId(ANY).count() == 0) { + AuthorizationEntity groupAuth = new AuthorizationEntity(AUTH_TYPE_GRANT); + groupAuth.setGroupId(OPERATON_ADMIN); + groupAuth.setResource(resource); + groupAuth.setResourceId(ANY); + groupAuth.addPermission(ALL); + authorizationService.saveAuthorization(groupAuth); + } + } + } +} diff --git a/experimental/operaton-engine-bpmn/src/main/java/io/koraframework/bpmn/operaton/engine/configurator/DeploymentProcessEngineConfigurator.java b/experimental/operaton-engine-bpmn/src/main/java/io/koraframework/bpmn/operaton/engine/configurator/DeploymentProcessEngineConfigurator.java new file mode 100644 index 000000000..7123ce32b --- /dev/null +++ b/experimental/operaton-engine-bpmn/src/main/java/io/koraframework/bpmn/operaton/engine/configurator/DeploymentProcessEngineConfigurator.java @@ -0,0 +1,361 @@ +package io.koraframework.bpmn.operaton.engine.configurator; + +import org.operaton.bpm.engine.ProcessEngine; +import org.operaton.bpm.engine.RepositoryService; +import org.operaton.bpm.engine.repository.Deployment; +import org.operaton.bpm.engine.repository.DeploymentBuilder; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import io.koraframework.bpmn.operaton.engine.OperatonEngineBpmnConfig; +import io.koraframework.common.util.TimeUtils; + +import java.io.*; +import java.net.URL; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.*; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Supplier; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +public final class DeploymentProcessEngineConfigurator implements ProcessEngineConfigurator { + + private static final Logger logger = LoggerFactory.getLogger(DeploymentProcessEngineConfigurator.class); + + private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1); + private final OperatonEngineBpmnConfig engineConfig; + + public DeploymentProcessEngineConfigurator(OperatonEngineBpmnConfig engineConfig) { + this.engineConfig = engineConfig; + } + + @Override + public void setup(ProcessEngine engine) { + try { + var deployment = engineConfig.deployment(); + if (deployment != null && !deployment.resources().isEmpty()) { + final Set normalizedLocations = deployment.resources().stream() + .map(location -> { + if (location.startsWith("classpath:")) { + return location.substring(10); + } else { + logger.warn("Only locations with `classpath:` prefix are supported, skipping unsupported resource location: {}", location); + return null; + } + }) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + + if (deployment.delay() == null) { + deployProcessModels(normalizedLocations, deployment, engine.getRepositoryService()); + } else { + this.scheduler.schedule(() -> { + try { + deployProcessModels(normalizedLocations, deployment, engine.getRepositoryService()); + } catch (IOException e) { + logger.error("Operaton Configurator deploying {} resources failed", normalizedLocations, e); + } + }, + deployment.delay().toMillis(), TimeUnit.MILLISECONDS); + } + } + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + private void deployProcessModels(Set normalizedLocations, OperatonEngineBpmnConfig.DeploymentConfig deploymentConfig, RepositoryService repositoryService) throws IOException { + logger.debug("Operaton Configurator deploying {} resources...", normalizedLocations); + final long started = TimeUtils.started(); + + final List resources = ClasspathResourceUtils.findResources(normalizedLocations); + if (resources.isEmpty()) { + logger.debug("Operaton Configurator found 0 resources"); + } else { + DeploymentBuilder builder = repositoryService.createDeployment() + .name(deploymentConfig.name()) + .source(deploymentConfig.name()) + .enableDuplicateFiltering(deploymentConfig.deployChangedOnly()); + + if (deploymentConfig.tenantId() != null) { + builder = builder.tenantId(deploymentConfig.tenantId()); + } + + for (var resource : resources) { + try (var res = resource) { + builder.addInputStream(res.name(), res.asInputStream()); + } + } + + Deployment deployment = builder.deploy(); + logger.info("Operaton Configurator deployed {} resources with deployment id '{}' in {}", + resources, deployment.getId(), TimeUtils.tookForLogging(started)); + } + } + + interface Resource extends Closeable { + + String name(); + + String path(); + + InputStream asInputStream(); + } + + record JarResource(String name, String path, Supplier inputStream, @Nullable Closeable closeable) implements Resource { + + public JarResource(String name, String path, Supplier inputStream) { + this(name, path, inputStream, null); + } + + @Override + public InputStream asInputStream() { + return inputStream.get(); + } + + @Override + public void close() throws IOException { + if (closeable != null) { + closeable.close(); + } + } + + @Override + public boolean equals(Object object) { + if (this == object) return true; + if (!(object instanceof Resource that)) return false; + return Objects.equals(name, that.name()) && Objects.equals(path, that.path()); + } + + @Override + public int hashCode() { + return Objects.hash(name, path); + } + + @Override + public String toString() { + return name; + } + } + + record FileResource(String name, String path) implements Resource { + + @Override + public InputStream asInputStream() { + return FileResource.class.getResourceAsStream("/" + path + "/" + name); + } + + @Override + public void close() { + + } + + @Override + public boolean equals(Object object) { + if (this == object) return true; + if (!(object instanceof Resource that)) return false; + return Objects.equals(name, that.name()) && Objects.equals(path, that.path()); + } + + @Override + public int hashCode() { + return Objects.hash(name, path); + } + + @Override + public String toString() { + return name; + } + } + + static final class ClasspathResourceUtils { + + private ClasspathResourceUtils() {} + + public static Optional findResource(String path) { + return findResources(path).stream().findFirst(); + } + + public static List findResources(Collection paths) { + return paths.stream() + .distinct() + .flatMap(p -> findResources(p).stream()) + .distinct() + .toList(); + } + + /** + * Scans for all resources under specified path and its subdirectories + * + * @param path to start scan from + * @return list of resources under target package or path + */ + public static List findResources(String path) { + if (path == null || path.isBlank()) { + return Collections.emptyList(); + } + if (path.startsWith("./")) { + return findResources(path.substring(2)); + } + if (path.startsWith("/")) { + return findResources(path.substring(1)); + } + + final Pattern pattern; + final List pathResources; + final String workPath; + + int innerDirectoryFrom = path.lastIndexOf('/'); + if (innerDirectoryFrom == -1) { + var directoryOnlyResources = getSystemResources(path); + if (!directoryOnlyResources.isEmpty()) { + workPath = path; + pathResources = directoryOnlyResources; + pattern = null; + } else { + workPath = "."; + pathResources = getSystemResources(workPath); + pattern = Pattern.compile("^" + path); + } + } else { + workPath = path.substring(0, innerDirectoryFrom); + pathResources = getSystemResources(workPath); + pattern = Pattern.compile("^" + path.substring(innerDirectoryFrom + 1)); + } + + return pathResources.stream() + .map(r -> { + if (r.toString().startsWith("jar")) { + return loadFromJar(workPath, r, pattern); + } else { + return loadFromDirectory(workPath, r, pattern); + } + }) + .flatMap(Collection::stream) + .toList(); + } + + /** + * Given a package name and a directory returns all classes within that directory + * + * @param path to process + * @return Classes within Directory with package name + */ + private static List loadFromDirectory(String path, URL resource, @Nullable Pattern pattern) { + final File filePath = new File(resource.getPath()); + if (filePath.isFile()) { + if (pattern == null) { + // mean that path is root self file + return List.of(new FileResource(filePath.getName(), ".")); + } else if (pattern.matcher(filePath.getName()).matches()) { + return List.of(new FileResource(filePath.getName(), path)); + } else { + return List.of(); + } + } else { + final List resources = new ArrayList<>(); + final String[] files = filePath.list(); + if (files == null || files.length == 0) { + return Collections.emptyList(); + } + + for (String fileName : files) { + final File file = new File(filePath, fileName); + if (file.isFile()) { + if (pattern == null) { + resources.add(new FileResource(file.getName(), path)); + } else if (pattern.matcher(fileName).matches()) { + resources.add(new FileResource(file.getName(), path)); + } + } + } + return resources; + } + } + + /** + * Given a jar file's URL and a package name returns all classes within jar file. + * + * @param resource as jar to process + */ + private static List loadFromJar(String path, URL resource, @Nullable Pattern pattern) { + final String jarPath = resource.getPath() + .replaceFirst("[.]jar!.*", ".jar") + .replaceFirst("[.]tar!.*", ".tar") + .replaceFirst("file:", ""); + + try { + final String jarUrlPath = URLDecoder.decode(jarPath, StandardCharsets.UTF_8); + logger.info("JarPath {}, path {}, pattern {}", jarPath, path, pattern); + final JarFile jar = new JarFile(jarUrlPath); + final List classes = new ArrayList<>(); + final Enumeration files = jar.entries(); + final AtomicInteger closeCounter = new AtomicInteger(0); + while (files.hasMoreElements()) { + final JarEntry file = files.nextElement(); + if (!file.isDirectory()) { + final String fileFullName = file.getName(); + final String fileName = (fileFullName.lastIndexOf('/') == -1) + ? fileFullName + : fileFullName.substring(fileFullName.lastIndexOf('/') + 1); + logger.info("File {}, path {}, pattern {}", fileFullName, path, pattern); + if ((pattern == null && fileFullName.startsWith(path)) + || (pattern != null && pattern.matcher(fileName).matches())) { + + logger.info("Match pattern {}, fileName {}", pattern, fileName); + closeCounter.incrementAndGet(); + classes.add(new JarResource(fileFullName, jarUrlPath, () -> { + try { + return jar.getInputStream(file); + } catch (Exception e) { + throw new IllegalStateException(e.getMessage() + " for file: " + jarPath, e); + } + }, () -> { + int res = closeCounter.decrementAndGet(); + if (res < 1) { + jar.close(); + } + })); + } + } + } + + return classes; + } catch (Exception e) { + throw new IllegalArgumentException("Can't open Jar '" + jarPath, e); + } + } + + /** + * Loads system resources as URLs + * + * @param path to load resources from + * @return resources urls + */ + private static List getSystemResources(String path) { + try { + final Enumeration resourceUrls = ClasspathResourceUtils.class.getClassLoader().getResources(path); + if (!resourceUrls.hasMoreElements()) { + return Collections.emptyList(); + } + + final List resources = new ArrayList<>(); + while (resourceUrls.hasMoreElements()) { + URL url = resourceUrls.nextElement(); + resources.add(url); + } + + return resources; + } catch (IOException e) { + throw new IllegalArgumentException(e); + } + } + } +} diff --git a/experimental/operaton-engine-bpmn/src/main/java/io/koraframework/bpmn/operaton/engine/configurator/ProcessEngineConfigurator.java b/experimental/operaton-engine-bpmn/src/main/java/io/koraframework/bpmn/operaton/engine/configurator/ProcessEngineConfigurator.java new file mode 100644 index 000000000..baa32cc4d --- /dev/null +++ b/experimental/operaton-engine-bpmn/src/main/java/io/koraframework/bpmn/operaton/engine/configurator/ProcessEngineConfigurator.java @@ -0,0 +1,16 @@ + +package io.koraframework.bpmn.operaton.engine.configurator; + +import org.operaton.bpm.engine.ProcessEngine; +import org.operaton.bpm.engine.ProcessEngineConfiguration; + +public interface ProcessEngineConfigurator { + + default void prepare(ProcessEngineConfiguration configuration) { + + } + + default void setup(ProcessEngine engine) throws Exception { + + } +} diff --git a/experimental/operaton-engine-bpmn/src/main/java/io/koraframework/bpmn/operaton/engine/configurator/SecondStageKoraProcessEngineConfigurator.java b/experimental/operaton-engine-bpmn/src/main/java/io/koraframework/bpmn/operaton/engine/configurator/SecondStageKoraProcessEngineConfigurator.java new file mode 100644 index 000000000..0a9515672 --- /dev/null +++ b/experimental/operaton-engine-bpmn/src/main/java/io/koraframework/bpmn/operaton/engine/configurator/SecondStageKoraProcessEngineConfigurator.java @@ -0,0 +1,69 @@ +package io.koraframework.bpmn.operaton.engine.configurator; + +import io.koraframework.bpmn.operaton.engine.KoraProcessEngine; +import io.koraframework.bpmn.operaton.engine.KoraProcessEngineConfiguration; +import io.koraframework.bpmn.operaton.engine.OperatonEngineBpmnConfig; +import io.koraframework.common.util.TimeUtils; +import org.apache.ibatis.mapping.MappedStatement; +import org.apache.ibatis.session.Configuration; +import org.operaton.bpm.engine.ProcessEngine; +import org.operaton.bpm.engine.ProcessEngineConfiguration; +import org.operaton.bpm.engine.impl.jobexecutor.JobExecutor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +public final class SecondStageKoraProcessEngineConfigurator implements ProcessEngineConfigurator { + + private static final Logger logger = LoggerFactory.getLogger(KoraProcessEngine.class); + + private final ProcessEngineConfiguration engineConfiguration; + private final OperatonEngineBpmnConfig engineConfig; + private final JobExecutor jobExecutor; + + public SecondStageKoraProcessEngineConfigurator(ProcessEngineConfiguration engineConfiguration, + OperatonEngineBpmnConfig engineConfig, + JobExecutor jobExecutor) { + this.engineConfiguration = engineConfiguration; + this.engineConfig = engineConfig; + this.jobExecutor = jobExecutor; + } + + @Override + public void setup(ProcessEngine engine) { + if (engineConfig.parallelInitialization().enabled() && engineConfiguration instanceof KoraProcessEngineConfiguration kEngine) { + logger.debug("Operaton BPMN Engine starting second stage..."); + final long started = TimeUtils.started(); + + Configuration src = kEngine.createConfigurationStageTwo(); + Configuration dest = kEngine.getSqlSessionFactory().getConfiguration(); + + final AtomicInteger statements = new AtomicInteger(0); + List secondPhaseStatements = new ArrayList<>(); + src.getMappedStatements().forEach(ms -> { + if (!dest.hasStatement(ms.getId(), engineConfig.parallelInitialization().validateIncompleteStatements())) { + dest.addMappedStatement(ms); + secondPhaseStatements.add(ms); + statements.incrementAndGet(); + } + }); + + if (logger.isDebugEnabled()) { + List resources = secondPhaseStatements.stream() + .map(MappedStatement::getResource) + .toList(); + logger.debug("Operaton BPMN Engine second stage statements mapped: {}", resources); + } + + if (jobExecutor.isAutoActivate()) { + jobExecutor.start(); + } + + logger.info("Operaton BPMN Engine started second stage with {} new and total {} mapped statements in {}", + statements.get(), dest.getMappedStatements().size(), TimeUtils.tookForLogging(started)); + } + } +} diff --git a/experimental/operaton-engine-bpmn/src/main/java/io/koraframework/bpmn/operaton/engine/telemetry/OperatonEngineObservation.java b/experimental/operaton-engine-bpmn/src/main/java/io/koraframework/bpmn/operaton/engine/telemetry/OperatonEngineObservation.java new file mode 100644 index 000000000..42ebf23f0 --- /dev/null +++ b/experimental/operaton-engine-bpmn/src/main/java/io/koraframework/bpmn/operaton/engine/telemetry/OperatonEngineObservation.java @@ -0,0 +1,9 @@ +package io.koraframework.bpmn.operaton.engine.telemetry; + +import io.koraframework.common.telemetry.Observation; +import org.operaton.bpm.engine.delegate.DelegateExecution; + +public interface OperatonEngineObservation extends Observation { + + void observeExecution(DelegateExecution execution); +} diff --git a/experimental/operaton-engine-bpmn/src/main/java/io/koraframework/bpmn/operaton/engine/telemetry/OperatonEngineTelemetry.java b/experimental/operaton-engine-bpmn/src/main/java/io/koraframework/bpmn/operaton/engine/telemetry/OperatonEngineTelemetry.java new file mode 100644 index 000000000..1d967cbd3 --- /dev/null +++ b/experimental/operaton-engine-bpmn/src/main/java/io/koraframework/bpmn/operaton/engine/telemetry/OperatonEngineTelemetry.java @@ -0,0 +1,6 @@ +package io.koraframework.bpmn.operaton.engine.telemetry; + +public interface OperatonEngineTelemetry { + + OperatonEngineObservation observe(String canonicalName); +} diff --git a/experimental/operaton-engine-bpmn/src/main/java/io/koraframework/bpmn/operaton/engine/telemetry/OperatonEngineTelemetryConfig.java b/experimental/operaton-engine-bpmn/src/main/java/io/koraframework/bpmn/operaton/engine/telemetry/OperatonEngineTelemetryConfig.java new file mode 100644 index 000000000..55f398278 --- /dev/null +++ b/experimental/operaton-engine-bpmn/src/main/java/io/koraframework/bpmn/operaton/engine/telemetry/OperatonEngineTelemetryConfig.java @@ -0,0 +1,36 @@ +package io.koraframework.bpmn.operaton.engine.telemetry; + +import io.koraframework.config.common.annotation.ConfigValueExtractor; +import io.koraframework.telemetry.common.TelemetryConfig; + +@ConfigValueExtractor +public interface OperatonEngineTelemetryConfig extends TelemetryConfig { + + @Override + CamundaEngineLoggingConfig logging(); + + @Override + CamundaEngineMetricsConfig metrics(); + + @Override + CamundaEngineTracingConfig tracing(); + + @ConfigValueExtractor + interface CamundaEngineLoggingConfig extends LoggingConfig { + + default boolean stacktrace() { + return true; + } + } + + @ConfigValueExtractor + interface CamundaEngineMetricsConfig extends MetricsConfig { + + default boolean engineMetrics() { + return false; + } + } + + @ConfigValueExtractor + interface CamundaEngineTracingConfig extends TracingConfig {} +} diff --git a/experimental/operaton-engine-bpmn/src/main/java/io/koraframework/bpmn/operaton/engine/telemetry/OperatonEngineTelemetryFactory.java b/experimental/operaton-engine-bpmn/src/main/java/io/koraframework/bpmn/operaton/engine/telemetry/OperatonEngineTelemetryFactory.java new file mode 100644 index 000000000..dcdccf016 --- /dev/null +++ b/experimental/operaton-engine-bpmn/src/main/java/io/koraframework/bpmn/operaton/engine/telemetry/OperatonEngineTelemetryFactory.java @@ -0,0 +1,6 @@ +package io.koraframework.bpmn.operaton.engine.telemetry; + +public interface OperatonEngineTelemetryFactory { + + OperatonEngineTelemetry get(OperatonEngineTelemetryConfig telemetryConfig); +} diff --git a/experimental/operaton-engine-bpmn/src/main/java/io/koraframework/bpmn/operaton/engine/telemetry/impl/DefaultOperatonEngineLoggerFactory.java b/experimental/operaton-engine-bpmn/src/main/java/io/koraframework/bpmn/operaton/engine/telemetry/impl/DefaultOperatonEngineLoggerFactory.java new file mode 100644 index 000000000..7c14a82f2 --- /dev/null +++ b/experimental/operaton-engine-bpmn/src/main/java/io/koraframework/bpmn/operaton/engine/telemetry/impl/DefaultOperatonEngineLoggerFactory.java @@ -0,0 +1,90 @@ +package io.koraframework.bpmn.operaton.engine.telemetry.impl; + +import io.koraframework.logging.common.arg.StructuredArgumentWriter; +import org.operaton.bpm.engine.delegate.DelegateExecution; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.slf4j.event.Level; + +public class DefaultOperatonEngineLoggerFactory { + + public static final DefaultOperatonEngineLoggerFactory INSTANCE = new DefaultOperatonEngineLoggerFactory(); + + public DefaultOperatonEngineLogger create(DefaultOperatonEngineTelemetry.TelemetryContext context, String javaDelegateName) { + return new DefaultOperatonEngineLogger(context, javaDelegateName, LoggerFactory.getLogger(javaDelegateName)); + } + + public static class DefaultOperatonEngineLogger { + + protected final DefaultOperatonEngineTelemetry.TelemetryContext context; + protected final String javaDelegateName; + protected final Logger logger; + + public DefaultOperatonEngineLogger(DefaultOperatonEngineTelemetry.TelemetryContext context, + String javaDelegateName, + Logger logger) { + this.context = context; + this.javaDelegateName = javaDelegateName; + this.logger = logger; + } + + public void logStart(DelegateExecution execution) { + if (!this.logger.isInfoEnabled()) { + return; + } + this.logger.atInfo() + .addKeyValue("operatonExecution", structuredExecution(execution, null, 0)) + .addKeyValue("delegate", this.javaDelegateName) + .log("Operaton BPMN Engine started"); + } + + public void logEnd(@Nullable DelegateExecution execution, @Nullable Throwable error, long processingTimeNanos) { + if (error == null && !this.logger.isInfoEnabled()) { + return; + } + if (error != null && !this.logger.isWarnEnabled()) { + return; + } + + this.logger.atLevel(error == null ? Level.INFO : Level.WARN) + .addKeyValue("operatonExecution", structuredExecution(execution, error, processingTimeNanos)) + .addKeyValue("delegate", this.javaDelegateName) + .setCause(this.context.config().logging().stacktrace() ? error : null) + .log(error == null + ? "Operaton BPMN Engine finished delegate execution" + : "Operaton BPMN Engine failed delegate execution" + ); + } + + protected StructuredArgumentWriter structuredExecution(@Nullable DelegateExecution execution, + @Nullable Throwable error, + long processingTimeNanos) { + return gen -> { + gen.writeStartObject(); + gen.writeStringProperty("delegate", this.javaDelegateName); + if (processingTimeNanos > 0) { + gen.writeNumberProperty("processingTime", processingTimeNanos / 1_000_000); + } + if (execution != null) { + gen.writeStringProperty("processBusinessKey", execution.getProcessBusinessKey()); + gen.writeStringProperty("processInstanceId", execution.getProcessInstanceId()); + gen.writeStringProperty("activityId", execution.getCurrentActivityId()); + gen.writeStringProperty("activityName", execution.getCurrentActivityName()); + gen.writeStringProperty("eventName", execution.getEventName()); + gen.writeStringProperty("businessKey", execution.getBusinessKey()); + } + if (error != null) { + var exceptionType = error.getClass().getCanonicalName(); + if (exceptionType != null) { + gen.writeStringProperty("exceptionType", exceptionType); + } + if (!this.context.config().logging().stacktrace()) { + gen.writeStringProperty("exceptionMessage", error.getMessage()); + } + } + gen.writeEndObject(); + }; + } + } +} diff --git a/experimental/operaton-engine-bpmn/src/main/java/io/koraframework/bpmn/operaton/engine/telemetry/impl/DefaultOperatonEngineMetricsFactory.java b/experimental/operaton-engine-bpmn/src/main/java/io/koraframework/bpmn/operaton/engine/telemetry/impl/DefaultOperatonEngineMetricsFactory.java new file mode 100644 index 000000000..a073fb54d --- /dev/null +++ b/experimental/operaton-engine-bpmn/src/main/java/io/koraframework/bpmn/operaton/engine/telemetry/impl/DefaultOperatonEngineMetricsFactory.java @@ -0,0 +1,76 @@ +package io.koraframework.bpmn.operaton.engine.telemetry.impl; + +import io.micrometer.core.instrument.Tag; +import io.micrometer.core.instrument.Tags; +import io.micrometer.core.instrument.Timer; +import io.opentelemetry.semconv.ErrorAttributes; +import org.jspecify.annotations.Nullable; + +import java.util.ArrayList; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; + +public class DefaultOperatonEngineMetricsFactory { + + public static final DefaultOperatonEngineMetricsFactory INSTANCE = new DefaultOperatonEngineMetricsFactory(); + + public DefaultOperatonEngineMetrics create(DefaultOperatonEngineTelemetry.TelemetryContext context, String javaDelegateName) { + return new DefaultOperatonEngineMetrics(context, javaDelegateName); + } + + public static class DefaultOperatonEngineMetrics { + + public record DurationKey(@Nullable Class errorType, + @Nullable Tags extraTags) { + + public DurationKey withExtraTags(Tags tags) { + return new DurationKey(errorType, tags); + } + } + + protected final ConcurrentHashMap durationCache = new ConcurrentHashMap<>(); + + protected final DefaultOperatonEngineTelemetry.TelemetryContext context; + protected final String javaDelegateName; + + public DefaultOperatonEngineMetrics(DefaultOperatonEngineTelemetry.TelemetryContext context, String javaDelegateName) { + this.context = context; + this.javaDelegateName = javaDelegateName; + } + + public void record(@Nullable Throwable throwable, long processingTimeNanos) { + var key = new DurationKey(throwable == null ? null : throwable.getClass(), null); + var meter = this.durationCache.computeIfAbsent(key, _ -> createDuration(key).register(context.meterRegistry())); + meter.record(processingTimeNanos, TimeUnit.NANOSECONDS); + } + + // DO NOT ADD DYNAMIC TAGS IN BUILDER, use metric key instead of metric collision will happen + protected Timer.Builder createDuration(DurationKey metricKey) { + var extraTags = 0; + if (metricKey.extraTags != null) { + for (Tag _ : metricKey.extraTags) { + extraTags++; + } + } + + var staticTags = new ArrayList(2 + this.context.config().metrics().tags().size() + extraTags); + var errorType = metricKey.errorType == null ? "" : metricKey.errorType.getCanonicalName(); + + staticTags.add(Tag.of("delegate", this.javaDelegateName)); + staticTags.add(Tag.of(ErrorAttributes.ERROR_TYPE.getKey(), errorType)); + + for (var entry : this.context.config().metrics().tags().entrySet()) { + staticTags.add(Tag.of(entry.getKey(), entry.getValue())); + } + if (metricKey.extraTags != null) { + for (Tag extraTag : metricKey.extraTags) { + staticTags.add(extraTag); + } + } + + return Timer.builder("operaton.engine.delegate.duration") + .serviceLevelObjectives(this.context.config().metrics().slo()) + .tags(Tags.of(staticTags)); + } + } +} diff --git a/experimental/operaton-engine-bpmn/src/main/java/io/koraframework/bpmn/operaton/engine/telemetry/impl/DefaultOperatonEngineObservation.java b/experimental/operaton-engine-bpmn/src/main/java/io/koraframework/bpmn/operaton/engine/telemetry/impl/DefaultOperatonEngineObservation.java new file mode 100644 index 000000000..99f17dd85 --- /dev/null +++ b/experimental/operaton-engine-bpmn/src/main/java/io/koraframework/bpmn/operaton/engine/telemetry/impl/DefaultOperatonEngineObservation.java @@ -0,0 +1,67 @@ +package io.koraframework.bpmn.operaton.engine.telemetry.impl; + +import io.koraframework.bpmn.operaton.engine.telemetry.OperatonEngineObservation; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.StatusCode; +import org.operaton.bpm.engine.delegate.DelegateExecution; +import org.jspecify.annotations.Nullable; + +import static io.opentelemetry.api.common.AttributeKey.stringKey; + +public class DefaultOperatonEngineObservation implements OperatonEngineObservation { + + protected final long start = System.nanoTime(); + protected final DefaultOperatonEngineTelemetry.TelemetryContext context; + protected final Span span; + protected final DefaultOperatonEngineLoggerFactory.DefaultOperatonEngineLogger logger; + protected final DefaultOperatonEngineMetricsFactory.DefaultOperatonEngineMetrics metrics; + + @Nullable + protected Throwable error; + protected DelegateExecution execution; + + public DefaultOperatonEngineObservation(DefaultOperatonEngineTelemetry.TelemetryContext context, + Span span, + DefaultOperatonEngineLoggerFactory.DefaultOperatonEngineLogger logger, + DefaultOperatonEngineMetricsFactory.DefaultOperatonEngineMetrics metrics) { + this.context = context; + this.span = span; + this.logger = logger; + this.metrics = metrics; + } + + @Override + public Span span() { + return this.span; + } + + @Override + public void end() { + var took = System.nanoTime() - this.start; + this.metrics.record(this.error, took); + this.logger.logEnd(this.execution, this.error, took); + this.span.end(); + } + + @Override + public void observeError(Throwable e) { + this.error = e; + this.span.setStatus(StatusCode.ERROR); + this.span.recordException(e); + } + + @Override + public void observeExecution(DelegateExecution execution) { + this.execution = execution; + setNotNull("eventName", execution.getEventName()); + setNotNull("processBusinessKey", execution.getProcessBusinessKey()); + setNotNull("processInstanceId", execution.getProcessInstanceId()); + this.logger.logStart(execution); + } + + protected void setNotNull(String name, String value) { + if (value != null) { + this.span.setAttribute(stringKey(name), value); + } + } +} diff --git a/experimental/operaton-engine-bpmn/src/main/java/io/koraframework/bpmn/operaton/engine/telemetry/impl/DefaultOperatonEngineTelemetry.java b/experimental/operaton-engine-bpmn/src/main/java/io/koraframework/bpmn/operaton/engine/telemetry/impl/DefaultOperatonEngineTelemetry.java new file mode 100644 index 000000000..2ab25a30d --- /dev/null +++ b/experimental/operaton-engine-bpmn/src/main/java/io/koraframework/bpmn/operaton/engine/telemetry/impl/DefaultOperatonEngineTelemetry.java @@ -0,0 +1,69 @@ +package io.koraframework.bpmn.operaton.engine.telemetry.impl; + +import io.koraframework.bpmn.operaton.engine.telemetry.OperatonEngineObservation; +import io.koraframework.bpmn.operaton.engine.telemetry.OperatonEngineTelemetry; +import io.koraframework.bpmn.operaton.engine.telemetry.OperatonEngineTelemetryConfig; +import io.micrometer.core.instrument.MeterRegistry; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.SpanBuilder; +import io.opentelemetry.api.trace.SpanKind; +import io.opentelemetry.api.trace.Tracer; + +import static io.opentelemetry.api.common.AttributeKey.stringKey; + +public class DefaultOperatonEngineTelemetry implements OperatonEngineTelemetry { + + public record TelemetryContext(OperatonEngineTelemetryConfig config, + boolean isTraceEnabled, + boolean isMetricsEnabled, + Tracer tracer, + MeterRegistry meterRegistry) {} + + protected final TelemetryContext context; + protected final DefaultOperatonEngineLoggerFactory loggerFactory; + protected final DefaultOperatonEngineMetricsFactory metricsFactory; + + public DefaultOperatonEngineTelemetry(OperatonEngineTelemetryConfig config, + Tracer tracer, + MeterRegistry meterRegistry, + DefaultOperatonEngineMetricsFactory metricsFactory, + DefaultOperatonEngineLoggerFactory loggerFactory) { + var isTraceEnabled = config.tracing().enabled() && tracer != DefaultOperatonEngineTelemetryFactory.NOOP_TRACER; + var isMetricsEnabled = config.metrics().enabled() && meterRegistry != DefaultOperatonEngineTelemetryFactory.NOOP_METER_REGISTRY; + this.context = new TelemetryContext(config, isTraceEnabled, isMetricsEnabled, tracer, meterRegistry); + this.loggerFactory = loggerFactory; + this.metricsFactory = metricsFactory; + } + + @Override + public OperatonEngineObservation observe(String javaDelegateName) { + var span = this.createSpan(javaDelegateName); + var logger = this.loggerFactory.create(this.context, javaDelegateName); + var metrics = this.metricsFactory.create(this.context, javaDelegateName); + + return new DefaultOperatonEngineObservation(this.context, span, logger, metrics); + } + + protected Span createSpan(String javaDelegateName) { + if (!this.context.isTraceEnabled()) { + return Span.getInvalid(); + } + + var span = this.context.tracer() + .spanBuilder("Operaton Delegate " + javaDelegateName) + .setSpanKind(SpanKind.INTERNAL) + .setParent(io.opentelemetry.context.Context.current()) + .setAttribute("delegate", javaDelegateName); + for (var entry : this.context.config().tracing().attributes().entrySet()) { + span.setAttribute(entry.getKey(), entry.getValue()); + } + return span.startSpan(); + } + + protected static SpanBuilder setNotNull(SpanBuilder builder, String name, String value) { + if (value != null) { + builder.setAttribute(stringKey(name), value); + } + return builder; + } +} diff --git a/experimental/operaton-engine-bpmn/src/main/java/io/koraframework/bpmn/operaton/engine/telemetry/impl/DefaultOperatonEngineTelemetryFactory.java b/experimental/operaton-engine-bpmn/src/main/java/io/koraframework/bpmn/operaton/engine/telemetry/impl/DefaultOperatonEngineTelemetryFactory.java new file mode 100644 index 000000000..b7172b5ec --- /dev/null +++ b/experimental/operaton-engine-bpmn/src/main/java/io/koraframework/bpmn/operaton/engine/telemetry/impl/DefaultOperatonEngineTelemetryFactory.java @@ -0,0 +1,75 @@ +package io.koraframework.bpmn.operaton.engine.telemetry.impl; + +import io.koraframework.bpmn.operaton.engine.telemetry.OperatonEngineTelemetry; +import io.koraframework.bpmn.operaton.engine.telemetry.OperatonEngineTelemetryConfig; +import io.koraframework.bpmn.operaton.engine.telemetry.OperatonEngineTelemetryFactory; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.composite.CompositeMeterRegistry; +import io.opentelemetry.api.trace.Tracer; +import io.opentelemetry.api.trace.TracerProvider; +import org.jspecify.annotations.Nullable; + +public class DefaultOperatonEngineTelemetryFactory implements OperatonEngineTelemetryFactory { + + public static final Tracer NOOP_TRACER = TracerProvider.noop().get("operaton-engine-bpmn"); + public static final MeterRegistry NOOP_METER_REGISTRY = new CompositeMeterRegistry(); + + @Nullable + private final Tracer tracer; + @Nullable + private final MeterRegistry meterRegistry; + @Nullable + private final DefaultOperatonEngineLoggerFactory loggerFactory; + @Nullable + private final DefaultOperatonEngineMetricsFactory metricsFactory; + + public DefaultOperatonEngineTelemetryFactory(@Nullable Tracer tracer, + @Nullable MeterRegistry meterRegistry, + @Nullable DefaultOperatonEngineLoggerFactory loggerFactory, + @Nullable DefaultOperatonEngineMetricsFactory metricsFactory) { + this.tracer = tracer; + this.meterRegistry = meterRegistry; + this.loggerFactory = loggerFactory; + this.metricsFactory = metricsFactory; + } + + @Override + public OperatonEngineTelemetry get(OperatonEngineTelemetryConfig config) { + var traceEnabled = this.tracer != null && config.tracing().enabled(); + var metricEnabled = this.meterRegistry != null && config.metrics().enabled(); + if (!traceEnabled && !metricEnabled && !config.logging().enabled()) { + return NoopOperatonEngineTelemetry.INSTANCE; + } + + var tracer = traceEnabled ? this.tracer : NOOP_TRACER; + var meterRegistry = metricEnabled ? this.meterRegistry : NOOP_METER_REGISTRY; + + final DefaultOperatonEngineMetricsFactory enabledMetricsFactory; + if (metricEnabled) { + enabledMetricsFactory = this.metricsFactory != null + ? this.metricsFactory + : DefaultOperatonEngineMetricsFactory.INSTANCE; + } else { + enabledMetricsFactory = NoopOperatonEngineMetricsFactory.INSTANCE; + } + + final DefaultOperatonEngineLoggerFactory enabledLoggerFactory; + if (config.logging().enabled()) { + enabledLoggerFactory = this.loggerFactory != null + ? this.loggerFactory + : DefaultOperatonEngineLoggerFactory.INSTANCE; + } else { + enabledLoggerFactory = NoopOperatonEngineLoggerFactory.INSTANCE; + } + + return build(config, tracer, meterRegistry, enabledMetricsFactory, enabledLoggerFactory); + } + + protected OperatonEngineTelemetry build(OperatonEngineTelemetryConfig config, + Tracer tracer, + MeterRegistry meterRegistry, + DefaultOperatonEngineMetricsFactory metricsFactory, + DefaultOperatonEngineLoggerFactory loggerFactory) { + return new DefaultOperatonEngineTelemetry(config, tracer, meterRegistry, metricsFactory, loggerFactory); + } +} diff --git a/experimental/operaton-engine-bpmn/src/main/java/io/koraframework/bpmn/operaton/engine/telemetry/impl/NoopOperatonEngineLoggerFactory.java b/experimental/operaton-engine-bpmn/src/main/java/io/koraframework/bpmn/operaton/engine/telemetry/impl/NoopOperatonEngineLoggerFactory.java new file mode 100644 index 000000000..e8ae7e0b9 --- /dev/null +++ b/experimental/operaton-engine-bpmn/src/main/java/io/koraframework/bpmn/operaton/engine/telemetry/impl/NoopOperatonEngineLoggerFactory.java @@ -0,0 +1,36 @@ +package io.koraframework.bpmn.operaton.engine.telemetry.impl; + +import org.operaton.bpm.engine.delegate.DelegateExecution; +import org.jspecify.annotations.Nullable; +import org.slf4j.helpers.NOPLogger; + +public final class NoopOperatonEngineLoggerFactory extends DefaultOperatonEngineLoggerFactory { + + public static final NoopOperatonEngineLoggerFactory INSTANCE = new NoopOperatonEngineLoggerFactory(); + + private NoopOperatonEngineLoggerFactory() {} + + @Override + public DefaultOperatonEngineLogger create(DefaultOperatonEngineTelemetry.TelemetryContext context, String javaDelegateName) { + return NoopOperatonEngineLogger.INSTANCE; + } + + public static final class NoopOperatonEngineLogger extends DefaultOperatonEngineLogger { + + public static final NoopOperatonEngineLogger INSTANCE = new NoopOperatonEngineLogger(); + + private NoopOperatonEngineLogger() { + super(null, "none", NOPLogger.NOP_LOGGER); + } + + @Override + public void logStart(DelegateExecution execution) { + + } + + @Override + public void logEnd(@Nullable DelegateExecution execution, @Nullable Throwable error, long processingTimeNanos) { + + } + } +} diff --git a/experimental/operaton-engine-bpmn/src/main/java/io/koraframework/bpmn/operaton/engine/telemetry/impl/NoopOperatonEngineMetricsFactory.java b/experimental/operaton-engine-bpmn/src/main/java/io/koraframework/bpmn/operaton/engine/telemetry/impl/NoopOperatonEngineMetricsFactory.java new file mode 100644 index 000000000..e859c6b93 --- /dev/null +++ b/experimental/operaton-engine-bpmn/src/main/java/io/koraframework/bpmn/operaton/engine/telemetry/impl/NoopOperatonEngineMetricsFactory.java @@ -0,0 +1,29 @@ +package io.koraframework.bpmn.operaton.engine.telemetry.impl; + +import org.jspecify.annotations.Nullable; + +public final class NoopOperatonEngineMetricsFactory extends DefaultOperatonEngineMetricsFactory { + + public static final NoopOperatonEngineMetricsFactory INSTANCE = new NoopOperatonEngineMetricsFactory(); + + private NoopOperatonEngineMetricsFactory() {} + + @Override + public DefaultOperatonEngineMetrics create(DefaultOperatonEngineTelemetry.TelemetryContext context, String javaDelegateName) { + return NoopOperatonEngineMetrics.INSTANCE; + } + + public static final class NoopOperatonEngineMetrics extends DefaultOperatonEngineMetrics { + + public static final NoopOperatonEngineMetrics INSTANCE = new NoopOperatonEngineMetrics(); + + private NoopOperatonEngineMetrics() { + super(null, "none"); + } + + @Override + public void record(@Nullable Throwable throwable, long processingTimeNanos) { + + } + } +} diff --git a/experimental/operaton-engine-bpmn/src/main/java/io/koraframework/bpmn/operaton/engine/telemetry/impl/NoopOperatonEngineObservation.java b/experimental/operaton-engine-bpmn/src/main/java/io/koraframework/bpmn/operaton/engine/telemetry/impl/NoopOperatonEngineObservation.java new file mode 100644 index 000000000..67c586a16 --- /dev/null +++ b/experimental/operaton-engine-bpmn/src/main/java/io/koraframework/bpmn/operaton/engine/telemetry/impl/NoopOperatonEngineObservation.java @@ -0,0 +1,30 @@ +package io.koraframework.bpmn.operaton.engine.telemetry.impl; + +import io.koraframework.bpmn.operaton.engine.telemetry.OperatonEngineObservation; +import io.opentelemetry.api.trace.Span; +import org.operaton.bpm.engine.delegate.DelegateExecution; + +public final class NoopOperatonEngineObservation implements OperatonEngineObservation { + + public static final NoopOperatonEngineObservation INSTANCE = new NoopOperatonEngineObservation(); + + @Override + public Span span() { + return Span.getInvalid(); + } + + @Override + public void end() { + + } + + @Override + public void observeError(Throwable e) { + + } + + @Override + public void observeExecution(DelegateExecution execution) { + + } +} diff --git a/experimental/operaton-engine-bpmn/src/main/java/io/koraframework/bpmn/operaton/engine/telemetry/impl/NoopOperatonEngineTelemetry.java b/experimental/operaton-engine-bpmn/src/main/java/io/koraframework/bpmn/operaton/engine/telemetry/impl/NoopOperatonEngineTelemetry.java new file mode 100644 index 000000000..5178995ca --- /dev/null +++ b/experimental/operaton-engine-bpmn/src/main/java/io/koraframework/bpmn/operaton/engine/telemetry/impl/NoopOperatonEngineTelemetry.java @@ -0,0 +1,14 @@ +package io.koraframework.bpmn.operaton.engine.telemetry.impl; + +import io.koraframework.bpmn.operaton.engine.telemetry.OperatonEngineTelemetry; +import io.koraframework.bpmn.operaton.engine.telemetry.OperatonEngineObservation; + +public final class NoopOperatonEngineTelemetry implements OperatonEngineTelemetry { + + public static final NoopOperatonEngineTelemetry INSTANCE = new NoopOperatonEngineTelemetry(); + + @Override + public OperatonEngineObservation observe(String canonicalName) { + return NoopOperatonEngineObservation.INSTANCE; + } +} diff --git a/experimental/operaton-engine-bpmn/src/main/java/io/koraframework/bpmn/operaton/engine/transaction/JdbcOperatonTransactionManager.java b/experimental/operaton-engine-bpmn/src/main/java/io/koraframework/bpmn/operaton/engine/transaction/JdbcOperatonTransactionManager.java new file mode 100644 index 000000000..8e3bf771d --- /dev/null +++ b/experimental/operaton-engine-bpmn/src/main/java/io/koraframework/bpmn/operaton/engine/transaction/JdbcOperatonTransactionManager.java @@ -0,0 +1,111 @@ +package io.koraframework.bpmn.operaton.engine.transaction; + +import io.koraframework.database.jdbc.RuntimeSqlException; + +import javax.sql.DataSource; +import java.sql.Connection; +import java.sql.SQLException; +import java.util.function.Supplier; + +public class JdbcOperatonTransactionManager implements OperatonTransactionManager { + + private final ScopedValue engineConnectionKey = ScopedValue.newInstance(); + + private final DataSource dataSource; + + public JdbcOperatonTransactionManager(DataSource dataSource) { + this.dataSource = dataSource; + } + + @Override + public TransactionConnection currentConnection() { + return new TransactionConnection() { + @Override + public void commit() throws RuntimeSqlException { + try { + if (engineConnectionKey.isBound()) { + engineConnectionKey.get().commit(); + } + } catch (SQLException e) { + throw new RuntimeSqlException(e); + } + } + + @Override + public void rollback() throws RuntimeSqlException { + try { + if (engineConnectionKey.isBound()) { + engineConnectionKey.get().rollback(); + } + } catch (SQLException e) { + throw new RuntimeSqlException(e); + } + } + }; + } + + @Override + public T inContinueTx(Supplier supplier) { + if (this.engineConnectionKey.isBound()) { + var currentConnection = this.engineConnectionKey.get(); + boolean isClosed; + try { + isClosed = currentConnection.isClosed(); + } catch (SQLException e) { + isClosed = true; + } + + if (!isClosed) { + try { + return processSupplier(currentConnection, supplier); + } catch (SQLException e) { + throw new RuntimeSqlException(e); + } + } + } + + return inNewTx(supplier); + } + + @Override + public void inContinueTx(Runnable runnable) { + inContinueTx(() -> { + runnable.run(); + return null; + }); + } + + @Override + public T inNewTx(Supplier supplier) { + try (var connection = this.dataSource.getConnection()) { + return ScopedValue.where(this.engineConnectionKey, connection).call(() -> processSupplier(connection, supplier)); + } catch (SQLException e) { + throw new RuntimeSqlException(e); + } + } + + @Override + public void inNewTx(Runnable runnable) { + inNewTx(() -> { + runnable.run(); + return null; + }); + } + + private T processSupplier(Connection connection, Supplier supplier) throws SQLException { + boolean isAutoCommit = connection.getAutoCommit(); + if (isAutoCommit) { + connection.setAutoCommit(false); + } + + try { + return supplier.get(); + } finally { + if (isAutoCommit) { + if (!connection.isClosed()) { + connection.setAutoCommit(true); + } + } + } + } +} diff --git a/experimental/operaton-engine-bpmn/src/main/java/io/koraframework/bpmn/operaton/engine/transaction/KoraTransactionContext.java b/experimental/operaton-engine-bpmn/src/main/java/io/koraframework/bpmn/operaton/engine/transaction/KoraTransactionContext.java new file mode 100644 index 000000000..543048d7b --- /dev/null +++ b/experimental/operaton-engine-bpmn/src/main/java/io/koraframework/bpmn/operaton/engine/transaction/KoraTransactionContext.java @@ -0,0 +1,96 @@ +package io.koraframework.bpmn.operaton.engine.transaction; + +import org.operaton.bpm.engine.impl.cfg.TransactionContext; +import org.operaton.bpm.engine.impl.cfg.TransactionListener; +import org.operaton.bpm.engine.impl.cfg.TransactionState; +import org.operaton.bpm.engine.impl.interceptor.CommandContext; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + +public final class KoraTransactionContext implements TransactionContext { + + private static final Logger logger = LoggerFactory.getLogger(KoraTransactionContext.class); + + private final CommandContext commandContext; + private final OperatonTransactionManager transactionManager; + + private final List beforeCommit = new CopyOnWriteArrayList<>(); + private final List afterCommit = new CopyOnWriteArrayList<>(); + private final List beforeRollback = new CopyOnWriteArrayList<>(); + private final List afterRollback = new CopyOnWriteArrayList<>(); + private volatile TransactionState lastTransactionState = null; + + public KoraTransactionContext(CommandContext commandContext, + OperatonTransactionManager transactionManager) { + this.transactionManager = transactionManager; + this.commandContext = commandContext; + } + + @Override + public void commit() { + this.lastTransactionState = TransactionState.COMMITTING; + for (TransactionListener listener : beforeCommit) { + try { + listener.execute(commandContext); + } catch (Exception e) { + logger.warn(e.getMessage()); + } + } + + var connection = transactionManager.currentConnection(); + connection.commit(); + + this.lastTransactionState = TransactionState.COMMITTED; + for (TransactionListener listener : afterCommit) { + try { + listener.execute(commandContext); + } catch (Exception e) { + logger.warn(e.getMessage()); + } + } + } + + @Override + public void rollback() { + this.lastTransactionState = TransactionState.ROLLINGBACK; + for (TransactionListener listener : beforeRollback) { + try { + listener.execute(commandContext); + } catch (Exception e) { + logger.warn(e.getMessage()); + } + } + + var connection = transactionManager.currentConnection(); + connection.rollback(); + + this.lastTransactionState = TransactionState.ROLLED_BACK; + for (TransactionListener listener : afterRollback) { + try { + listener.execute(commandContext); + } catch (Exception e) { + logger.warn(e.getMessage()); + } + } + } + + @Override + public void addTransactionListener(TransactionState transactionState, TransactionListener transactionListener) { + switch (transactionState) { + case COMMITTING -> beforeCommit.add(transactionListener); + case COMMITTED -> afterCommit.add(transactionListener); + case ROLLINGBACK -> beforeRollback.add(transactionListener); + case ROLLED_BACK -> afterRollback.add(transactionListener); + } + } + + @Override + public boolean isTransactionActive() { + return this.lastTransactionState != null + && this.lastTransactionState != TransactionState.ROLLINGBACK + && this.lastTransactionState != TransactionState.ROLLED_BACK; + } +} diff --git a/experimental/operaton-engine-bpmn/src/main/java/io/koraframework/bpmn/operaton/engine/transaction/KoraTransactionContextFactory.java b/experimental/operaton-engine-bpmn/src/main/java/io/koraframework/bpmn/operaton/engine/transaction/KoraTransactionContextFactory.java new file mode 100644 index 000000000..7e28d80d4 --- /dev/null +++ b/experimental/operaton-engine-bpmn/src/main/java/io/koraframework/bpmn/operaton/engine/transaction/KoraTransactionContextFactory.java @@ -0,0 +1,19 @@ +package io.koraframework.bpmn.operaton.engine.transaction; + +import org.operaton.bpm.engine.impl.cfg.TransactionContext; +import org.operaton.bpm.engine.impl.cfg.TransactionContextFactory; +import org.operaton.bpm.engine.impl.interceptor.CommandContext; + +public final class KoraTransactionContextFactory implements TransactionContextFactory { + + private final OperatonTransactionManager transactionManager; + + public KoraTransactionContextFactory(OperatonTransactionManager transactionManager) { + this.transactionManager = transactionManager; + } + + @Override + public TransactionContext openTransactionContext(CommandContext commandContext) { + return new KoraTransactionContext(commandContext, transactionManager); + } +} diff --git a/experimental/operaton-engine-bpmn/src/main/java/io/koraframework/bpmn/operaton/engine/transaction/KoraTransactionInterceptor.java b/experimental/operaton-engine-bpmn/src/main/java/io/koraframework/bpmn/operaton/engine/transaction/KoraTransactionInterceptor.java new file mode 100644 index 000000000..fd27d5e09 --- /dev/null +++ b/experimental/operaton-engine-bpmn/src/main/java/io/koraframework/bpmn/operaton/engine/transaction/KoraTransactionInterceptor.java @@ -0,0 +1,24 @@ +package io.koraframework.bpmn.operaton.engine.transaction; + +import org.operaton.bpm.engine.impl.interceptor.Command; +import org.operaton.bpm.engine.impl.interceptor.CommandInterceptor; + +public final class KoraTransactionInterceptor extends CommandInterceptor { + + private final OperatonTransactionManager transactionManager; + private final boolean requiresNew; + + public KoraTransactionInterceptor(OperatonTransactionManager transactionManager, boolean requiresNew) { + this.requiresNew = requiresNew; + this.transactionManager = transactionManager; + } + + @Override + public T execute(Command command) { + if (requiresNew) { + return transactionManager.inNewTx(() -> next.execute(command)); + } else { + return transactionManager.inContinueTx(() -> next.execute(command)); + } + } +} diff --git a/experimental/operaton-engine-bpmn/src/main/java/io/koraframework/bpmn/operaton/engine/transaction/OperatonTransactionManager.java b/experimental/operaton-engine-bpmn/src/main/java/io/koraframework/bpmn/operaton/engine/transaction/OperatonTransactionManager.java new file mode 100644 index 000000000..fd120f670 --- /dev/null +++ b/experimental/operaton-engine-bpmn/src/main/java/io/koraframework/bpmn/operaton/engine/transaction/OperatonTransactionManager.java @@ -0,0 +1,25 @@ +package io.koraframework.bpmn.operaton.engine.transaction; + +import io.koraframework.database.jdbc.RuntimeSqlException; + +import java.util.function.Supplier; + +public interface OperatonTransactionManager { + + T inContinueTx(Supplier supplier) throws RuntimeSqlException; + + void inContinueTx(Runnable runnable) throws RuntimeSqlException; + + T inNewTx(Supplier supplier) throws RuntimeSqlException; + + void inNewTx(Runnable runnable) throws RuntimeSqlException; + + TransactionConnection currentConnection(); + + interface TransactionConnection { + + void commit() throws RuntimeSqlException; + + void rollback() throws RuntimeSqlException; + } +} diff --git a/experimental/operaton-engine-bpmn/src/test/java/io/koraframework/bpmn/operaton/engine/KoraArtifactFactoryTests.java b/experimental/operaton-engine-bpmn/src/test/java/io/koraframework/bpmn/operaton/engine/KoraArtifactFactoryTests.java new file mode 100644 index 000000000..603980bbd --- /dev/null +++ b/experimental/operaton-engine-bpmn/src/test/java/io/koraframework/bpmn/operaton/engine/KoraArtifactFactoryTests.java @@ -0,0 +1,27 @@ +package io.koraframework.bpmn.operaton.engine; + +import org.operaton.bpm.engine.ArtifactFactory; +import org.operaton.bpm.engine.delegate.DelegateExecution; +import org.operaton.bpm.engine.delegate.JavaDelegate; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertInstanceOf; + +public class KoraArtifactFactoryTests { + + private static final class SimpleDelegate implements JavaDelegate { + + @Override + public void execute(DelegateExecution execution) { + // do nothing + } + } + + @Test + void getByCanonicalName() { + ArtifactFactory artifactFactory = new KoraArtifactFactory(delegate -> delegate, List.of(), List.of(new SimpleDelegate())); + assertInstanceOf(SimpleDelegate.class, artifactFactory.getArtifact(SimpleDelegate.class)); + } +} diff --git a/experimental/operaton-engine-bpmn/src/test/java/io/koraframework/bpmn/operaton/engine/KoraELResolverTests.java b/experimental/operaton-engine-bpmn/src/test/java/io/koraframework/bpmn/operaton/engine/KoraELResolverTests.java new file mode 100644 index 000000000..8a1905eed --- /dev/null +++ b/experimental/operaton-engine-bpmn/src/test/java/io/koraframework/bpmn/operaton/engine/KoraELResolverTests.java @@ -0,0 +1,54 @@ +package io.koraframework.bpmn.operaton.engine; + +import jakarta.el.ELResolver; +import org.operaton.bpm.engine.delegate.DelegateExecution; +import org.operaton.bpm.engine.delegate.JavaDelegate; +import org.operaton.bpm.impl.juel.SimpleContext; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertInstanceOf; + +public class KoraELResolverTests { + + private static final class SimpleDelegate implements JavaDelegate { + + @Override + public void execute(DelegateExecution execution) { + // do nothing + } + } + + private static class SimpleKoraDelegate implements KoraDelegate { + @NotNull + @Override + public String key() { + return "key"; + } + + @Override + public void execute(DelegateExecution delegateExecution) { + + } + } + + @Test + void getByCanonicalName() { + ELResolver resolver = new KoraELResolver(delegate -> delegate, List.of(), List.of(new SimpleDelegate())); + assertInstanceOf(SimpleDelegate.class, resolver.getValue(new SimpleContext(), null, SimpleDelegate.class.getCanonicalName())); + } + + @Test + void getBySimpleName() { + ELResolver resolver = new KoraELResolver(delegate -> delegate, List.of(), List.of(new SimpleDelegate())); + assertInstanceOf(SimpleDelegate.class, resolver.getValue(new SimpleContext(), null, SimpleDelegate.class.getSimpleName())); + } + + @Test + void getByKey() { + ELResolver resolver = new KoraELResolver(delegate -> delegate, List.of(new SimpleKoraDelegate()), List.of()); + assertInstanceOf(SimpleKoraDelegate.class, resolver.getValue(new SimpleContext(), null, "key")); + } +} diff --git a/experimental/operaton-engine-bpmn/src/test/java/io/koraframework/bpmn/operaton/engine/KoraProcessEngineTests.java b/experimental/operaton-engine-bpmn/src/test/java/io/koraframework/bpmn/operaton/engine/KoraProcessEngineTests.java new file mode 100644 index 000000000..09506a63b --- /dev/null +++ b/experimental/operaton-engine-bpmn/src/test/java/io/koraframework/bpmn/operaton/engine/KoraProcessEngineTests.java @@ -0,0 +1,194 @@ +package io.koraframework.bpmn.operaton.engine; + +import io.koraframework.application.graph.All; +import io.koraframework.bpmn.operaton.engine.telemetry.$OperatonEngineTelemetryConfig_CamundaEngineLoggingConfig_ConfigValueExtractor; +import io.koraframework.bpmn.operaton.engine.telemetry.$OperatonEngineTelemetryConfig_CamundaEngineMetricsConfig_ConfigValueExtractor; +import io.koraframework.bpmn.operaton.engine.telemetry.$OperatonEngineTelemetryConfig_CamundaEngineTracingConfig_ConfigValueExtractor; +import io.koraframework.bpmn.operaton.engine.telemetry.$OperatonEngineTelemetryConfig_ConfigValueExtractor; +import io.koraframework.bpmn.operaton.engine.transaction.JdbcOperatonTransactionManager; +import io.koraframework.bpmn.operaton.engine.transaction.OperatonTransactionManager; +import io.koraframework.database.common.telemetry.*; +import io.koraframework.database.common.telemetry.impl.NoopDatabaseMetricsFactory; +import io.koraframework.database.common.telemetry.impl.NoopDatabaseTelemetryFactory; +import io.koraframework.database.jdbc.$JdbcDatabaseConfig_ConfigValueExtractor; +import io.koraframework.database.jdbc.JdbcDatabase; +import io.koraframework.telemetry.common.$TelemetryConfig_MetricsConfig_ConfigValueExtractor; +import io.koraframework.telemetry.common.$TelemetryConfig_TracingConfig_ConfigValueExtractor; +import io.koraframework.telemetry.common.TelemetryConfig; +import io.koraframework.test.postgres.PostgresParams; +import io.koraframework.test.postgres.PostgresTestContainer; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.operaton.bpm.engine.ProcessEngineConfiguration; +import org.operaton.bpm.engine.impl.jobexecutor.JobExecutor; + +import javax.sql.DataSource; +import java.sql.SQLException; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.Properties; +import java.util.function.Consumer; + +@ExtendWith({PostgresTestContainer.class}) +public class KoraProcessEngineTests implements OperatonEngineBpmnModule { + + @Test + void initTwoStage(PostgresParams params) { + withDatabase(params, jdbc -> { + var config = new $OperatonEngineBpmnConfig_ConfigValueExtractor.OperatonEngineBpmnConfig_Impl( + new OperatonEngineBpmnConfig.ParallelInitConfig() {}, + $OperatonEngineBpmnConfig_JobExecutorConfig_ConfigValueExtractor.DEFAULTS, + new $OperatonEngineBpmnConfig_DeploymentConfig_ConfigValueExtractor.DeploymentConfig_Impl(null, "MyDep", false, List.of("bpm"), null), + new $OperatonEngineBpmnConfig_AdminConfig_ConfigValueExtractor.AdminConfig_Impl("admin", "admin", null, null, null), + new $OperatonEngineTelemetryConfig_ConfigValueExtractor.OperatonEngineTelemetryConfig_Impl( + new $OperatonEngineTelemetryConfig_CamundaEngineLoggingConfig_ConfigValueExtractor.CamundaEngineLoggingConfig_Defaults(), + new $OperatonEngineTelemetryConfig_CamundaEngineMetricsConfig_ConfigValueExtractor.CamundaEngineMetricsConfig_Defaults(), + new $OperatonEngineTelemetryConfig_CamundaEngineTracingConfig_ConfigValueExtractor.CamundaEngineTracingConfig_Defaults() + ) + ); + + KoraDelegateWrapperFactory koraDelegateWrapperFactory = koraJavaDelegateTelemetryWrapper(null, null); + JobExecutor jobExecutor = operatonEngineBpmnKoraJobExecutor(config); + + OperatonEngineDataSource operatonEngineDataSource = new OperatonEngineDataSource() { + + @Override + public OperatonTransactionManager transactionManager() { + return new JdbcOperatonTransactionManager(jdbc.value()); + } + + @Override + public DataSource dataSource() { + return jdbc.value(); + } + }; + + ProcessEngineConfiguration koraProcessEngineConfiguration = operatonEngineBpmnKoraProcessEngineConfiguration( + jobExecutor, + operatonEngineBpmnIdGenerator(), + operatonEngineBpmnKoraExpressionManager(operatonEngineBpmnKoraELResolver(koraDelegateWrapperFactory, All.of(), All.of())), + operatonEngineBpmnKoraArtifactFactory(koraDelegateWrapperFactory, All.of(), All.of()), + All.of(), + operatonEngineDataSource, + config, + operatonEngineBpmnKoraComponentResolverFactory(koraDelegateWrapperFactory, All.of(), All.of()) + ); + + var koraProcessEngine = operatonEngineBpmnKoraProcessEngine(koraProcessEngineConfiguration, + config, + All.of( + operatonEngineBpmnKoraProcessEngineTwoStageOperatonConfigurator(koraProcessEngineConfiguration, config, jobExecutor), + operatonEngineBpmnKoraAdminUserConfigurator(config, operatonEngineDataSource), + operatonEngineBpmnKoraResourceDeploymentConfigurator(config) + )); + try { + koraProcessEngine.init(); + } catch (Exception e) { + throw new IllegalStateException(e); + } finally { + koraProcessEngine.release(); + } + }); + } + + @Test + void initOneStage(PostgresParams params) { + withDatabase(params, jdbc -> { + var config = new $OperatonEngineBpmnConfig_ConfigValueExtractor.OperatonEngineBpmnConfig_Impl( + new OperatonEngineBpmnConfig.ParallelInitConfig() { + @Override + public boolean enabled() { + return false; + } + }, + $OperatonEngineBpmnConfig_JobExecutorConfig_ConfigValueExtractor.DEFAULTS, + new $OperatonEngineBpmnConfig_DeploymentConfig_ConfigValueExtractor.DeploymentConfig_Impl(null, "MyDep", false, List.of("bpm"), null), + new $OperatonEngineBpmnConfig_AdminConfig_ConfigValueExtractor.AdminConfig_Impl("admin", "admin", null, null, null), + new $OperatonEngineTelemetryConfig_ConfigValueExtractor.OperatonEngineTelemetryConfig_Impl( + new $OperatonEngineTelemetryConfig_CamundaEngineLoggingConfig_ConfigValueExtractor.CamundaEngineLoggingConfig_Defaults(), + new $OperatonEngineTelemetryConfig_CamundaEngineMetricsConfig_ConfigValueExtractor.CamundaEngineMetricsConfig_Defaults(), + new $OperatonEngineTelemetryConfig_CamundaEngineTracingConfig_ConfigValueExtractor.CamundaEngineTracingConfig_Defaults() + ) + ); + + KoraDelegateWrapperFactory koraDelegateWrapperFactory = koraJavaDelegateTelemetryWrapper(null, null); + JobExecutor jobExecutor = operatonEngineBpmnKoraJobExecutor(config); + + OperatonEngineDataSource operatonEngineDataSource = new OperatonEngineDataSource() { + + @Override + public OperatonTransactionManager transactionManager() { + return new JdbcOperatonTransactionManager(jdbc.value()); + } + + @Override + public DataSource dataSource() { + return jdbc.value(); + } + }; + + ProcessEngineConfiguration koraProcessEngineConfiguration = operatonEngineBpmnKoraProcessEngineConfiguration( + jobExecutor, + operatonEngineBpmnIdGenerator(), + operatonEngineBpmnKoraExpressionManager(operatonEngineBpmnKoraELResolver(koraDelegateWrapperFactory, All.of(), All.of())), + operatonEngineBpmnKoraArtifactFactory(koraDelegateWrapperFactory, All.of(), All.of()), + All.of(), + operatonEngineDataSource, + config, + operatonEngineBpmnKoraComponentResolverFactory(koraDelegateWrapperFactory, All.of(), All.of()) + ); + + KoraProcessEngine koraProcessEngine = operatonEngineBpmnKoraProcessEngine(koraProcessEngineConfiguration, + config, + All.of( + operatonEngineBpmnKoraAdminUserConfigurator(config, operatonEngineDataSource), + operatonEngineBpmnKoraResourceDeploymentConfigurator(config) + )); + try { + koraProcessEngine.init(); + } catch (Exception e) { + throw new IllegalStateException(e); + } finally { + koraProcessEngine.release(); + } + }); + } + + private static void withDatabase(PostgresParams params, Consumer consumer) { + var config = new $JdbcDatabaseConfig_ConfigValueExtractor.JdbcDatabaseConfig_Impl( + params.user(), + params.password(), + params.jdbcUrl(), + "testPool", + null, + Duration.ofMillis(5000L), + Duration.ofMillis(5000L), + Duration.ofMillis(5000L), + Duration.ofMillis(5000L), + Duration.ofMillis(5000L), + 10, + 1, + Duration.ofMillis(5000L), + false, + new Properties(), + new $DatabaseTelemetryConfig_ConfigValueExtractor.DatabaseTelemetryConfig_Impl( + new $DatabaseTelemetryConfig_DatabaseLoggingConfig_ConfigValueExtractor.DatabaseLoggingConfig_Defaults(), + new $DatabaseTelemetryConfig_DatabaseMetricsConfig_ConfigValueExtractor.DatabaseMetricsConfig_Defaults(), + new $DatabaseTelemetryConfig_DatabaseTracingConfig_ConfigValueExtractor.DatabaseTracingConfig_Defaults() + ) + ); + + var db = new JdbcDatabase(config, NoopDatabaseTelemetryFactory.INSTANCE, null); + try { + db.init(); + } catch (SQLException e) { + throw new IllegalStateException(e); + } + try { + consumer.accept(db); + } finally { + db.release(); + } + } +} diff --git a/experimental/operaton-engine-bpmn/src/test/java/io/koraframework/bpmn/operaton/engine/KoraResolverFactoryTests.java b/experimental/operaton-engine-bpmn/src/test/java/io/koraframework/bpmn/operaton/engine/KoraResolverFactoryTests.java new file mode 100644 index 000000000..83252e05a --- /dev/null +++ b/experimental/operaton-engine-bpmn/src/test/java/io/koraframework/bpmn/operaton/engine/KoraResolverFactoryTests.java @@ -0,0 +1,53 @@ +package io.koraframework.bpmn.operaton.engine; + +import org.operaton.bpm.engine.delegate.DelegateExecution; +import org.operaton.bpm.engine.delegate.JavaDelegate; +import org.operaton.bpm.engine.impl.scripting.engine.Resolver; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertInstanceOf; + +public class KoraResolverFactoryTests { + + private static final class SimpleDelegate implements JavaDelegate { + + @Override + public void execute(DelegateExecution execution) { + // do nothing + } + } + + private static class SimpleKoraDelegate implements KoraDelegate { + @NotNull + @Override + public String key() { + return "key"; + } + + @Override + public void execute(DelegateExecution delegateExecution) throws Exception { + + } + } + + @Test + void getByCanonicalName() { + Resolver resolver = new KoraResolverFactory(delegate -> delegate, List.of(), List.of(new SimpleDelegate())); + assertInstanceOf(SimpleDelegate.class, resolver.get(SimpleDelegate.class.getCanonicalName())); + } + + @Test + void getBySimpleName() { + Resolver resolver = new KoraResolverFactory(delegate -> delegate, List.of(), List.of(new SimpleDelegate())); + assertInstanceOf(SimpleDelegate.class, resolver.get(SimpleDelegate.class.getSimpleName())); + } + + @Test + void getByKey() { + Resolver resolver = new KoraResolverFactory(delegate -> delegate, List.of(new SimpleKoraDelegate()), List.of()); + assertInstanceOf(SimpleKoraDelegate.class, resolver.get("key")); + } +} diff --git a/experimental/operaton-engine-bpmn/src/test/java/io/koraframework/bpmn/operaton/engine/configurator/ClasspathResourceUtilsTests.java b/experimental/operaton-engine-bpmn/src/test/java/io/koraframework/bpmn/operaton/engine/configurator/ClasspathResourceUtilsTests.java new file mode 100644 index 000000000..e5d485346 --- /dev/null +++ b/experimental/operaton-engine-bpmn/src/test/java/io/koraframework/bpmn/operaton/engine/configurator/ClasspathResourceUtilsTests.java @@ -0,0 +1,57 @@ +package io.koraframework.bpmn.operaton.engine.configurator; + +import io.koraframework.bpmn.operaton.engine.configurator.DeploymentProcessEngineConfigurator.ClasspathResourceUtils; +import io.koraframework.bpmn.operaton.engine.configurator.DeploymentProcessEngineConfigurator.Resource; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +public class ClasspathResourceUtilsTests { + + @Test + void fileByRegex() { + List resources = ClasspathResourceUtils.findResources("bpm/.*.form"); + assertEquals(1, resources.size()); + for (Resource resource : resources) { + assertNotNull(resource.name()); + assertNotNull(resource.path()); + assertNotNull(resource.asInputStream()); + } + } + + @Test + void filesFromDirectory() { + List resources = ClasspathResourceUtils.findResources("bpm"); + assertEquals(4, resources.size()); + for (Resource resource : resources) { + assertNotNull(resource.name()); + assertNotNull(resource.path()); + assertNotNull(resource.asInputStream()); + } + } + + @Test + void fileByNameFromDirectory() { + List resources = ClasspathResourceUtils.findResources("bpm/ProcessEmpty.bpmn"); + assertEquals(1, resources.size()); + for (Resource resource : resources) { + assertNotNull(resource.name()); + assertNotNull(resource.path()); + assertNotNull(resource.asInputStream()); + } + } + + @Test + void fileByRegexFromDirectory() { + List resources = ClasspathResourceUtils.findResources("bpm/.*\\.bpmn"); + assertEquals(2, resources.size()); + for (Resource resource : resources) { + assertNotNull(resource.name()); + assertNotNull(resource.path()); + assertNotNull(resource.asInputStream()); + } + } +} diff --git a/experimental/operaton-engine-bpmn/src/test/resources/bpm/ProcessEmpty.bpmn b/experimental/operaton-engine-bpmn/src/test/resources/bpm/ProcessEmpty.bpmn new file mode 100644 index 000000000..58f0f0b01 --- /dev/null +++ b/experimental/operaton-engine-bpmn/src/test/resources/bpm/ProcessEmpty.bpmn @@ -0,0 +1,43 @@ + + + + + SequenceFlow_1ik94za + + + SequenceFlow_1ik94za + + + + This process is simply there to test that process models are deployed correctly if they are placed in the resources, see tests in ProcessEngineFactoryDeploymentTest + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/experimental/operaton-engine-bpmn/src/test/resources/bpm/ProcessEmptySubdir.bpmn b/experimental/operaton-engine-bpmn/src/test/resources/bpm/ProcessEmptySubdir.bpmn new file mode 100644 index 000000000..43d223b9e --- /dev/null +++ b/experimental/operaton-engine-bpmn/src/test/resources/bpm/ProcessEmptySubdir.bpmn @@ -0,0 +1,43 @@ + + + + + SequenceFlow_1ik94za + + + SequenceFlow_1ik94za + + + + This process is simply there to test that process models are deployed correctly if they are placed in the resources, see tests in ProcessEngineFactoryDeploymentTest + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/experimental/operaton-engine-bpmn/src/test/resources/bpm/sample.form b/experimental/operaton-engine-bpmn/src/test/resources/bpm/sample.form new file mode 100644 index 000000000..e69de29bb diff --git a/experimental/operaton-engine-bpmn/src/test/resources/bpm/simple-table.dmn b/experimental/operaton-engine-bpmn/src/test/resources/bpm/simple-table.dmn new file mode 100644 index 000000000..e69de29bb diff --git a/experimental/operaton-rest-undertow/build.gradle b/experimental/operaton-rest-undertow/build.gradle new file mode 100644 index 000000000..1e7ef3ad1 --- /dev/null +++ b/experimental/operaton-rest-undertow/build.gradle @@ -0,0 +1,33 @@ +apply from: "${project.rootDir}/gradle/in-test-generated.gradle" + +dependencies { + annotationProcessor project(":config:config-annotation-processor") + + compileOnly(libs.operaton.engine) { + exclude group: "org.springframework", module: "spring-beans" + exclude group: "org.apache.tomcat", module: "catalina" + } + + implementation project(":config:config-common") + implementation project(":logging:logging-common") + implementation libs.resteasy.undertow + implementation libs.resteasy.jackson + implementation project(":openapi:openapi-management") + implementation project(":http:http-server-undertow") + + api project(":telemetry:telemetry-common") + api project(":common") + api libs.operaton.rest.jakarta + api libs.operaton.openapi + api libs.jakarta.rs.api + api libs.undertow.servlet + api libs.undertow.core + api libs.jboss.threads + api libs.jboss.logging + + testImplementation libs.jdbc.postgresql + testImplementation project(":database:database-jdbc") + testImplementation project(":internal:test-logging") + testImplementation project(":test:test-junit5") + testImplementation project(":internal:test-postgres") +} diff --git a/experimental/operaton-rest-undertow/src/main/java/io/koraframework/bpmn/operaton/rest/KoraProcessEngineProvider.java b/experimental/operaton-rest-undertow/src/main/java/io/koraframework/bpmn/operaton/rest/KoraProcessEngineProvider.java new file mode 100644 index 000000000..9a56a643a --- /dev/null +++ b/experimental/operaton-rest-undertow/src/main/java/io/koraframework/bpmn/operaton/rest/KoraProcessEngineProvider.java @@ -0,0 +1,13 @@ +package io.koraframework.bpmn.operaton.rest; + +import org.operaton.bpm.engine.ProcessEngine; +import org.operaton.bpm.engine.ProcessEngineProvider; +import org.operaton.bpm.engine.ProcessEngines; + +public final class KoraProcessEngineProvider implements ProcessEngineProvider { + + @Override + public ProcessEngine getProcessEngine() { + return ProcessEngines.getDefaultProcessEngine(); + } +} diff --git a/experimental/operaton-rest-undertow/src/main/java/io/koraframework/bpmn/operaton/rest/OperatonRest.java b/experimental/operaton-rest-undertow/src/main/java/io/koraframework/bpmn/operaton/rest/OperatonRest.java new file mode 100644 index 000000000..ec961fcd8 --- /dev/null +++ b/experimental/operaton-rest-undertow/src/main/java/io/koraframework/bpmn/operaton/rest/OperatonRest.java @@ -0,0 +1,6 @@ +package io.koraframework.bpmn.operaton.rest; + +public final class OperatonRest { + + private OperatonRest() {} +} diff --git a/experimental/operaton-rest-undertow/src/main/java/io/koraframework/bpmn/operaton/rest/OperatonRestConfig.java b/experimental/operaton-rest-undertow/src/main/java/io/koraframework/bpmn/operaton/rest/OperatonRestConfig.java new file mode 100644 index 000000000..d9402e12e --- /dev/null +++ b/experimental/operaton-rest-undertow/src/main/java/io/koraframework/bpmn/operaton/rest/OperatonRestConfig.java @@ -0,0 +1,110 @@ +package io.koraframework.bpmn.operaton.rest; + +import io.koraframework.bpmn.operaton.rest.telemetry.OperatonRestTelemetryConfig; +import io.koraframework.config.common.annotation.ConfigValueExtractor; +import io.koraframework.http.server.common.telemetry.HttpServerTelemetryConfig; +import org.jspecify.annotations.Nullable; + +import java.time.Duration; +import java.util.List; + +@ConfigValueExtractor +public interface OperatonRestConfig { + + default boolean enabled() { + return false; + } + + default String path() { + return "/engine-rest"; + } + + default Integer port() { + return 8081; + } + + default Duration shutdownWait() { + return Duration.ofSeconds(30); + } + + CamundaOpenApiConfig openapi(); + + OperatonRestTelemetryConfig telemetry(); + + CamundaCorsConfig cors(); + + @ConfigValueExtractor + interface CamundaOpenApiConfig { + + default List file() { + return List.of("openapi.json"); + } + + default boolean enabled() { + return false; + } + + default String endpoint() { + return "/openapi"; + } + + SwaggerUIConfig swaggerui(); + + RapidocConfig rapidoc(); + + @ConfigValueExtractor + interface SwaggerUIConfig { + + default boolean enabled() { + return false; + } + + default String endpoint() { + return "/swagger-ui"; + } + } + + @ConfigValueExtractor + interface RapidocConfig { + + default boolean enabled() { + return false; + } + + default String endpoint() { + return "/rapidoc"; + } + } + } + + @ConfigValueExtractor + interface CamundaCorsConfig { + + default boolean enabled() { + return false; + } + + @Nullable + String allowOrigin(); + + default List allowHeaders() { + return List.of("*"); + } + + default List allowMethods() { + return List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"); + } + + default Boolean allowCredentials() { + return true; + } + + default List exposeHeaders() { + return List.of("*"); + } + + default Duration maxAge() { + return Duration.ofHours(1); + } + } +} diff --git a/experimental/operaton-rest-undertow/src/main/java/io/koraframework/bpmn/operaton/rest/OperatonRestModule.java b/experimental/operaton-rest-undertow/src/main/java/io/koraframework/bpmn/operaton/rest/OperatonRestModule.java new file mode 100644 index 000000000..ae51a1a22 --- /dev/null +++ b/experimental/operaton-rest-undertow/src/main/java/io/koraframework/bpmn/operaton/rest/OperatonRestModule.java @@ -0,0 +1,54 @@ +package io.koraframework.bpmn.operaton.rest; + +import io.koraframework.bpmn.operaton.rest.telemetry.impl.DefaultOperatonRestLoggerFactory; +import io.koraframework.bpmn.operaton.rest.telemetry.impl.DefaultOperatonRestMetricsFactory; +import io.koraframework.bpmn.operaton.rest.telemetry.impl.DefaultOperatonRestTelemetryFactory; +import io.koraframework.common.DefaultComponent; +import io.koraframework.common.Tag; +import io.koraframework.config.common.Config; +import io.koraframework.config.common.extractor.ConfigValueExtractor; +import io.koraframework.bpmn.operaton.rest.telemetry.OperatonRestTelemetryFactory; +import io.micrometer.core.instrument.MeterRegistry; +import io.opentelemetry.api.trace.Tracer; +import jakarta.ws.rs.core.Application; +import org.jboss.resteasy.plugins.providers.jackson.ResteasyJackson2Provider; +import org.jspecify.annotations.Nullable; +import org.operaton.bpm.engine.rest.impl.OperatonRestResources; + +import java.util.HashSet; +import java.util.Set; + +public interface OperatonRestModule { + + @DefaultComponent + default OperatonRestTelemetryFactory operatonRestTelemetryFactory(@Nullable Tracer tracer, + @Nullable MeterRegistry meterRegistry, + @Nullable DefaultOperatonRestLoggerFactory loggerFactory, + @Nullable DefaultOperatonRestMetricsFactory metricsFactory) { + return new DefaultOperatonRestTelemetryFactory(tracer, meterRegistry, loggerFactory, metricsFactory); + } + + default OperatonRestConfig operatonRestConfig(Config config, ConfigValueExtractor extractor) { + return extractor.extract(config.get("operaton.rest")); + } + + @Tag(OperatonRest.class) + default Application operatonRestApplication() { + return new Application() { + @Override + public Set> getClasses() { + var set = new HashSet>(); + set.addAll(OperatonRestResources.getResourceClasses()); + set.addAll(OperatonRestResources.getConfigurationClasses()); + return set; + } + + @Override + public Set getSingletons() { + return Set.of( + new ResteasyJackson2Provider() + ); + } + }; + } +} diff --git a/experimental/operaton-rest-undertow/src/main/java/io/koraframework/bpmn/operaton/rest/telemetry/OperatonRestObservation.java b/experimental/operaton-rest-undertow/src/main/java/io/koraframework/bpmn/operaton/rest/telemetry/OperatonRestObservation.java new file mode 100644 index 000000000..65c9be536 --- /dev/null +++ b/experimental/operaton-rest-undertow/src/main/java/io/koraframework/bpmn/operaton/rest/telemetry/OperatonRestObservation.java @@ -0,0 +1,13 @@ +package io.koraframework.bpmn.operaton.rest.telemetry; + +import io.koraframework.common.telemetry.Observation; + +import java.util.Map; + +public interface OperatonRestObservation extends Observation { + + void observeRequest(String route, Map pathParams); + + void observeResponseCode(int code); + +} diff --git a/experimental/operaton-rest-undertow/src/main/java/io/koraframework/bpmn/operaton/rest/telemetry/OperatonRestTelemetry.java b/experimental/operaton-rest-undertow/src/main/java/io/koraframework/bpmn/operaton/rest/telemetry/OperatonRestTelemetry.java new file mode 100644 index 000000000..2c001a162 --- /dev/null +++ b/experimental/operaton-rest-undertow/src/main/java/io/koraframework/bpmn/operaton/rest/telemetry/OperatonRestTelemetry.java @@ -0,0 +1,9 @@ +package io.koraframework.bpmn.operaton.rest.telemetry; + +import io.undertow.server.HttpServerExchange; +import org.jspecify.annotations.Nullable; + +public interface OperatonRestTelemetry { + + OperatonRestObservation observe(HttpServerExchange exchange, @Nullable String route); +} diff --git a/experimental/operaton-rest-undertow/src/main/java/io/koraframework/bpmn/operaton/rest/telemetry/OperatonRestTelemetryConfig.java b/experimental/operaton-rest-undertow/src/main/java/io/koraframework/bpmn/operaton/rest/telemetry/OperatonRestTelemetryConfig.java new file mode 100644 index 000000000..4a7203db8 --- /dev/null +++ b/experimental/operaton-rest-undertow/src/main/java/io/koraframework/bpmn/operaton/rest/telemetry/OperatonRestTelemetryConfig.java @@ -0,0 +1,50 @@ +package io.koraframework.bpmn.operaton.rest.telemetry; + +import io.koraframework.config.common.annotation.ConfigValueExtractor; +import io.koraframework.telemetry.common.TelemetryConfig; +import org.jspecify.annotations.Nullable; + +import java.util.Collections; +import java.util.Set; + +@ConfigValueExtractor +public interface OperatonRestTelemetryConfig extends TelemetryConfig { + + @Override + CamundaRestLoggingConfig logging(); + + @Override + CamundaRestMetricsConfig metrics(); + + @Override + CamundaRestTracingConfig tracing(); + + @ConfigValueExtractor + interface CamundaRestLoggingConfig extends LoggingConfig { + + default boolean stacktrace() { + return true; + } + + default Set maskQueries() { + return Collections.emptySet(); + } + + default Set maskHeaders() { + return Set.of("authorization", "cookie", "set-cookie"); + } + + default String mask() { + return "***"; + } + + @Nullable + Boolean pathFull(); + } + + @ConfigValueExtractor + interface CamundaRestMetricsConfig extends MetricsConfig {} + + @ConfigValueExtractor + interface CamundaRestTracingConfig extends TracingConfig {} +} diff --git a/experimental/operaton-rest-undertow/src/main/java/io/koraframework/bpmn/operaton/rest/telemetry/OperatonRestTelemetryFactory.java b/experimental/operaton-rest-undertow/src/main/java/io/koraframework/bpmn/operaton/rest/telemetry/OperatonRestTelemetryFactory.java new file mode 100644 index 000000000..226f7dedd --- /dev/null +++ b/experimental/operaton-rest-undertow/src/main/java/io/koraframework/bpmn/operaton/rest/telemetry/OperatonRestTelemetryFactory.java @@ -0,0 +1,6 @@ +package io.koraframework.bpmn.operaton.rest.telemetry; + +public interface OperatonRestTelemetryFactory { + + OperatonRestTelemetry get(OperatonRestTelemetryConfig telemetryConfig); +} diff --git a/experimental/operaton-rest-undertow/src/main/java/io/koraframework/bpmn/operaton/rest/telemetry/impl/DefaultOperatonRestLoggerFactory.java b/experimental/operaton-rest-undertow/src/main/java/io/koraframework/bpmn/operaton/rest/telemetry/impl/DefaultOperatonRestLoggerFactory.java new file mode 100644 index 000000000..b839d98f8 --- /dev/null +++ b/experimental/operaton-rest-undertow/src/main/java/io/koraframework/bpmn/operaton/rest/telemetry/impl/DefaultOperatonRestLoggerFactory.java @@ -0,0 +1,150 @@ +package io.koraframework.bpmn.operaton.rest.telemetry.impl; + +import io.koraframework.http.common.HttpResultCode; +import io.koraframework.http.common.header.HttpHeaders; +import io.koraframework.http.common.telemetry.MaskingUtils; +import io.koraframework.http.server.common.HttpServer; +import io.koraframework.http.server.common.request.HttpServerRequest; +import io.koraframework.logging.common.arg.StructuredArgumentWriter; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.slf4j.event.Level; + +import java.util.Locale; +import java.util.Set; +import java.util.stream.Collectors; + +public class DefaultOperatonRestLoggerFactory { + + public static final DefaultOperatonRestLoggerFactory INSTANCE = new DefaultOperatonRestLoggerFactory(); + + public DefaultOperatonRestLogger create(DefaultOperatonRestTelemetry.TelemetryContext context) { + var logger = LoggerFactory.getLogger(HttpServer.class); + var maskedQueryParams = context.config().logging().maskQueries().stream() + .map(e -> e.toLowerCase(Locale.ROOT)) + .collect(Collectors.toSet()); + var maskedHeaders = context.config().logging().maskHeaders().stream() + .map(e -> e.toLowerCase(Locale.ROOT)) + .collect(Collectors.toSet()); + return new DefaultOperatonRestLogger(context, logger, maskedQueryParams, maskedHeaders); + } + + public static class DefaultOperatonRestLogger { + + protected final DefaultOperatonRestTelemetry.TelemetryContext context; + protected final Logger logger; + protected final Set maskedQueryParams; + protected final Set maskedHeaders; + + public DefaultOperatonRestLogger(DefaultOperatonRestTelemetry.TelemetryContext context, + Logger logger, + Set maskedQueryParams, + Set maskedHeaders) { + this.context = context; + this.logger = logger; + this.maskedQueryParams = maskedQueryParams; + this.maskedHeaders = maskedHeaders; + } + + public void logStart(HttpServerRequest request) { + if (!logger.isInfoEnabled()) { + return; + } + + var queryParams = request.queryParams(); + var headers = request.headers(); + var level = Level.INFO; + if (!logger.isDebugEnabled()) { + queryParams = null; + headers = null; + } else { + level = Level.DEBUG; + } + + var finalQuery = queryParams; + var finalHeaders = headers; + var operation = getOperation(request.method(), request.path(), request.pathTemplate()); + var arg = (StructuredArgumentWriter) gen -> { + gen.writeStartObject(); + gen.writeStringProperty("operation", operation); + if (finalQuery != null && !finalQuery.isEmpty()) { + gen.writeStringProperty("queryParams", MaskingUtils.toMaskedString(maskedQueryParams, context.config().logging().mask(), finalQuery)); + } + if (finalHeaders != null && !finalHeaders.isEmpty()) { + gen.writeStringProperty("headers", MaskingUtils.toMaskedString(maskedHeaders, context.config().logging().mask(), finalHeaders)); + } + gen.writeEndObject(); + }; + logger.atLevel(level) + .addKeyValue("httpRequest", arg) + .log("OperatonRest received request"); + } + + public void logEnd(HttpServerRequest request, + int statusCode, + HttpResultCode resultCode, + long processingTime, + @Nullable HttpHeaders headers, + @Nullable Throwable exception) { + if (exception == null && !logger.isInfoEnabled()) { + return; + } + if (exception != null && !logger.isWarnEnabled()) { + return; + } + if (!logger.isDebugEnabled()) { + headers = null; + } + + var finalHeaders = headers; + var operation = getOperation(request.method(), request.path(), request.pathTemplate()); + var arg = (StructuredArgumentWriter) gen -> { + gen.writeStartObject(); + gen.writeStringProperty("operation", operation); + gen.writeStringProperty("resultCode", resultCode.string()); + gen.writeNumberProperty("processingTime", processingTime / 1_000_000); + gen.writeNumberProperty("statusCode", statusCode); + if (finalHeaders != null && !finalHeaders.isEmpty()) { + gen.writeStringProperty("headers", MaskingUtils.toMaskedString(maskedHeaders, context.config().logging().mask(), finalHeaders)); + } + if (exception != null) { + var exceptionType = exception.getClass().getCanonicalName(); + if (exceptionType != null) { + gen.writeStringProperty("exceptionType", exceptionType); + } + if (!context.config().logging().stacktrace()) { + gen.writeStringProperty("exceptionMessage", exception.getMessage()); + } + } + gen.writeEndObject(); + }; + + if (exception != null) { + logger.atWarn() + .addKeyValue("httpResponse", arg) + .setCause(context.config().logging().stacktrace() ? exception : null) + .log("OperatonRest errored response"); + } else { + logger.atLevel(logger.isDebugEnabled() ? Level.DEBUG : Level.INFO) + .addKeyValue("httpResponse", arg) + .log("OperatonRest succeed response"); + } + } + + protected boolean shouldWritePathFull() { + var pathFull = context.config().logging().pathFull(); + return pathFull != null ? pathFull : logger.isTraceEnabled(); + } + + protected String getOperation(String method, String path, @Nullable String pathTemplate) { + if (shouldWritePathFull()) { + return method + ' ' + path; + } else if (pathTemplate != null) { + return method + ' ' + pathTemplate; + } else { + return method; + } + } + } +} diff --git a/experimental/operaton-rest-undertow/src/main/java/io/koraframework/bpmn/operaton/rest/telemetry/impl/DefaultOperatonRestMetricsFactory.java b/experimental/operaton-rest-undertow/src/main/java/io/koraframework/bpmn/operaton/rest/telemetry/impl/DefaultOperatonRestMetricsFactory.java new file mode 100644 index 000000000..4dd958ae2 --- /dev/null +++ b/experimental/operaton-rest-undertow/src/main/java/io/koraframework/bpmn/operaton/rest/telemetry/impl/DefaultOperatonRestMetricsFactory.java @@ -0,0 +1,169 @@ +package io.koraframework.bpmn.operaton.rest.telemetry.impl; + +import io.koraframework.http.common.HttpResultCode; +import io.micrometer.core.instrument.Gauge; +import io.micrometer.core.instrument.Tag; +import io.micrometer.core.instrument.Tags; +import io.micrometer.core.instrument.Timer; +import io.opentelemetry.semconv.ErrorAttributes; +import io.opentelemetry.semconv.HttpAttributes; +import io.opentelemetry.semconv.ServerAttributes; +import io.opentelemetry.semconv.UrlAttributes; +import io.undertow.server.HttpServerExchange; +import org.jspecify.annotations.Nullable; + +import java.util.ArrayList; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; + +public class DefaultOperatonRestMetricsFactory { + + public static final DefaultOperatonRestMetricsFactory INSTANCE = new DefaultOperatonRestMetricsFactory(); + + public DefaultOperatonRestMetrics create(DefaultOperatonRestTelemetry.TelemetryContext context) { + return new DefaultOperatonRestMetrics(context); + } + + public static class DefaultOperatonRestMetrics { + + public record DurationKey(String method, + String pathTemplate, + String scheme, + String host, + int statusCode, + HttpResultCode resultCode, + @Nullable Class errorType, + @Nullable Tags extraTags) { + + public DurationKey withExtraTags(Tags tags) { + return new DurationKey(method, pathTemplate, scheme, host, statusCode, resultCode, errorType, tags); + } + } + + public record ActiveRequestsKey(String method, + String pathTemplate, + String scheme, + String host, + @Nullable Tags extraTags) { + + public ActiveRequestsKey withExtraTags(Tags tags) { + return new ActiveRequestsKey(method, pathTemplate, scheme, host, tags); + } + } + + protected final ConcurrentHashMap durationCache = new ConcurrentHashMap<>(); + protected final ConcurrentHashMap activeRequestsCache = new ConcurrentHashMap<>(); + + protected final DefaultOperatonRestTelemetry.TelemetryContext context; + + public DefaultOperatonRestMetrics(DefaultOperatonRestTelemetry.TelemetryContext context) { + this.context = context; + } + + public void recordDuration(HttpServerExchange exchange, + @Nullable String route, + int statusCode, + @Nullable HttpResultCode resultCode, + @Nullable Throwable throwable, + long processingTimeNanos) { + var key = createDurationKey(exchange, route, statusCode, resultCode, throwable); + var meter = this.durationCache.computeIfAbsent(key, _ -> createDuration(key).register(this.context.meterRegistry())); + meter.record(processingTimeNanos, TimeUnit.NANOSECONDS); + } + + public void recordActive(HttpServerExchange exchange, String route, int delta) { + var key = createActiveRequestsKey(exchange, route); + var activeRequests = this.activeRequestsCache.computeIfAbsent(key, _ -> createActiveRequests(key)); + activeRequests.addAndGet(delta); + } + + protected DurationKey createDurationKey(HttpServerExchange exchange, + @Nullable String route, + int statusCode, + @Nullable HttpResultCode resultCode, + @Nullable Throwable throwable) { + return new DurationKey( + exchange.getRequestMethod().toString(), + Objects.requireNonNullElse(route, "UNKNOWN_ROUTE"), + exchange.getRequestScheme(), + exchange.getHostAndPort(), + statusCode, + Objects.requireNonNullElse(resultCode, HttpResultCode.SERVER_ERROR), + throwable == null ? null : throwable.getClass(), + null + ); + } + + protected ActiveRequestsKey createActiveRequestsKey(HttpServerExchange exchange, String route) { + return new ActiveRequestsKey( + exchange.getRequestMethod().toString(), + route, + exchange.getRequestScheme(), + exchange.getHostAndPort(), + null + ); + } + + // DO NOT ADD DYNAMIC TAGS IN BUILDER, use metric key instead of metric collision will happen + protected Timer.Builder createDuration(DurationKey metricKey) { + var staticTags = new ArrayList(7 + this.context.config().metrics().tags().size() + extraTagsSize(metricKey.extraTags())); + staticTags.add(Tag.of(HttpAttributes.HTTP_REQUEST_METHOD.getKey(), metricKey.method())); + staticTags.add(Tag.of(HttpAttributes.HTTP_RESPONSE_STATUS_CODE.getKey(), Integer.toString(metricKey.statusCode()))); + staticTags.add(Tag.of(HttpAttributes.HTTP_ROUTE.getKey(), metricKey.pathTemplate())); + staticTags.add(Tag.of(UrlAttributes.URL_SCHEME.getKey(), metricKey.scheme())); + staticTags.add(Tag.of(ServerAttributes.SERVER_ADDRESS.getKey(), metricKey.host())); + staticTags.add(Tag.of("http.response.result_code", metricKey.resultCode().string())); + staticTags.add(Tag.of(ErrorAttributes.ERROR_TYPE.getKey(), metricKey.errorType() == null ? "" : metricKey.errorType().getCanonicalName())); + addConfiguredTags(staticTags); + addExtraTags(staticTags, metricKey.extraTags()); + + return Timer.builder("operaton.rest.request.duration") + .serviceLevelObjectives(this.context.config().metrics().slo()) + .tags(Tags.of(staticTags)); + } + + // DO NOT ADD DYNAMIC TAGS IN BUILDER, use metric key instead of metric collision will happen + protected AtomicLong createActiveRequests(ActiveRequestsKey metricKey) { + var staticTags = new ArrayList(4 + this.context.config().metrics().tags().size() + extraTagsSize(metricKey.extraTags())); + staticTags.add(Tag.of(HttpAttributes.HTTP_REQUEST_METHOD.getKey(), metricKey.method())); + staticTags.add(Tag.of(HttpAttributes.HTTP_ROUTE.getKey(), metricKey.pathTemplate())); + staticTags.add(Tag.of(UrlAttributes.URL_SCHEME.getKey(), metricKey.scheme())); + staticTags.add(Tag.of(ServerAttributes.SERVER_ADDRESS.getKey(), metricKey.host())); + addConfiguredTags(staticTags); + addExtraTags(staticTags, metricKey.extraTags()); + + var value = new AtomicLong(0); + Gauge.builder("operaton.rest.active_requests", value, AtomicLong::get) + .tags(Tags.of(staticTags)) + .register(this.context.meterRegistry()); + return value; + } + + protected void addConfiguredTags(ArrayList staticTags) { + for (var e : this.context.config().metrics().tags().entrySet()) { + staticTags.add(Tag.of(e.getKey(), e.getValue())); + } + } + + protected void addExtraTags(ArrayList staticTags, @Nullable Tags extraTags) { + if (extraTags != null) { + for (Tag extraTag : extraTags) { + staticTags.add(extraTag); + } + } + } + + protected int extraTagsSize(@Nullable Tags tags) { + if (tags == null) { + return 0; + } + var size = 0; + for (Tag _ : tags) { + size++; + } + return size; + } + } +} diff --git a/experimental/operaton-rest-undertow/src/main/java/io/koraframework/bpmn/operaton/rest/telemetry/impl/DefaultOperatonRestObservation.java b/experimental/operaton-rest-undertow/src/main/java/io/koraframework/bpmn/operaton/rest/telemetry/impl/DefaultOperatonRestObservation.java new file mode 100644 index 000000000..4fa809b74 --- /dev/null +++ b/experimental/operaton-rest-undertow/src/main/java/io/koraframework/bpmn/operaton/rest/telemetry/impl/DefaultOperatonRestObservation.java @@ -0,0 +1,122 @@ +package io.koraframework.bpmn.operaton.rest.telemetry.impl; + +import io.koraframework.bpmn.operaton.rest.telemetry.OperatonRestObservation; +import io.koraframework.http.common.HttpResultCode; +import io.koraframework.http.common.header.HttpHeaders; +import io.koraframework.http.server.common.request.RouterHttpServerRequest; +import io.koraframework.http.server.undertow.request.UndertowHttpRouterRequest; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.StatusCode; +import io.opentelemetry.semconv.HttpAttributes; +import io.undertow.server.HttpServerExchange; +import org.jspecify.annotations.Nullable; + +import java.util.Map; +import java.util.Objects; + +public class DefaultOperatonRestObservation implements OperatonRestObservation { + + protected int statusCode = 0; + protected final HttpServerExchange exchange; + @Nullable + protected HttpResultCode resultCode; + @Nullable + protected HttpHeaders httpHeaders; + @Nullable + protected Throwable exception; + protected final DefaultOperatonRestTelemetry.TelemetryContext context; + protected final long requestStartTime; + protected final Span span; + protected final DefaultOperatonRestLoggerFactory.DefaultOperatonRestLogger logger; + protected final DefaultOperatonRestMetricsFactory.DefaultOperatonRestMetrics metrics; + @Nullable + private String route; + @Nullable + private Map pathParams; + + public DefaultOperatonRestObservation(HttpServerExchange exchange, + DefaultOperatonRestTelemetry.TelemetryContext context, + long requestStartTime, + Span span, + DefaultOperatonRestLoggerFactory.DefaultOperatonRestLogger logger, + DefaultOperatonRestMetricsFactory.DefaultOperatonRestMetrics metrics) { + this.exchange = exchange; + this.context = context; + this.requestStartTime = requestStartTime; + this.span = span; + this.logger = logger; + this.metrics = metrics; + } + + @Override + public void observeError(Throwable exception) { + this.exception = exception; + this.span.recordException(exception); + this.span.setStatus(StatusCode.ERROR); + } + + @Override + public void observeRequest(String route, Map pathParams) { + this.route = route; + this.pathParams = pathParams; + this.metrics.recordActive(this.exchange, route, 1); + var request = new RouterHttpServerRequest( + new UndertowHttpRouterRequest(this.exchange), + pathParams, + route + ); + this.logger.logStart(request); + } + + @Override + public void observeResponseCode(int code) { + if (this.statusCode == 0) { + this.resultCode = HttpResultCode.fromStatusCode(code); + } + this.statusCode = code; + } + + @Override + public Span span() { + return this.span; + } + + @Override + public void end() { + var end = System.nanoTime(); + var processingTime = end - requestStartTime; + this.metrics.recordDuration(this.exchange, this.route, this.statusCode, this.resultCode, this.exception, processingTime); + if (this.route != null) { + this.metrics.recordActive(this.exchange, this.route, -1); + } + this.writeLog(processingTime); + this.closeSpan(resultCode); + } + + protected void writeLog(long processingTime) { + if (route != null) { + var request = new RouterHttpServerRequest( + new UndertowHttpRouterRequest(exchange), + pathParams, + route + ); + this.logger.logEnd(request, statusCode, Objects.requireNonNullElse(this.resultCode, HttpResultCode.SERVER_ERROR), processingTime, httpHeaders, exception); + } + } + + protected void closeSpan(@Nullable HttpResultCode resultCode) { + if (route != null) { + var finalResultCode = Objects.requireNonNullElse(resultCode, HttpResultCode.SERVER_ERROR); + span.setAttribute("http.response.result_code", finalResultCode.string()); + if (statusCode >= 500 || finalResultCode == HttpResultCode.CONNECTION_ERROR) { + span.setStatus(StatusCode.ERROR); + } else if (exception == null) { + span.setStatus(StatusCode.OK); + } + if (statusCode != 0) { + span.setAttribute(HttpAttributes.HTTP_RESPONSE_STATUS_CODE, statusCode); + } + span.end(); + } + } +} diff --git a/experimental/operaton-rest-undertow/src/main/java/io/koraframework/bpmn/operaton/rest/telemetry/impl/DefaultOperatonRestTelemetry.java b/experimental/operaton-rest-undertow/src/main/java/io/koraframework/bpmn/operaton/rest/telemetry/impl/DefaultOperatonRestTelemetry.java new file mode 100644 index 000000000..d62a7779d --- /dev/null +++ b/experimental/operaton-rest-undertow/src/main/java/io/koraframework/bpmn/operaton/rest/telemetry/impl/DefaultOperatonRestTelemetry.java @@ -0,0 +1,66 @@ +package io.koraframework.bpmn.operaton.rest.telemetry.impl; + +import io.koraframework.bpmn.operaton.rest.telemetry.OperatonRestObservation; +import io.koraframework.bpmn.operaton.rest.telemetry.OperatonRestTelemetry; +import io.koraframework.bpmn.operaton.rest.telemetry.OperatonRestTelemetryConfig; +import io.koraframework.http.server.common.telemetry.HttpServerTelemetryConfig; +import io.micrometer.core.instrument.MeterRegistry; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.SpanKind; +import io.opentelemetry.api.trace.Tracer; +import io.opentelemetry.semconv.HttpAttributes; +import io.opentelemetry.semconv.ServerAttributes; +import io.opentelemetry.semconv.UrlAttributes; +import io.undertow.server.HttpServerExchange; +import org.jspecify.annotations.Nullable; + +public class DefaultOperatonRestTelemetry implements OperatonRestTelemetry { + + public record TelemetryContext(OperatonRestTelemetryConfig config, + boolean isTraceEnabled, + boolean isMetricsEnabled, + Tracer tracer, + MeterRegistry meterRegistry) {} + + protected final TelemetryContext context; + protected final DefaultOperatonRestLoggerFactory.DefaultOperatonRestLogger logger; + protected final DefaultOperatonRestMetricsFactory.DefaultOperatonRestMetrics metrics; + + public DefaultOperatonRestTelemetry(OperatonRestTelemetryConfig config, + Tracer tracer, + MeterRegistry meterRegistry, + DefaultOperatonRestMetricsFactory metricsFactory, + DefaultOperatonRestLoggerFactory loggerFactory) { + var isTraceEnabled = config.tracing().enabled() && tracer != DefaultOperatonRestTelemetryFactory.NOOP_TRACER; + var isMetricsEnabled = config.metrics().enabled() && meterRegistry != DefaultOperatonRestTelemetryFactory.NOOP_METER_REGISTRY; + this.context = new TelemetryContext(config, isTraceEnabled, isMetricsEnabled, tracer, meterRegistry); + this.logger = loggerFactory.create(this.context); + this.metrics = metricsFactory.create(this.context); + } + + @Override + public OperatonRestObservation observe(HttpServerExchange exchange, @Nullable String route) { + var span = this.createSpan(route, exchange); + return new DefaultOperatonRestObservation(exchange, this.context, exchange.getRequestStartTime(), span, logger, metrics); + } + + protected Span createSpan(String template, HttpServerExchange exchange) { + if (template == null || !this.context.isTraceEnabled()) { + return Span.getInvalid(); + } + var span = this.context.tracer() + .spanBuilder(exchange.getRequestMethod() + " " + template) + .setSpanKind(SpanKind.SERVER) + .setParent(io.opentelemetry.context.Context.current()) + .setAttribute(HttpAttributes.HTTP_REQUEST_METHOD, exchange.getRequestMethod().toString()) + .setAttribute(UrlAttributes.URL_SCHEME, exchange.getRequestScheme()) + .setAttribute(ServerAttributes.SERVER_ADDRESS, exchange.getHostAndPort()) + .setAttribute(UrlAttributes.URL_PATH, exchange.getRequestPath()) + .setAttribute(HttpAttributes.HTTP_ROUTE, template); + for (var attribute : this.context.config().tracing().attributes().entrySet()) { + span.setAttribute(attribute.getKey(), attribute.getValue()); + } + return span.startSpan(); + } + +} diff --git a/experimental/operaton-rest-undertow/src/main/java/io/koraframework/bpmn/operaton/rest/telemetry/impl/DefaultOperatonRestTelemetryFactory.java b/experimental/operaton-rest-undertow/src/main/java/io/koraframework/bpmn/operaton/rest/telemetry/impl/DefaultOperatonRestTelemetryFactory.java new file mode 100644 index 000000000..96b68cadf --- /dev/null +++ b/experimental/operaton-rest-undertow/src/main/java/io/koraframework/bpmn/operaton/rest/telemetry/impl/DefaultOperatonRestTelemetryFactory.java @@ -0,0 +1,75 @@ +package io.koraframework.bpmn.operaton.rest.telemetry.impl; + +import io.koraframework.bpmn.operaton.rest.telemetry.OperatonRestTelemetry; +import io.koraframework.bpmn.operaton.rest.telemetry.OperatonRestTelemetryConfig; +import io.koraframework.bpmn.operaton.rest.telemetry.OperatonRestTelemetryFactory; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.composite.CompositeMeterRegistry; +import io.opentelemetry.api.trace.Tracer; +import io.opentelemetry.api.trace.TracerProvider; +import org.jspecify.annotations.Nullable; + +public class DefaultOperatonRestTelemetryFactory implements OperatonRestTelemetryFactory { + + public static final Tracer NOOP_TRACER = TracerProvider.noop().get("operaton-rest"); + public static final MeterRegistry NOOP_METER_REGISTRY = new CompositeMeterRegistry(); + + @Nullable + private final Tracer tracer; + @Nullable + private final MeterRegistry meterRegistry; + @Nullable + private final DefaultOperatonRestLoggerFactory loggerFactory; + @Nullable + private final DefaultOperatonRestMetricsFactory metricsFactory; + + public DefaultOperatonRestTelemetryFactory(@Nullable Tracer tracer, + @Nullable MeterRegistry meterRegistry, + @Nullable DefaultOperatonRestLoggerFactory loggerFactory, + @Nullable DefaultOperatonRestMetricsFactory metricsFactory) { + this.tracer = tracer; + this.meterRegistry = meterRegistry; + this.loggerFactory = loggerFactory; + this.metricsFactory = metricsFactory; + } + + @Override + public OperatonRestTelemetry get(OperatonRestTelemetryConfig config) { + var traceEnabled = this.tracer != null && config.tracing().enabled(); + var metricEnabled = this.meterRegistry != null && config.metrics().enabled(); + if (!traceEnabled && !metricEnabled && !config.logging().enabled()) { + return NoopOperatonRestTelemetry.INSTANCE; + } + + var tracer = traceEnabled ? this.tracer : NOOP_TRACER; + var meterRegistry = metricEnabled ? this.meterRegistry : NOOP_METER_REGISTRY; + + final DefaultOperatonRestMetricsFactory enabledMetricsFactory; + if (metricEnabled) { + enabledMetricsFactory = this.metricsFactory != null + ? this.metricsFactory + : DefaultOperatonRestMetricsFactory.INSTANCE; + } else { + enabledMetricsFactory = NoopOperatonRestMetricsFactory.INSTANCE; + } + + final DefaultOperatonRestLoggerFactory enabledLoggerFactory; + if (config.logging().enabled()) { + enabledLoggerFactory = this.loggerFactory != null + ? this.loggerFactory + : DefaultOperatonRestLoggerFactory.INSTANCE; + } else { + enabledLoggerFactory = NoopOperatonRestLoggerFactory.INSTANCE; + } + + return build(config, tracer, meterRegistry, enabledMetricsFactory, enabledLoggerFactory); + } + + protected OperatonRestTelemetry build(OperatonRestTelemetryConfig config, + Tracer tracer, + MeterRegistry meterRegistry, + DefaultOperatonRestMetricsFactory metricsFactory, + DefaultOperatonRestLoggerFactory loggerFactory) { + return new DefaultOperatonRestTelemetry(config, tracer, meterRegistry, metricsFactory, loggerFactory); + } +} diff --git a/experimental/operaton-rest-undertow/src/main/java/io/koraframework/bpmn/operaton/rest/telemetry/impl/NoopOperatonRestLoggerFactory.java b/experimental/operaton-rest-undertow/src/main/java/io/koraframework/bpmn/operaton/rest/telemetry/impl/NoopOperatonRestLoggerFactory.java new file mode 100644 index 000000000..435046a85 --- /dev/null +++ b/experimental/operaton-rest-undertow/src/main/java/io/koraframework/bpmn/operaton/rest/telemetry/impl/NoopOperatonRestLoggerFactory.java @@ -0,0 +1,45 @@ +package io.koraframework.bpmn.operaton.rest.telemetry.impl; + +import io.koraframework.http.common.HttpResultCode; +import io.koraframework.http.common.header.HttpHeaders; +import io.koraframework.http.server.common.request.HttpServerRequest; +import org.jspecify.annotations.Nullable; +import org.slf4j.helpers.NOPLogger; + +import java.util.Set; + +public final class NoopOperatonRestLoggerFactory extends DefaultOperatonRestLoggerFactory { + + public static final NoopOperatonRestLoggerFactory INSTANCE = new NoopOperatonRestLoggerFactory(); + + private NoopOperatonRestLoggerFactory() {} + + @Override + public DefaultOperatonRestLogger create(DefaultOperatonRestTelemetry.TelemetryContext context) { + return NoopOperatonRestLogger.INSTANCE; + } + + public static final class NoopOperatonRestLogger extends DefaultOperatonRestLogger { + + public static final NoopOperatonRestLogger INSTANCE = new NoopOperatonRestLogger(); + + private NoopOperatonRestLogger() { + super(null, NOPLogger.NOP_LOGGER, Set.of(), Set.of()); + } + + @Override + public void logStart(HttpServerRequest request) { + + } + + @Override + public void logEnd(HttpServerRequest request, + int statusCode, + HttpResultCode resultCode, + long processingTime, + @Nullable HttpHeaders headers, + @Nullable Throwable exception) { + + } + } +} diff --git a/experimental/operaton-rest-undertow/src/main/java/io/koraframework/bpmn/operaton/rest/telemetry/impl/NoopOperatonRestMetricsFactory.java b/experimental/operaton-rest-undertow/src/main/java/io/koraframework/bpmn/operaton/rest/telemetry/impl/NoopOperatonRestMetricsFactory.java new file mode 100644 index 000000000..b72ae4dab --- /dev/null +++ b/experimental/operaton-rest-undertow/src/main/java/io/koraframework/bpmn/operaton/rest/telemetry/impl/NoopOperatonRestMetricsFactory.java @@ -0,0 +1,41 @@ +package io.koraframework.bpmn.operaton.rest.telemetry.impl; + +import io.koraframework.http.common.HttpResultCode; +import io.undertow.server.HttpServerExchange; +import org.jspecify.annotations.Nullable; + +public final class NoopOperatonRestMetricsFactory extends DefaultOperatonRestMetricsFactory { + + public static final NoopOperatonRestMetricsFactory INSTANCE = new NoopOperatonRestMetricsFactory(); + + private NoopOperatonRestMetricsFactory() {} + + @Override + public DefaultOperatonRestMetrics create(DefaultOperatonRestTelemetry.TelemetryContext context) { + return NoopOperatonRestMetrics.INSTANCE; + } + + public static final class NoopOperatonRestMetrics extends DefaultOperatonRestMetrics { + + public static final NoopOperatonRestMetrics INSTANCE = new NoopOperatonRestMetrics(); + + private NoopOperatonRestMetrics() { + super(null); + } + + @Override + public void recordDuration(HttpServerExchange exchange, + @Nullable String route, + int statusCode, + @Nullable HttpResultCode resultCode, + @Nullable Throwable throwable, + long processingTimeNanos) { + + } + + @Override + public void recordActive(HttpServerExchange exchange, String route, int delta) { + + } + } +} diff --git a/experimental/operaton-rest-undertow/src/main/java/io/koraframework/bpmn/operaton/rest/telemetry/impl/NoopOperatonRestObservation.java b/experimental/operaton-rest-undertow/src/main/java/io/koraframework/bpmn/operaton/rest/telemetry/impl/NoopOperatonRestObservation.java new file mode 100644 index 000000000..e11cad8eb --- /dev/null +++ b/experimental/operaton-rest-undertow/src/main/java/io/koraframework/bpmn/operaton/rest/telemetry/impl/NoopOperatonRestObservation.java @@ -0,0 +1,36 @@ +package io.koraframework.bpmn.operaton.rest.telemetry.impl; + +import io.koraframework.bpmn.operaton.rest.telemetry.OperatonRestObservation; +import io.opentelemetry.api.trace.Span; + +import java.util.Map; + +public final class NoopOperatonRestObservation implements OperatonRestObservation { + + public static final NoopOperatonRestObservation INSTANCE = new NoopOperatonRestObservation(); + + @Override + public void observeRequest(String route, Map pathParams) { + + } + + @Override + public void observeResponseCode(int code) { + + } + + @Override + public Span span() { + return Span.getInvalid(); + } + + @Override + public void end() { + + } + + @Override + public void observeError(Throwable e) { + + } +} diff --git a/experimental/operaton-rest-undertow/src/main/java/io/koraframework/bpmn/operaton/rest/telemetry/impl/NoopOperatonRestTelemetry.java b/experimental/operaton-rest-undertow/src/main/java/io/koraframework/bpmn/operaton/rest/telemetry/impl/NoopOperatonRestTelemetry.java new file mode 100644 index 000000000..143b20375 --- /dev/null +++ b/experimental/operaton-rest-undertow/src/main/java/io/koraframework/bpmn/operaton/rest/telemetry/impl/NoopOperatonRestTelemetry.java @@ -0,0 +1,16 @@ +package io.koraframework.bpmn.operaton.rest.telemetry.impl; + +import io.koraframework.bpmn.operaton.rest.telemetry.OperatonRestObservation; +import io.koraframework.bpmn.operaton.rest.telemetry.OperatonRestTelemetry; +import io.undertow.server.HttpServerExchange; +import org.jspecify.annotations.Nullable; + +public final class NoopOperatonRestTelemetry implements OperatonRestTelemetry { + + public static final NoopOperatonRestTelemetry INSTANCE = new NoopOperatonRestTelemetry(); + + @Override + public OperatonRestObservation observe(HttpServerExchange exchange, @Nullable String pathTemplate) { + return NoopOperatonRestObservation.INSTANCE; + } +} diff --git a/experimental/operaton-rest-undertow/src/main/java/io/koraframework/bpmn/operaton/rest/undertow/OpenApiHttpHandler.java b/experimental/operaton-rest-undertow/src/main/java/io/koraframework/bpmn/operaton/rest/undertow/OpenApiHttpHandler.java new file mode 100644 index 000000000..3f0cbf964 --- /dev/null +++ b/experimental/operaton-rest-undertow/src/main/java/io/koraframework/bpmn/operaton/rest/undertow/OpenApiHttpHandler.java @@ -0,0 +1,278 @@ +package io.koraframework.bpmn.operaton.rest.undertow; + +import io.koraframework.bpmn.operaton.rest.OperatonRestConfig; +import io.koraframework.http.common.HttpMethod; +import io.koraframework.http.common.body.HttpBodyInput; +import io.koraframework.http.common.cookie.Cookie; +import io.koraframework.http.common.header.HttpHeaders; +import io.koraframework.http.server.common.request.HttpServerRequest; +import io.koraframework.http.server.common.request.HttpServerRequestHandler; +import io.koraframework.http.server.common.response.HttpServerResponse; +import io.koraframework.openapi.management.OpenApiHttpServerHandler; +import io.koraframework.openapi.management.RapidocHttpServerHandler; +import io.koraframework.openapi.management.SwaggerUIHttpServerHandler; +import io.undertow.io.IoCallback; +import io.undertow.io.Sender; +import io.undertow.server.HttpHandler; +import io.undertow.server.HttpServerExchange; +import io.undertow.util.AttachmentKey; +import io.undertow.util.HeaderMap; +import io.undertow.util.Headers; +import io.undertow.util.HttpString; +import org.jspecify.annotations.Nullable; +import org.xnio.IoUtils; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ExecutorService; + +final class OpenApiHttpHandler implements HttpHandler { + + private final UndertowPathMatcher pathMatcher; + private final OperatonRestConfig restConfig; + + private final OpenApiHttpServerHandler openApiHandler; + private final SwaggerUIHttpServerHandler swaggerUIHandler; + private final RapidocHttpServerHandler rapidocHandler; + private final AttachmentKey executorServiceAttachmentKey = AttachmentKey.create(ExecutorService.class); + + OpenApiHttpHandler(OperatonRestConfig restConfig) { + this.restConfig = restConfig; + + final List openapiMethods = new ArrayList<>(); + var openapi = restConfig.openapi(); + if (openapi.file().size() == 1) { + openapiMethods.add(new UndertowPathMatcher.HttpMethodPath(HttpMethod.GET, openapi.endpoint())); + } else { + openapiMethods.add(new UndertowPathMatcher.HttpMethodPath(HttpMethod.GET, openapi.endpoint() + "/{file}")); + } + openapiMethods.add(new UndertowPathMatcher.HttpMethodPath(HttpMethod.GET, openapi.rapidoc().endpoint())); + openapiMethods.add(new UndertowPathMatcher.HttpMethodPath(HttpMethod.GET, openapi.swaggerui().endpoint())); + this.pathMatcher = new UndertowPathMatcher(openapiMethods); + + this.openApiHandler = new OpenApiHttpServerHandler(openapi.file(), f -> { + if ("/engine-rest".equals(restConfig.path())) { + String fileAsStr = new String(f, StandardCharsets.UTF_8); + return fileAsStr + .replace("8080", String.valueOf(restConfig.port())) + .getBytes(StandardCharsets.UTF_8); + } else { + String fileAsStr = new String(f, StandardCharsets.UTF_8); + String newEnginePath = restConfig.path().startsWith("/") + ? restConfig.path().substring(1) + : restConfig.path(); + + return fileAsStr + .replace("engine-rest", newEnginePath) + .replace("8080", String.valueOf(restConfig.port())) + .getBytes(StandardCharsets.UTF_8); + } + }); + this.swaggerUIHandler = new SwaggerUIHttpServerHandler(openapi.endpoint(), openapi.swaggerui().endpoint(), openapi.file()); + this.rapidocHandler = new RapidocHttpServerHandler(openapi.endpoint(), openapi.rapidoc().endpoint(), openapi.file()); + } + + @Override + public void handleRequest(HttpServerExchange exchange) { + var requestPath = exchange.getRequestPath(); + var match = pathMatcher.getMatch(exchange.getRequestMethod().toString(), requestPath); + if (match == null) { + exchange.setStatusCode(404); + exchange.endExchange(); + return; + } + var fakeRequest = getFakeRequest(match); + var openapi = restConfig.openapi(); + if (openapi.enabled() && requestPath.startsWith(openapi.endpoint())) { + executeHandler(exchange, openApiHandler, fakeRequest); + } else if (openapi.swaggerui().enabled() && requestPath.startsWith(openapi.swaggerui().endpoint())) { + executeHandler(exchange, swaggerUIHandler, fakeRequest); + } else if (openapi.rapidoc().enabled() && requestPath.startsWith(openapi.rapidoc().endpoint())) { + executeHandler(exchange, rapidocHandler, fakeRequest); + } else { + exchange.setStatusCode(404); + exchange.endExchange(); + } + } + + private HttpServerRequest getFakeRequest(UndertowPathMatcher.Match match) { + return new HttpServerRequest() { + @Override + public String scheme() { + return "http"; + } + + @Override + public String host() { + return "localhost"; + } + + @Override + public String method() { + return ""; + } + + @Override + public String path() { + return ""; + } + + @Override + public String pathTemplate() { + return ""; + } + + @Override + public HttpHeaders headers() { + return null; + } + + @Override + public List cookies() { + return List.of(); + } + + @Override + public Map> queryParams() { + return Map.of(); + } + + @Override + public Map pathParams() { + return restConfig.openapi().file().size() == 1 + ? Map.of() + : Map.of("file", match.pathParameters().get("file")); + } + + @Override + public HttpBodyInput body() { + return null; + } + + @Override + public long requestStartTimeInNanos() { + return 0; + } + }; + } + + private void executeHandler(HttpServerExchange exchange, HttpServerRequestHandler.HandlerFunction handler, HttpServerRequest request) { + final HttpServerResponse response; + try { + response = handler.apply(request); + } catch (Throwable e) { + sendException(exchange, e); + return; + } + sendResponse(exchange, response); + + } + + private void sendResponse(HttpServerExchange exchange, + HttpServerResponse httpResponse) { + var headers = httpResponse.headers(); + exchange.setStatusCode(httpResponse.code()); + setHeaders(exchange.getResponseHeaders(), headers, null); + var body = httpResponse.body(); + if (body == null) { + exchange.endExchange(); + return; + } + + var contentType = body.contentType(); + if (contentType != null) { + exchange.getResponseHeaders().put(Headers.CONTENT_TYPE, contentType); + } + + var full = body.getFullContentIfAvailable(); + if (full != null) { + this.sendBody(exchange, httpResponse, full); + } else { + throw new IllegalStateException("Shouldn't happen"); + } + } + + private void setHeaders(HeaderMap responseHeaders, HttpHeaders headers, @Nullable String contentType) { + for (var header : headers) { + var key = header.getKey(); + if (key.equals("server")) { + continue; + } + if (key.equals("content-type") && contentType != null) { + continue; + } + if (key.equals("content-length")) { + continue; + } + if (key.equals("transfer-encoding")) { + continue; + } + responseHeaders.addAll(HttpString.tryFromString(key), header.getValue()); + } + } + + private void sendBody(HttpServerExchange exchange, + HttpServerResponse httpResponse, + @Nullable ByteBuffer body) { + var headers = httpResponse.headers(); + if (body == null || body.remaining() == 0) { + exchange.setResponseContentLength(0); + exchange.endExchange(); + } else { + exchange.setResponseContentLength(body.remaining()); + // io.undertow.io.DefaultIoCallback + exchange.getResponseSender().send(body, new IoCallback() { + @Override + public void onComplete(HttpServerExchange exchange, Sender sender) { + sender.close(new IoCallback() { + @Override + public void onComplete(HttpServerExchange exchange, Sender sender) { + exchange.endExchange(); + } + + @Override + public void onException(HttpServerExchange exchange, Sender sender, IOException exception) { + exchange.endExchange(); + } + }); + } + + @Override + public void onException(HttpServerExchange exchange, Sender sender, IOException exception) { + try { + exchange.endExchange(); + } finally { + IoUtils.safeClose(exchange.getConnection()); + } + } + }); + } + } + + private void sendException(HttpServerExchange exchange, + Throwable error) { + if (!(error instanceof HttpServerResponse rs)) { + exchange.setStatusCode(500); + exchange.getResponseSender().send(Objects.requireNonNullElse(error.getMessage(), "Unknown error"), StandardCharsets.UTF_8, new IoCallback() { + @Override + public void onComplete(HttpServerExchange exchange, Sender sender) { + IoCallback.END_EXCHANGE.onComplete(exchange, sender); + } + + @Override + public void onException(HttpServerExchange exchange, Sender sender, IOException exception) { + error.addSuppressed(exception); + IoCallback.END_EXCHANGE.onException(exchange, sender, exception); + } + }); + exchange.endExchange(); + } else { + sendResponse(exchange, rs); + } + } +} diff --git a/experimental/operaton-rest-undertow/src/main/java/io/koraframework/bpmn/operaton/rest/undertow/OperatonRestUndertowModule.java b/experimental/operaton-rest-undertow/src/main/java/io/koraframework/bpmn/operaton/rest/undertow/OperatonRestUndertowModule.java new file mode 100644 index 000000000..57ee8fb55 --- /dev/null +++ b/experimental/operaton-rest-undertow/src/main/java/io/koraframework/bpmn/operaton/rest/undertow/OperatonRestUndertowModule.java @@ -0,0 +1,34 @@ +package io.koraframework.bpmn.operaton.rest.undertow; + +import io.koraframework.application.graph.All; +import io.koraframework.application.graph.Lifecycle; +import io.koraframework.application.graph.ValueOf; +import io.koraframework.application.graph.Wrapped; +import io.koraframework.bpmn.operaton.rest.OperatonRest; +import io.koraframework.bpmn.operaton.rest.OperatonRestConfig; +import io.koraframework.bpmn.operaton.rest.OperatonRestModule; +import io.koraframework.bpmn.operaton.rest.telemetry.OperatonRestTelemetryFactory; +import io.koraframework.common.DefaultComponent; +import io.koraframework.common.Tag; +import io.koraframework.common.annotation.Root; +import io.undertow.server.HttpHandler; +import jakarta.ws.rs.core.Application; + +public interface OperatonRestUndertowModule extends OperatonRestModule { + + @Tag(OperatonRest.class) + @DefaultComponent + default Wrapped operatonRestUndertowHttpHandler(@Tag(OperatonRest.class) All applications, + OperatonRestConfig restConfig, + OperatonRestTelemetryFactory telemetryFactory) { + var telemetry = telemetryFactory.get(restConfig.telemetry()); + return new UndertowOperatonRestHttpHandler(applications, restConfig, telemetry); + } + + @Tag(OperatonRest.class) + @Root + default Lifecycle operatonRestUndertowHttpServer(@Tag(OperatonRest.class) ValueOf operatonHttpHandler, + ValueOf restConfig) { + return new UndertowOperatonHttpServer(restConfig, operatonHttpHandler); + } +} diff --git a/experimental/operaton-rest-undertow/src/main/java/io/koraframework/bpmn/operaton/rest/undertow/UndertowCorsFilter.java b/experimental/operaton-rest-undertow/src/main/java/io/koraframework/bpmn/operaton/rest/undertow/UndertowCorsFilter.java new file mode 100644 index 000000000..fb217ec67 --- /dev/null +++ b/experimental/operaton-rest-undertow/src/main/java/io/koraframework/bpmn/operaton/rest/undertow/UndertowCorsFilter.java @@ -0,0 +1,145 @@ +package io.koraframework.bpmn.operaton.rest.undertow; + +import io.undertow.server.HttpHandler; +import io.undertow.server.HttpServerExchange; +import io.undertow.util.HeaderValues; +import io.undertow.util.HttpString; +import org.jspecify.annotations.Nullable; +import io.koraframework.bpmn.operaton.rest.OperatonRestConfig; + +public class UndertowCorsFilter implements HttpHandler { + + /** + * The main CORS header indicating if cross-origin access is allowed. + * + *

If it's value is equal to the requesting origin, cross-origin access from that origin is allowed. + * If it differs, cross-origin access is denied. "*" allows all resources, but is only valid for requests that + * do not include credentials (Authorization header, session cookie).

+ * + *

This filter simply echoes the origin of the request if the request was allowed by the + * selected policy class, because this is valid under all circumstances.

+ * + * @see Access-Control-Allow_Origin (MDN) + */ + public static final String ACCESS_CONTROL_ALLOW_ORIGIN = "Access-Control-Allow-Origin"; + + /** + * Indicates whether cross-origin access with credentials (Authorization header, cookies) is allowed. + * + * @see Access-Control-Allow-Credentials (MDN) + */ + public static final String ACCESS_CONTROL_ALLOW_CREDENTIALS = "Access-Control-Allow-Credentials"; + + /** + * Used in response to a preflight request to indicate which HTTP headers can be used + * when making the actual request. + * + * @see Access-Control-Allow-Headers (MDN) + */ + public static final String ACCESS_CONTROL_ALLOW_HEADERS = "Access-Control-Allow-Headers"; + + /** + * Used in response to a preflight request to indicate which HTTP methods can be used when making the actual request. + * + * @see Access-Control-Allow-Methods (MDN) + */ + public static final String ACCESS_CONTROL_ALLOW_METHODS = "Access-Control-Allow-Methods"; + + /** + * Lets a server whitelist headers that browsers are allowed to access. + * + *

Default headers allowed without needing to be exposed: + * Cache-Control, Content-Language, Content-Type, Expires, Last-Modified, Pragma

+ * + * @see Access-Control-Expose-Headers (MDN) + */ + public static final String ACCESS_CONTROL_EXPOSE_HEADERS = "Access-Control-Expose-Headers"; + + /** + * The max age header determines how long browsers are allowed to cache the CORS responses. + * + * @see Access-Control-Max-Age (MDN) + */ + public static final String ACCESS_CONTROL_MAX_AGE = "Access-Control-Max-Age"; + + private final HttpHandler next; + private final OperatonRestConfig config; + + private final String allowMethods; + private final String allowHeaders; + private final String allowCredentials; + @Nullable + private final String exposeHeaders; + private final String maxAge; + + public UndertowCorsFilter(HttpHandler next, OperatonRestConfig config) { + this.next = next; + this.config = config; + this.allowMethods = String.join(",", config.cors().allowMethods()); + this.allowHeaders = String.join(",", config.cors().allowHeaders()); + this.allowCredentials = String.valueOf(config.cors().allowCredentials()); + this.maxAge = String.valueOf(config.cors().maxAge().getSeconds()); + this.exposeHeaders = config.cors().exposeHeaders().isEmpty() + ? null + : String.join(",", config.cors().exposeHeaders()); + } + + @Override + public void handleRequest(HttpServerExchange exchange) throws Exception { + String origin = getOrigin(exchange); + applyCorsPolicy(exchange, origin); + next.handleRequest(exchange); + } + + protected void applyCorsPolicy(HttpServerExchange exchange, @Nullable String origin) { + if (!hasHeader(exchange, ACCESS_CONTROL_ALLOW_ORIGIN)) { + final String allowOrigin; + if (config.cors().allowOrigin() != null) { + allowOrigin = config.cors().allowOrigin(); + } else if (origin != null) { + allowOrigin = origin; + } else { + allowOrigin = "*"; + } + + addHeader(exchange, ACCESS_CONTROL_ALLOW_ORIGIN, allowOrigin); + } + if (!hasHeader(exchange, ACCESS_CONTROL_ALLOW_HEADERS)) { + addHeader(exchange, ACCESS_CONTROL_ALLOW_HEADERS, allowHeaders); + } + if (!hasHeader(exchange, ACCESS_CONTROL_ALLOW_CREDENTIALS)) { + addHeader(exchange, ACCESS_CONTROL_ALLOW_CREDENTIALS, allowCredentials); + } + if (!hasHeader(exchange, ACCESS_CONTROL_ALLOW_METHODS)) { + addHeader(exchange, ACCESS_CONTROL_ALLOW_METHODS, allowMethods); + } + if (exposeHeaders != null) { + if (!hasHeader(exchange, ACCESS_CONTROL_EXPOSE_HEADERS)) { + addHeader(exchange, ACCESS_CONTROL_EXPOSE_HEADERS, exposeHeaders); + } + } + if (!hasHeader(exchange, ACCESS_CONTROL_MAX_AGE)) { + addHeader(exchange, ACCESS_CONTROL_MAX_AGE, maxAge); + } + } + + @Nullable + protected String getOrigin(HttpServerExchange exchange) { + HeaderValues headers = exchange.getRequestHeaders().get("Origin"); + return headers == null ? null : headers.peekFirst(); + } + + protected boolean hasHeader(HttpServerExchange exchange, String name) { + return exchange.getResponseHeaders().get(name) != null; + } + + protected void addHeader(HttpServerExchange exchange, String name, String value) { + exchange.getResponseHeaders().add(HttpString.tryFromString(name), value); + } +} diff --git a/experimental/operaton-rest-undertow/src/main/java/io/koraframework/bpmn/operaton/rest/undertow/UndertowOperatonHttpServer.java b/experimental/operaton-rest-undertow/src/main/java/io/koraframework/bpmn/operaton/rest/undertow/UndertowOperatonHttpServer.java new file mode 100644 index 000000000..b8a6fdd44 --- /dev/null +++ b/experimental/operaton-rest-undertow/src/main/java/io/koraframework/bpmn/operaton/rest/undertow/UndertowOperatonHttpServer.java @@ -0,0 +1,103 @@ +package io.koraframework.bpmn.operaton.rest.undertow; + +import io.undertow.Undertow; +import io.undertow.server.HttpHandler; +import io.undertow.server.handlers.GracefulShutdownHandler; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import io.koraframework.application.graph.Lifecycle; +import io.koraframework.application.graph.ValueOf; +import io.koraframework.bpmn.operaton.rest.OperatonRestConfig; +import io.koraframework.common.readiness.ReadinessProbe; +import io.koraframework.common.readiness.ReadinessProbeFailure; +import io.koraframework.common.util.TimeUtils; + +import java.net.BindException; +import java.time.Duration; +import java.util.concurrent.atomic.AtomicReference; + +final class UndertowOperatonHttpServer implements Lifecycle, ReadinessProbe { + + private static final Logger logger = LoggerFactory.getLogger(UndertowOperatonHttpServer.class); + + private final AtomicReference state = new AtomicReference<>(HttpServerState.INIT); + private final ValueOf config; + private final GracefulShutdownHandler gracefulShutdown; + + private volatile Undertow undertow; + + UndertowOperatonHttpServer(ValueOf config, ValueOf publicApiHandler) { + this.config = config; + + this.gracefulShutdown = new GracefulShutdownHandler(exch -> { + try { + publicApiHandler.get().handleRequest(exch); + } catch (Exception e) { + exch.setStatusCode(500); + exch.endExchange(); + e.printStackTrace(); + } + }); + } + + @Override + public void init() { + if (this.config.get().enabled()) { + try { + logger.debug("Operaton HTTP Server (Undertow) starting..."); + final long started = TimeUtils.started(); + this.gracefulShutdown.start(); + this.undertow = Undertow.builder() + .addHttpListener(this.config.get().port(), "0.0.0.0", this.gracefulShutdown) + .build(); + + this.undertow.start(); + this.state.set(HttpServerState.RUN); + logger.info("Operaton HTTP Server (Undertow) started in {}", TimeUtils.tookForLogging(started)); + } catch (Exception e) { + if (e.getCause() instanceof BindException be) { + throw new RuntimeException("Operaton HTTP Server (Undertow) failed to start, cause port '%s' is already in use" + .formatted(config.get().port()), be); + } else { + throw new RuntimeException("Operaton HTTP Server (Undertow) failed to start on port '%s', due to: %s" + .formatted(config.get().port(), e.getMessage()), e); + } + } + } + } + + @Override + public void release() { + if (this.undertow != null) { + this.state.set(HttpServerState.SHUTDOWN); + final long started = TimeUtils.started(); + this.gracefulShutdown.shutdown(); + final Duration shutdownAwait = this.config.get().shutdownWait(); + try { + logger.debug("Operaton HTTP Server (Undertow) awaiting graceful shutdown..."); + if (!this.gracefulShutdown.awaitShutdown(shutdownAwait.toMillis())) { + logger.warn("Operaton HTTP Server (Undertow) failed completing graceful shutdown in {}", shutdownAwait); + } + } catch (InterruptedException e) { + logger.warn("Operaton HTTP Server (Undertow) failed completing graceful shutdown in {}", shutdownAwait, e); + } + + this.undertow.stop(); + this.undertow = null; + logger.info("Operaton HTTP Server (Undertow) stopped in {}", TimeUtils.tookForLogging(started)); + } + } + + @Override + public ReadinessProbeFailure probe() { + return switch (this.state.get()) { + case INIT -> new ReadinessProbeFailure("Operaton HTTP Server (Undertow) init"); + case RUN -> null; + case SHUTDOWN -> new ReadinessProbeFailure("OperatonHTTP Server (Undertow) shutdown"); + }; + } + + private enum HttpServerState { + INIT, RUN, SHUTDOWN + } +} diff --git a/experimental/operaton-rest-undertow/src/main/java/io/koraframework/bpmn/operaton/rest/undertow/UndertowOperatonRestHttpHandler.java b/experimental/operaton-rest-undertow/src/main/java/io/koraframework/bpmn/operaton/rest/undertow/UndertowOperatonRestHttpHandler.java new file mode 100644 index 000000000..6827bf5f6 --- /dev/null +++ b/experimental/operaton-rest-undertow/src/main/java/io/koraframework/bpmn/operaton/rest/undertow/UndertowOperatonRestHttpHandler.java @@ -0,0 +1,565 @@ +package io.koraframework.bpmn.operaton.rest.undertow; + +import io.koraframework.application.graph.Lifecycle; +import io.koraframework.application.graph.Wrapped; +import io.koraframework.bpmn.operaton.rest.OperatonRestConfig; +import io.koraframework.bpmn.operaton.rest.telemetry.OperatonRestTelemetry; +import io.koraframework.bpmn.operaton.rest.undertow.UndertowPathMatcher.HttpMethodPath; +import io.koraframework.common.telemetry.Observation; +import io.koraframework.common.telemetry.OpentelemetryContext; +import io.koraframework.common.util.TimeUtils; +import io.koraframework.http.server.undertow.RequestProcessingHttpHandler; +import io.koraframework.http.server.undertow.UndertowContext; +import io.koraframework.http.server.undertow.VirtualThreadHttpHandler; +import io.opentelemetry.api.trace.propagation.W3CTraceContextPropagator; +import io.undertow.server.HttpHandler; +import io.undertow.server.handlers.PathHandler; +import io.undertow.servlet.api.DeploymentManager; +import io.undertow.servlet.api.ServletContainer; +import io.undertow.util.AttachmentKey; +import jakarta.ws.rs.core.Application; +import org.jboss.resteasy.core.ResteasyDeploymentImpl; +import org.jboss.resteasy.plugins.server.undertow.UndertowJaxrsServer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.slf4j.MDC; + +import java.util.*; +import java.util.concurrent.ExecutorService; + +final class UndertowOperatonRestHttpHandler implements Lifecycle, Wrapped { + + private static final Logger logger = LoggerFactory.getLogger(UndertowOperatonRestHttpHandler.class); + private final AttachmentKey executorServiceAttachmentKey = AttachmentKey.create(ExecutorService.class); + + private final Application application; + private final OperatonRestConfig restConfig; + private final OperatonRestTelemetry telemetry; + + private volatile DeploymentManager deploymentManager; + private volatile HttpHandler realhttpHandler; + + UndertowOperatonRestHttpHandler(Iterable applications, + OperatonRestConfig restConfig, + OperatonRestTelemetry telemetry) { + this.telemetry = telemetry; + var classes = new HashSet>(); + var singletons = new HashSet(); + var props = new HashMap(); + for (var app : applications) { + classes.addAll(app.getClasses()); + singletons.addAll(app.getSingletons()); + props.putAll(app.getProperties()); + } + + this.application = new Application() { + @Override + public Set> getClasses() { + return classes; + } + + @Override + public Set getSingletons() { + return singletons; + } + + @Override + public Map getProperties() { + return props; + } + }; + + this.restConfig = restConfig; + } + + @Override + public HttpHandler value() { + if (restConfig.cors().enabled()) { + return new UndertowCorsFilter(realhttpHandler, restConfig); + } else { + return realhttpHandler; + } + } + + @Override + public void init() throws Exception { + logger.debug("Operaton Rest Handler (Undertow) configuring..."); + var started = TimeUtils.started(); + + var deployment = new ResteasyDeploymentImpl(); + deployment.setApplication(application); + deployment.start(); + + var root = new PathHandler(); + var container = ServletContainer.Factory.newInstance(); + + var server = new UndertowJaxrsServer(); + var di = server.undertowDeployment(deployment); + + var classLoader = UndertowOperatonRestHttpHandler.class.getClassLoader(); + di.setClassLoader(classLoader); + di.setContextPath(restConfig.path()); + di.setDeploymentName("ResteasyOperatonKora"); + deploymentManager = container.addDeployment(di); + deploymentManager.deploy(); + + var restPaths = getRestPaths(restConfig); + var restMatcher = new UndertowPathMatcher(restPaths); + + var restHandler = deploymentManager.start(); + root.addPrefixPath(restConfig.path(), exchange -> { + var rootCtx = W3CTraceContextPropagator.getInstance().extract(io.opentelemetry.context.Context.root(), exchange.getRequestHeaders(), RequestProcessingHttpHandler.HttpServerExchangeMapGetter.INSTANCE); + ScopedValue + .where(UndertowContext.VALUE, new UndertowContext(exchange)) + .where(io.koraframework.logging.common.MDC.VALUE, new io.koraframework.logging.common.MDC()) + .where(OpentelemetryContext.VALUE, rootCtx) + .call(() -> { + MDC.clear(); + var match = restMatcher.getMatch(exchange.getRequestMethod().toString(), exchange.getRequestPath()); + var pathTemplate = match == null ? null : match.pathTemplate(); + var pathParams = match == null ? Map.of() : match.pathParameters(); + var observation = this.telemetry.observe(exchange, pathTemplate); + exchange.addExchangeCompleteListener((e, nextListener) -> { + observation.observeResponseCode(e.getStatusCode()); + observation.end(); + nextListener.proceed(); + }); + observation.observeRequest(pathTemplate, pathParams); + var ctx = rootCtx.with(observation.span()); + W3CTraceContextPropagator.getInstance().inject( + ctx, + exchange.getResponseHeaders(), + RequestProcessingHttpHandler.HttpServerExchangeMapGetter.INSTANCE + ); + + ScopedValue + .where(OpentelemetryContext.VALUE, ctx) + .where(Observation.VALUE, observation) + .call(() -> { + try { + restHandler.handleRequest(exchange); + return null; + } catch (Throwable t) { + observation.observeError(t); + throw t; + } + }); + return null; + }); + }); + + root.addPrefixPath("/", new OpenApiHttpHandler(restConfig)); + this.realhttpHandler = new VirtualThreadHttpHandler("operaton-rest", root); + + logger.info("Operaton Rest Handler (Undertow) configured in {}", TimeUtils.tookForLogging(started)); + } + + @Override + public void release() throws Exception { + logger.debug("Operaton Rest Handler (Undertow) stopping..."); + final long started = TimeUtils.started(); + + deploymentManager.stop(); + + logger.info("Operaton Rest Handler (Undertow) stopped in {}", TimeUtils.tookForLogging(started)); + } + + private static List getRestPaths(OperatonRestConfig restConfig) { + List restPaths = new ArrayList<>(400); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/authorization")); + restPaths.add(new HttpMethodPath("OPTIONS", restConfig.path() + "/authorization")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/authorization/check")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/authorization/count")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/authorization/create")); + restPaths.add(new HttpMethodPath("DELETE", restConfig.path() + "/authorization/{id}")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/authorization/{id}")); + restPaths.add(new HttpMethodPath("OPTIONS", restConfig.path() + "/authorization/{id}")); + restPaths.add(new HttpMethodPath("PUT", restConfig.path() + "/authorization/{id}")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/batch")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/batch/count")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/batch/statistics")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/batch/statistics/count")); + restPaths.add(new HttpMethodPath("DELETE", restConfig.path() + "/batch/{id}")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/batch/{id}")); + restPaths.add(new HttpMethodPath("PUT", restConfig.path() + "/batch/{id}/suspended")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/condition")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/decision-definition")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/decision-definition/count")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/decision-definition/key/{key}")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/decision-definition/key/{key}/diagram")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/decision-definition/key/{key}/evaluate")); + restPaths.add(new HttpMethodPath("PUT", restConfig.path() + "/decision-definition/key/{key}/history-time-to-live")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/decision-definition/key/{key}/tenant-id/{tenant-id}")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/decision-definition/key/{key}/tenant-id/{tenant-id}/diagram")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/decision-definition/key/{key}/tenant-id/{tenant-id}/evaluate")); + restPaths.add(new HttpMethodPath("PUT", restConfig.path() + "/decision-definition/key/{key}/tenant-id/{tenant-id}/history-time-to-live")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/decision-definition/key/{key}/tenant-id/{tenant-id}/xml")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/decision-definition/key/{key}/xml")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/decision-definition/{id}")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/decision-definition/{id}/diagram")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/decision-definition/{id}/evaluate")); + restPaths.add(new HttpMethodPath("PUT", restConfig.path() + "/decision-definition/{id}/history-time-to-live")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/decision-definition/{id}/xml")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/decision-requirements-definition")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/decision-requirements-definition/count")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/decision-requirements-definition/key/{key}")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/decision-requirements-definition/key/{key}/diagram")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/decision-requirements-definition/key/{key}/tenant-id/{tenant-id}")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/decision-requirements-definition/key/{key}/tenant-id/{tenant-id}/diagram")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/decision-requirements-definition/key/{key}/tenant-id/{tenant-id}/xml")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/decision-requirements-definition/key/{key}/xml")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/decision-requirements-definition/{id}")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/decision-requirements-definition/{id}/diagram")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/decision-requirements-definition/{id}/xml")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/deployment")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/deployment/count")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/deployment/create")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/deployment/registered")); + restPaths.add(new HttpMethodPath("DELETE", restConfig.path() + "/deployment/{id}")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/deployment/{id}")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/deployment/{id}/redeploy")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/deployment/{id}/resources")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/deployment/{id}/resources/{resourceId}")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/deployment/{id}/resources/{resourceId}/data")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/engine")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/event-subscription")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/event-subscription/count")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/execution")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/execution")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/execution/count")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/execution/count")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/execution/{id}")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/execution/{id}/create-incident")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/execution/{id}/localVariables")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/execution/{id}/localVariables")); + restPaths.add(new HttpMethodPath("DELETE", restConfig.path() + "/execution/{id}/localVariables/{varName}")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/execution/{id}/localVariables/{varName}")); + restPaths.add(new HttpMethodPath("PUT", restConfig.path() + "/execution/{id}/localVariables/{varName}")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/execution/{id}/localVariables/{varName}/data")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/execution/{id}/localVariables/{varName}/data")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/execution/{id}/messageSubscriptions/{messageName}")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/execution/{id}/messageSubscriptions/{messageName}/trigger")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/execution/{id}/signal")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/external-task")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/external-task")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/external-task/count")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/external-task/count")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/external-task/fetchAndLock")); + restPaths.add(new HttpMethodPath("PUT", restConfig.path() + "/external-task/retries")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/external-task/retries-async")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/external-task/topic-names")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/external-task/{id}")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/external-task/{id}/bpmnError")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/external-task/{id}/complete")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/external-task/{id}/errorDetails")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/external-task/{id}/extendLock")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/external-task/{id}/failure")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/external-task/{id}/lock")); + restPaths.add(new HttpMethodPath("PUT", restConfig.path() + "/external-task/{id}/priority")); + restPaths.add(new HttpMethodPath("PUT", restConfig.path() + "/external-task/{id}/retries")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/external-task/{id}/unlock")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/filter")); + restPaths.add(new HttpMethodPath("OPTIONS", restConfig.path() + "/filter")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/filter/count")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/filter/create")); + restPaths.add(new HttpMethodPath("DELETE", restConfig.path() + "/filter/{id}")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/filter/{id}")); + restPaths.add(new HttpMethodPath("OPTIONS", restConfig.path() + "/filter/{id}")); + restPaths.add(new HttpMethodPath("PUT", restConfig.path() + "/filter/{id}")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/filter/{id}/count")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/filter/{id}/count")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/filter/{id}/list")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/filter/{id}/list")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/filter/{id}/singleResult")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/filter/{id}/singleResult")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/group")); + restPaths.add(new HttpMethodPath("OPTIONS", restConfig.path() + "/group")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/group")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/group/count")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/group/count")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/group/create")); + restPaths.add(new HttpMethodPath("DELETE", restConfig.path() + "/group/{id}")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/group/{id}")); + restPaths.add(new HttpMethodPath("OPTIONS", restConfig.path() + "/group/{id}")); + restPaths.add(new HttpMethodPath("PUT", restConfig.path() + "/group/{id}")); + restPaths.add(new HttpMethodPath("OPTIONS", restConfig.path() + "/group/{id}/members")); + restPaths.add(new HttpMethodPath("DELETE", restConfig.path() + "/group/{id}/members/{userId}")); + restPaths.add(new HttpMethodPath("PUT", restConfig.path() + "/group/{id}/members/{userId}")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/history/activity-instance")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/history/activity-instance")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/history/activity-instance/count")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/history/activity-instance/count")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/history/activity-instance/{id}")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/history/batch")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/history/batch/cleanable-batch-report")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/history/batch/cleanable-batch-report/count")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/history/batch/count")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/history/batch/set-removal-time")); + restPaths.add(new HttpMethodPath("DELETE", restConfig.path() + "/history/batch/{id}")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/history/batch/{id}")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/history/cleanup")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/history/cleanup/configuration")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/history/cleanup/job")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/history/cleanup/jobs")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/history/decision-definition/cleanable-decision-instance-report")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/history/decision-definition/cleanable-decision-instance-report/count")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/history/decision-instance")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/history/decision-instance/count")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/history/decision-instance/delete")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/history/decision-instance/set-removal-time")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/history/decision-instance/{id}")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/history/decision-requirements-definition/{id}/statistics")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/history/detail")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/history/detail")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/history/detail/count")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/history/detail/{id}")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/history/detail/{id}/data")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/history/external-task-log")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/history/external-task-log")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/history/external-task-log/count")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/history/external-task-log/count")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/history/external-task-log/{id}")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/history/external-task-log/{id}/error-details")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/history/identity-link-log")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/history/identity-link-log/count")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/history/incident")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/history/incident/count")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/history/job-log")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/history/job-log")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/history/job-log/count")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/history/job-log/count")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/history/job-log/{id}")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/history/job-log/{id}/stacktrace")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/history/process-definition/cleanable-process-instance-report")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/history/process-definition/cleanable-process-instance-report/count")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/history/process-definition/{id}/statistics")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/history/process-instance")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/history/process-instance")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/history/process-instance/count")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/history/process-instance/count")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/history/process-instance/delete")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/history/process-instance/report")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/history/process-instance/set-removal-time")); + restPaths.add(new HttpMethodPath("DELETE", restConfig.path() + "/history/process-instance/{id}")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/history/process-instance/{id}")); + restPaths.add(new HttpMethodPath("DELETE", restConfig.path() + "/history/process-instance/{id}/variable-instances")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/history/task")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/history/task")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/history/task/count")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/history/task/count")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/history/task/report")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/history/user-operation")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/history/user-operation/count")); + restPaths.add(new HttpMethodPath("PUT", restConfig.path() + "/history/user-operation/{operationId}/clear-annotation")); + restPaths.add(new HttpMethodPath("PUT", restConfig.path() + "/history/user-operation/{operationId}/set-annotation")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/history/variable-instance")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/history/variable-instance")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/history/variable-instance/count")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/history/variable-instance/count")); + restPaths.add(new HttpMethodPath("DELETE", restConfig.path() + "/history/variable-instance/{id}")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/history/variable-instance/{id}")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/history/variable-instance/{id}/data")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/identity/groups")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/identity/password-policy")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/identity/password-policy")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/identity/verify")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/incident")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/incident/count")); + restPaths.add(new HttpMethodPath("DELETE", restConfig.path() + "/incident/{id}")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/incident/{id}")); + restPaths.add(new HttpMethodPath("DELETE", restConfig.path() + "/incident/{id}/annotation")); + restPaths.add(new HttpMethodPath("PUT", restConfig.path() + "/incident/{id}/annotation")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/job")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/job")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/job-definition")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/job-definition")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/job-definition/count")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/job-definition/count")); + restPaths.add(new HttpMethodPath("PUT", restConfig.path() + "/job-definition/suspended")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/job-definition/{id}")); + restPaths.add(new HttpMethodPath("PUT", restConfig.path() + "/job-definition/{id}/jobPriority")); + restPaths.add(new HttpMethodPath("PUT", restConfig.path() + "/job-definition/{id}/retries")); + restPaths.add(new HttpMethodPath("PUT", restConfig.path() + "/job-definition/{id}/suspended")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/job/count")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/job/count")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/job/retries")); + restPaths.add(new HttpMethodPath("PUT", restConfig.path() + "/job/suspended")); + restPaths.add(new HttpMethodPath("DELETE", restConfig.path() + "/job/{id}")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/job/{id}")); + restPaths.add(new HttpMethodPath("PUT", restConfig.path() + "/job/{id}/duedate")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/job/{id}/duedate/recalculate")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/job/{id}/execute")); + restPaths.add(new HttpMethodPath("PUT", restConfig.path() + "/job/{id}/priority")); + restPaths.add(new HttpMethodPath("PUT", restConfig.path() + "/job/{id}/retries")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/job/{id}/stacktrace")); + restPaths.add(new HttpMethodPath("PUT", restConfig.path() + "/job/{id}/suspended")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/message")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/metrics")); + restPaths.add(new HttpMethodPath("DELETE", restConfig.path() + "/metrics/task-worker")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/metrics/{metrics-name}/sum")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/migration/execute")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/migration/executeAsync")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/migration/generate")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/migration/validate")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/modification/execute")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/modification/executeAsync")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/process-definition")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/process-definition/count")); + restPaths.add(new HttpMethodPath("DELETE", restConfig.path() + "/process-definition/key/{key}")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/process-definition/key/{key}")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/process-definition/key/{key}/deployed-start-form")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/process-definition/key/{key}/diagram")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/process-definition/key/{key}/form-variables")); + restPaths.add(new HttpMethodPath("PUT", restConfig.path() + "/process-definition/key/{key}/history-time-to-live")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/process-definition/key/{key}/rendered-form")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/process-definition/key/{key}/start")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/process-definition/key/{key}/startForm")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/process-definition/key/{key}/statistics")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/process-definition/key/{key}/submit-form")); + restPaths.add(new HttpMethodPath("PUT", restConfig.path() + "/process-definition/key/{key}/suspended")); + restPaths.add(new HttpMethodPath("DELETE", restConfig.path() + "/process-definition/key/{key}/tenant-id/{tenant-id}")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/process-definition/key/{key}/tenant-id/{tenant-id}")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/process-definition/key/{key}/tenant-id/{tenant-id}/deployed-start-form")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/process-definition/key/{key}/tenant-id/{tenant-id}/diagram")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/process-definition/key/{key}/tenant-id/{tenant-id}/form-variables")); + restPaths.add(new HttpMethodPath("PUT", restConfig.path() + "/process-definition/key/{key}/tenant-id/{tenant-id}/history-time-to-live")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/process-definition/key/{key}/tenant-id/{tenant-id}/rendered-form")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/process-definition/key/{key}/tenant-id/{tenant-id}/start")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/process-definition/key/{key}/tenant-id/{tenant-id}/startForm")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/process-definition/key/{key}/tenant-id/{tenant-id}/statistics")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/process-definition/key/{key}/tenant-id/{tenant-id}/submit-form")); + restPaths.add(new HttpMethodPath("PUT", restConfig.path() + "/process-definition/key/{key}/tenant-id/{tenant-id}/suspended")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/process-definition/key/{key}/tenant-id/{tenant-id}/xml")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/process-definition/key/{key}/xml")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/process-definition/statistics")); + restPaths.add(new HttpMethodPath("PUT", restConfig.path() + "/process-definition/suspended")); + restPaths.add(new HttpMethodPath("DELETE", restConfig.path() + "/process-definition/{id}")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/process-definition/{id}")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/process-definition/{id}/deployed-start-form")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/process-definition/{id}/diagram")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/process-definition/{id}/form-variables")); + restPaths.add(new HttpMethodPath("PUT", restConfig.path() + "/process-definition/{id}/history-time-to-live")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/process-definition/{id}/rendered-form")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/process-definition/{id}/restart")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/process-definition/{id}/restart-async")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/process-definition/{id}/start")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/process-definition/{id}/startForm")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/process-definition/{id}/static-called-process-definitions")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/process-definition/{id}/statistics")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/process-definition/{id}/submit-form")); + restPaths.add(new HttpMethodPath("PUT", restConfig.path() + "/process-definition/{id}/suspended")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/process-definition/{id}/xml")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/process-instance")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/process-instance")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/process-instance/count")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/process-instance/count")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/process-instance/delete")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/process-instance/delete-historic-query-based")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/process-instance/job-retries")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/process-instance/job-retries-historic-query-based")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/process-instance/message-async")); + restPaths.add(new HttpMethodPath("PUT", restConfig.path() + "/process-instance/suspended")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/process-instance/suspended-async")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/process-instance/variables-async")); + restPaths.add(new HttpMethodPath("DELETE", restConfig.path() + "/process-instance/{id}")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/process-instance/{id}")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/process-instance/{id}/activity-instances")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/process-instance/{id}/comment")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/process-instance/{id}/modification")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/process-instance/{id}/modification-async")); + restPaths.add(new HttpMethodPath("PUT", restConfig.path() + "/process-instance/{id}/suspended")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/process-instance/{id}/variables")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/process-instance/{id}/variables")); + restPaths.add(new HttpMethodPath("DELETE", restConfig.path() + "/process-instance/{id}/variables/{varName}")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/process-instance/{id}/variables/{varName}")); + restPaths.add(new HttpMethodPath("PUT", restConfig.path() + "/process-instance/{id}/variables/{varName}")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/process-instance/{id}/variables/{varName}/data")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/process-instance/{id}/variables/{varName}/data")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/schema/log")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/schema/log")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/signal")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/task")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/task")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/task/count")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/task/count")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/task/create")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/task/report/candidate-group-count")); + restPaths.add(new HttpMethodPath("DELETE", restConfig.path() + "/task/{id}")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/task/{id}")); + restPaths.add(new HttpMethodPath("PUT", restConfig.path() + "/task/{id}")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/task/{id}/assignee")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/task/{id}/attachment")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/task/{id}/attachment/create")); + restPaths.add(new HttpMethodPath("DELETE", restConfig.path() + "/task/{id}/attachment/{attachmentId}")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/task/{id}/attachment/{attachmentId}")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/task/{id}/attachment/{attachmentId}/data")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/task/{id}/bpmnError")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/task/{id}/bpmnEscalation")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/task/{id}/claim")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/task/{id}/comment")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/task/{id}/comment/create")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/task/{id}/comment/{commentId}")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/task/{id}/complete")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/task/{id}/delegate")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/task/{id}/deployed-form")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/task/{id}/form")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/task/{id}/form-variables")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/task/{id}/identity-links")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/task/{id}/identity-links")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/task/{id}/identity-links/delete")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/task/{id}/localVariables")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/task/{id}/localVariables")); + restPaths.add(new HttpMethodPath("DELETE", restConfig.path() + "/task/{id}/localVariables/{varName}")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/task/{id}/localVariables/{varName}")); + restPaths.add(new HttpMethodPath("PUT", restConfig.path() + "/task/{id}/localVariables/{varName}")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/task/{id}/localVariables/{varName}/data")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/task/{id}/localVariables/{varName}/data")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/task/{id}/rendered-form")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/task/{id}/resolve")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/task/{id}/submit-form")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/task/{id}/unclaim")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/task/{id}/variables")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/task/{id}/variables")); + restPaths.add(new HttpMethodPath("DELETE", restConfig.path() + "/task/{id}/variables/{varName}")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/task/{id}/variables/{varName}")); + restPaths.add(new HttpMethodPath("PUT", restConfig.path() + "/task/{id}/variables/{varName}")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/task/{id}/variables/{varName}/data")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/task/{id}/variables/{varName}/data")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/telemetry/configuration")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/telemetry/configuration")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/telemetry/data")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/tenant")); + restPaths.add(new HttpMethodPath("OPTIONS", restConfig.path() + "/tenant")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/tenant/count")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/tenant/create")); + restPaths.add(new HttpMethodPath("DELETE", restConfig.path() + "/tenant/{id}")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/tenant/{id}")); + restPaths.add(new HttpMethodPath("OPTIONS", restConfig.path() + "/tenant/{id}")); + restPaths.add(new HttpMethodPath("PUT", restConfig.path() + "/tenant/{id}")); + restPaths.add(new HttpMethodPath("OPTIONS", restConfig.path() + "/tenant/{id}/group-members")); + restPaths.add(new HttpMethodPath("DELETE", restConfig.path() + "/tenant/{id}/group-members/{groupId}")); + restPaths.add(new HttpMethodPath("PUT", restConfig.path() + "/tenant/{id}/group-members/{groupId}")); + restPaths.add(new HttpMethodPath("OPTIONS", restConfig.path() + "/tenant/{id}/user-members")); + restPaths.add(new HttpMethodPath("DELETE", restConfig.path() + "/tenant/{id}/user-members/{userId}")); + restPaths.add(new HttpMethodPath("PUT", restConfig.path() + "/tenant/{id}/user-members/{userId}")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/user")); + restPaths.add(new HttpMethodPath("OPTIONS", restConfig.path() + "/user")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/user/count")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/user/create")); + restPaths.add(new HttpMethodPath("DELETE", restConfig.path() + "/user/{id}")); + restPaths.add(new HttpMethodPath("OPTIONS", restConfig.path() + "/user/{id}")); + restPaths.add(new HttpMethodPath("PUT", restConfig.path() + "/user/{id}/credentials")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/user/{id}/profile")); + restPaths.add(new HttpMethodPath("PUT", restConfig.path() + "/user/{id}/profile")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/user/{id}/unlock")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/variable-instance")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/variable-instance")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/variable-instance/count")); + restPaths.add(new HttpMethodPath("POST", restConfig.path() + "/variable-instance/count")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/variable-instance/{id}")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/variable-instance/{id}/data")); + restPaths.add(new HttpMethodPath("GET", restConfig.path() + "/version")); + return restPaths; + } + +} diff --git a/experimental/operaton-rest-undertow/src/main/java/io/koraframework/bpmn/operaton/rest/undertow/UndertowPathMatcher.java b/experimental/operaton-rest-undertow/src/main/java/io/koraframework/bpmn/operaton/rest/undertow/UndertowPathMatcher.java new file mode 100644 index 000000000..72dd785aa --- /dev/null +++ b/experimental/operaton-rest-undertow/src/main/java/io/koraframework/bpmn/operaton/rest/undertow/UndertowPathMatcher.java @@ -0,0 +1,49 @@ +package io.koraframework.bpmn.operaton.rest.undertow; + +import org.jspecify.annotations.Nullable; +import io.koraframework.http.server.common.router.PathTemplateMatcher; + +import java.util.*; + +final class UndertowPathMatcher { + + private final Map> pathTemplateMatcher = new HashMap<>(); + + record HttpMethodPath(String method, String routeTemplate) {} + + UndertowPathMatcher(List methods) { + final PathTemplateMatcher> allMethodMatchers = new PathTemplateMatcher<>(); + for (var h : methods) { + var route = h.routeTemplate(); + var methodMatchers = pathTemplateMatcher.computeIfAbsent(h.method().toUpperCase(Locale.ROOT), k -> new PathTemplateMatcher<>()); + var oldValue = methodMatchers.add(route, route); + if (oldValue != null) { + throw new IllegalStateException("Can't add path template %s, matcher already contains an equivalent pattern %s".formatted(route, oldValue.getKey().templateString())); + } + + var otherMethods = new ArrayList<>(List.of(h.method())); + var oldAllMethodValue = allMethodMatchers.add(route, otherMethods); + if (oldAllMethodValue != null) { + otherMethods.addAll(oldAllMethodValue.getValue()); + } + } + } + + record Match(String method, String pathTemplate, Map pathParameters) {} + + @Nullable + Match getMatch(String method, String path) { + final Map templateParameters; + final @Nullable String routeTemplate; + + var methodMatchers = pathTemplateMatcher.get(method); + var pathTemplateMatch = methodMatchers == null ? null : methodMatchers.match(path); + if (pathTemplateMatch == null) { + return null; + } else { + templateParameters = pathTemplateMatch.parameters(); + routeTemplate = pathTemplateMatch.matchedTemplate(); + return new Match(method, routeTemplate, templateParameters); + } + } +} diff --git a/experimental/operaton-rest-undertow/src/main/resources/META-INF/services/org.operaton.bpm.engine.ProcessEngineProvider b/experimental/operaton-rest-undertow/src/main/resources/META-INF/services/org.operaton.bpm.engine.ProcessEngineProvider new file mode 100644 index 000000000..ddb4f0d65 --- /dev/null +++ b/experimental/operaton-rest-undertow/src/main/resources/META-INF/services/org.operaton.bpm.engine.ProcessEngineProvider @@ -0,0 +1 @@ +io.koraframework.bpmn.operaton.rest.KoraProcessEngineProvider diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index dcb6d14ad..323a4d65a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -18,6 +18,7 @@ opentelemetry = "1.54.0" undertow = "2.3.18.Final" resteasy = "6.2.12.Final" camunda7 = "7.24.0" +operaton = "2.1.1" zeebe = "8.9.8" junit = "6.0.1" guava = "33.3.1-jre" @@ -156,6 +157,9 @@ s3client-aws = { module = "software.amazon.awssdk:s3", version = "2.29.52" } camunda7-engine = { module = "org.camunda.bpm:camunda-engine", version.ref = "camunda7" } camunda7-rest-jakarta = { module = "org.camunda.bpm:camunda-engine-rest-jakarta", version.ref = "camunda7" } camunda7-openapi = { module = "org.camunda.bpm:camunda-engine-rest-openapi", version.ref = "camunda7" } +operaton-engine = { module = "org.operaton.bpm:operaton-engine", version.ref = "operaton" } +operaton-rest-jakarta = { module = "org.operaton.bpm:operaton-engine-rest-jakarta", version = "1.0.0-beta-3" } +operaton-openapi = { module = "org.operaton.bpm:operaton-engine-rest-openapi", version.ref = "operaton" } resteasy-undertow = { module = "org.jboss.resteasy:resteasy-undertow", version.ref = "resteasy" } resteasy-jackson = { module = "org.jboss.resteasy:resteasy-jackson2-provider", version.ref = "resteasy" } fasterxml-uuidgenerator = { module = "com.fasterxml.uuid:java-uuid-generator", version = "5.1.0" } diff --git a/internal/test-logging/src/main/resources/logback.xml b/internal/test-logging/src/main/resources/logback.xml index eb0d50feb..2056efe07 100644 --- a/internal/test-logging/src/main/resources/logback.xml +++ b/internal/test-logging/src/main/resources/logback.xml @@ -5,8 +5,12 @@ - - + + + + + + diff --git a/settings.gradle b/settings.gradle index c53c26283..1aa56a197 100644 --- a/settings.gradle +++ b/settings.gradle @@ -102,6 +102,8 @@ include( 'experimental:s3-client', 'experimental:camunda-engine-bpmn', 'experimental:camunda-rest-undertow', + 'experimental:operaton-engine-bpmn', + 'experimental:operaton-rest-undertow', 'experimental:camunda-zeebe-worker', 'experimental:camunda-zeebe-worker-annotation-processor', 'experimental:camunda-zeebe-worker-symbol-processor',