diff --git a/.circleci/config.yml b/.circleci/config.yml
index 0ef02745..fe4c371c 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -42,7 +42,7 @@ jobs:
executor: java17
steps:
- run_maven:
- maven_subcommand: test
+ maven_subcommand: verify
- slack/notify:
event: fail
diff --git a/checkstyle.xml b/checkstyle.xml
index 76f872ed..f5e7412d 100644
--- a/checkstyle.xml
+++ b/checkstyle.xml
@@ -181,8 +181,8 @@
-->
-
+
+ org.jacoco
+ jacoco-maven-plugin
+ 0.8.8
+
+
+ pre-unit-test
+
+ prepare-agent
+
+
+ jaCoCoArgLine
+
+
+
+ unit-test-check
+
+ check
+
+
+
+ ${coverage.haltOnFailure}
+
+
+ BUNDLE
+
+
+ INSTRUCTION
+ COVEREDRATIO
+ ${coverage.instructionCoveredRatioMinimum}
+
+
+
+
+
+
+
+ post-unit-test
+ verify
+
+ report
+
+
+
+
+
+ exec-maven-plugin
+ org.codehaus.mojo
+ 3.0.0
+
+
+ test-coverage-summary
+ verify
+
+ exec
+
+
+ sh
+
+ -c
+
+ /tmp/$$.headers
+xpath -q -e '/html/body/table/tfoot/tr[1]/td/text()' target/site/jacoco/index.html > /tmp/$$.values
+echo
+echo "Jacoco coverage summary report:"
+echo " See also target/site/jacoco/index.html"
+echo " and https://www.jacoco.org/jacoco/trunk/doc/counters.html"
+echo "------------------------------------------------------------"
+paste /tmp/$$.headers /tmp/$$.values | tail +2 | awk -v FS='\t' '{printf("%-20s %s\n",$1,$2)}'
+rm /tmp/$$.headers /tmp/$$.values
+ ]]>
+
+
+
+
+
+
diff --git a/src/main/java/com/kingsrook/qqq/backend/javalin/QJavalinImplementation.java b/src/main/java/com/kingsrook/qqq/backend/javalin/QJavalinImplementation.java
index de2d2dff..83e1f65d 100644
--- a/src/main/java/com/kingsrook/qqq/backend/javalin/QJavalinImplementation.java
+++ b/src/main/java/com/kingsrook/qqq/backend/javalin/QJavalinImplementation.java
@@ -29,10 +29,9 @@ import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
-import java.util.concurrent.CompletableFuture;
-import java.util.concurrent.CompletionException;
+import java.util.Optional;
+import java.util.UUID;
import java.util.concurrent.TimeUnit;
-import java.util.concurrent.TimeoutException;
import com.kingsrook.qqq.backend.core.actions.DeleteAction;
import com.kingsrook.qqq.backend.core.actions.InsertAction;
import com.kingsrook.qqq.backend.core.actions.MetaDataAction;
@@ -41,11 +40,16 @@ import com.kingsrook.qqq.backend.core.actions.QueryAction;
import com.kingsrook.qqq.backend.core.actions.RunProcessAction;
import com.kingsrook.qqq.backend.core.actions.TableMetaDataAction;
import com.kingsrook.qqq.backend.core.actions.UpdateAction;
+import com.kingsrook.qqq.backend.core.actions.async.AsyncJobManager;
+import com.kingsrook.qqq.backend.core.actions.async.AsyncJobState;
+import com.kingsrook.qqq.backend.core.actions.async.AsyncJobStatus;
+import com.kingsrook.qqq.backend.core.actions.async.JobGoingAsyncException;
import com.kingsrook.qqq.backend.core.adapters.QInstanceAdapter;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QModuleDispatchException;
import com.kingsrook.qqq.backend.core.exceptions.QNotFoundException;
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
+import com.kingsrook.qqq.backend.core.exceptions.QValueException;
import com.kingsrook.qqq.backend.core.model.actions.AbstractQRequest;
import com.kingsrook.qqq.backend.core.model.actions.delete.DeleteRequest;
import com.kingsrook.qqq.backend.core.model.actions.delete.DeleteResult;
@@ -57,6 +61,7 @@ import com.kingsrook.qqq.backend.core.model.actions.metadata.process.ProcessMeta
import com.kingsrook.qqq.backend.core.model.actions.metadata.process.ProcessMetaDataResult;
import com.kingsrook.qqq.backend.core.model.actions.metadata.table.TableMetaDataRequest;
import com.kingsrook.qqq.backend.core.model.actions.metadata.table.TableMetaDataResult;
+import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessState;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessRequest;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessResult;
import com.kingsrook.qqq.backend.core.model.actions.query.QCriteriaOperator;
@@ -73,10 +78,13 @@ import com.kingsrook.qqq.backend.core.model.metadata.QTableMetaData;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.modules.QAuthenticationModuleDispatcher;
import com.kingsrook.qqq.backend.core.modules.interfaces.QAuthenticationModuleInterface;
+import com.kingsrook.qqq.backend.core.state.StateType;
+import com.kingsrook.qqq.backend.core.state.UUIDAndTypeStateKey;
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
import com.kingsrook.qqq.backend.core.utils.ExceptionUtils;
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
+import com.kingsrook.qqq.backend.core.utils.ValueUtils;
import io.javalin.Javalin;
import io.javalin.apibuilder.EndpointGroup;
import io.javalin.http.Context;
@@ -105,7 +113,9 @@ public class QJavalinImplementation
private static QInstance qInstance;
- private static int PORT = 8001;
+ private static int DEFAULT_PORT = 8001;
+
+ private static int ASYNC_STEP_TIMEOUT_MILLIS = 3_000;
@@ -118,7 +128,7 @@ public class QJavalinImplementation
// todo - parse args to look up metaData and prime instance
// qInstance.addBackend(QMetaDataProvider.getQBackend());
- new QJavalinImplementation(qInstance).startJavalinServer(PORT);
+ new QJavalinImplementation(qInstance).startJavalinServer(DEFAULT_PORT);
}
@@ -158,6 +168,26 @@ public class QJavalinImplementation
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ public static void setDefaultPort(int port)
+ {
+ QJavalinImplementation.DEFAULT_PORT = port;
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ public static void setAsyncStepTimeoutMillis(int asyncStepTimeoutMillis)
+ {
+ QJavalinImplementation.ASYNC_STEP_TIMEOUT_MILLIS = asyncStepTimeoutMillis;
+ }
+
+
+
/*******************************************************************************
**
*******************************************************************************/
@@ -172,7 +202,7 @@ public class QJavalinImplementation
{
get("", QJavalinImplementation::tableMetaData);
});
- path("/process/:process", () ->
+ path("/process/:processName", () ->
{
get("", QJavalinImplementation::processMetaData);
});
@@ -195,10 +225,16 @@ public class QJavalinImplementation
});
path("/processes", () ->
{
- path("/:process", () ->
+ path("/:processName", () ->
{
get("/init", QJavalinImplementation::processInit);
- get("/step", QJavalinImplementation::processStep);
+ post("/init", QJavalinImplementation::processInit);
+
+ path("/:processUUID", () ->
+ {
+ post("/step/:step", QJavalinImplementation::processStep);
+ get("/status/:jobUUID", QJavalinImplementation::processStatus);
+ });
});
});
});
@@ -481,7 +517,7 @@ public class QJavalinImplementation
{
ProcessMetaDataRequest processMetaDataRequest = new ProcessMetaDataRequest(qInstance);
setupSession(context, processMetaDataRequest);
- processMetaDataRequest.setProcessName(context.pathParam("process"));
+ processMetaDataRequest.setProcessName(context.pathParam("processName"));
ProcessMetaDataAction processMetaDataAction = new ProcessMetaDataAction();
ProcessMetaDataResult processMetaDataResult = processMetaDataAction.execute(processMetaDataRequest);
@@ -506,7 +542,7 @@ public class QJavalinImplementation
if(userFacingException instanceof QNotFoundException)
{
context.status(HttpStatus.NOT_FOUND_404)
- .result("{\"error\":\"" + e.getMessage() + "\"}");
+ .result("{\"error\":\"" + userFacingException.getMessage() + "\"}");
}
else
{
@@ -527,15 +563,15 @@ public class QJavalinImplementation
/*******************************************************************************
** Returns Integer if context has a valid int query parameter by the given name,
- * Returns null if no param (or empty value).
- * Throws NumberFormatException for malformed numbers.
+ ** Returns null if no param (or empty value).
+ ** Throws QValueException for malformed numbers.
*******************************************************************************/
- private static Integer integerQueryParam(Context context, String name) throws NumberFormatException
+ private static Integer integerQueryParam(Context context, String name) throws QValueException
{
String value = context.queryParam(name);
if(StringUtils.hasContent(value))
{
- return (Integer.parseInt(value));
+ return (ValueUtils.getValueAsInteger(value));
}
return (null);
@@ -547,7 +583,7 @@ public class QJavalinImplementation
** Returns String if context has a valid query parameter by the given name,
* Returns null if no param (or empty value).
*******************************************************************************/
- private static String stringQueryParam(Context context, String name) throws NumberFormatException
+ private static String stringQueryParam(Context context, String name)
{
String value = context.queryParam(name);
if(StringUtils.hasContent(value))
@@ -566,15 +602,138 @@ public class QJavalinImplementation
*******************************************************************************/
private static void processInit(Context context) throws QException
{
+ doProcessInitOrStep(context, null, null);
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ private static void doProcessInitOrStep(Context context, String processUUID, String startAfterStep) throws QModuleDispatchException
+ {
+ if(processUUID == null)
+ {
+ processUUID = UUID.randomUUID().toString();
+ }
+
RunProcessRequest runProcessRequest = new RunProcessRequest(qInstance);
setupSession(context, runProcessRequest);
- runProcessRequest.setProcessName(context.pathParam("process"));
+ runProcessRequest.setProcessName(context.pathParam("processName"));
runProcessRequest.setCallback(new QJavalinProcessCallback());
+ runProcessRequest.setBackendOnly(true);
+ runProcessRequest.setProcessUUID(processUUID);
+ runProcessRequest.setStartAfterStep(startAfterStep);
+ populateRunProcessRequestWithValuesFromContext(context, runProcessRequest);
- /////////////////////////////////////////////////////////////////////////////////////
- // take values from query-string params, and put them into the run process request //
- // todo - better from POST body, or with a "field-" type of prefix?? //
- /////////////////////////////////////////////////////////////////////////////////////
+ LOG.info(startAfterStep == null ? "Initiating process [" + runProcessRequest.getProcessName() + "] [" + processUUID + "]"
+ : "Resuming process [" + runProcessRequest.getProcessName() + "] [" + processUUID + "] after step [" + startAfterStep + "]");
+
+ Map resultForCaller = new HashMap<>();
+ resultForCaller.put("processUUID", processUUID);
+
+ try
+ {
+ ////////////////////////////////////////
+ // run the process as an async action //
+ ////////////////////////////////////////
+ Integer timeout = getTimeoutMillis(context);
+ RunProcessResult runProcessResult = new AsyncJobManager().startJob(timeout, TimeUnit.MILLISECONDS, (callback) ->
+ {
+ runProcessRequest.setAsyncJobCallback(callback);
+ return (new RunProcessAction().execute(runProcessRequest));
+ });
+
+ LOG.info("Process result error? " + runProcessResult.getException());
+ for(QFieldMetaData outputField : qInstance.getProcess(runProcessRequest.getProcessName()).getOutputFields())
+ {
+ LOG.info("Process result output value: " + outputField.getName() + ": " + runProcessResult.getValues().get(outputField.getName()));
+ }
+ serializeRunProcessResultForCaller(resultForCaller, runProcessResult);
+ }
+ catch(JobGoingAsyncException jgae)
+ {
+ resultForCaller.put("jobUUID", jgae.getJobUUID());
+ }
+ catch(Exception e)
+ {
+ //////////////////////////////////////////////////////////////////////////////
+ // our other actions in here would do: handleException(context, e); //
+ // which would return a 500 to the client. //
+ // but - other process-step actions, they always return a 200, just with an //
+ // optional error message - so - keep all of the processes consistent. //
+ //////////////////////////////////////////////////////////////////////////////
+ serializeRunProcessExceptionForCaller(resultForCaller, e);
+ }
+
+ context.result(JsonUtils.toJson(resultForCaller));
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ private static Integer getTimeoutMillis(Context context)
+ {
+ Integer timeout = integerQueryParam(context, "_qStepTimeoutMillis");
+ if(timeout == null)
+ {
+ timeout = ASYNC_STEP_TIMEOUT_MILLIS;
+ }
+ return timeout;
+ }
+
+
+
+ /*******************************************************************************
+ ** Whether a step finished synchronously or asynchronously, return its data
+ ** to the caller the same way.
+ *******************************************************************************/
+ private static void serializeRunProcessResultForCaller(Map resultForCaller, RunProcessResult runProcessResult)
+ {
+ if(runProcessResult.getException().isPresent())
+ {
+ ////////////////////////////////////////////////////////////////
+ // per code coverage, this path may never actually get hit... //
+ ////////////////////////////////////////////////////////////////
+ serializeRunProcessExceptionForCaller(resultForCaller, runProcessResult.getException().get());
+ }
+ resultForCaller.put("values", runProcessResult.getValues());
+ runProcessResult.getProcessState().getNextStepName().ifPresent(lastStep -> resultForCaller.put("nextStep", lastStep));
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ private static void serializeRunProcessExceptionForCaller(Map resultForCaller, Exception exception)
+ {
+ QUserFacingException userFacingException = ExceptionUtils.findClassInRootChain(exception, QUserFacingException.class);
+
+ if(userFacingException != null)
+ {
+ LOG.info("User-facing exception in process", userFacingException);
+ resultForCaller.put("error", userFacingException.getMessage()); // todo - put this somewhere else (make error an object w/ user-facing and/or other error?)
+ }
+ else
+ {
+ Throwable rootException = ExceptionUtils.getRootException(exception);
+ LOG.warn("Uncaught Exception in process", exception);
+ resultForCaller.put("error", "Original error message: " + rootException.getMessage());
+ }
+ }
+
+
+
+ /*******************************************************************************
+ ** take values from query-string params, and put them into the run process request
+ ** todo - better from POST body, or with a "field-" type of prefix??
+ **
+ *******************************************************************************/
+ private static void populateRunProcessRequestWithValuesFromContext(Context context, RunProcessRequest runProcessRequest)
+ {
for(Map.Entry> queryParam : context.queryParamMap().entrySet())
{
String fieldName = queryParam.getKey();
@@ -584,61 +743,74 @@ public class QJavalinImplementation
runProcessRequest.addValue(fieldName, values.get(0));
}
}
-
- try
- {
- ////////////////////////////////////////////////
- // run the process //
- // todo - some "job id" to return to caller? //
- ////////////////////////////////////////////////
- CompletableFuture future = CompletableFuture.supplyAsync(() ->
- {
- try
- {
- LOG.info("Running process [" + runProcessRequest.getProcessName() + "]");
- RunProcessResult runProcessResult = new RunProcessAction().execute(runProcessRequest);
- LOG.info("Process result error? " + runProcessResult.getError());
- for(QFieldMetaData outputField : qInstance.getProcess(runProcessRequest.getProcessName()).getOutputFields())
- {
- LOG.info("Process result output value: " + outputField.getName() + ": " + runProcessResult.getValues().get(outputField.getName()));
- }
- return (runProcessResult);
- }
- catch(Exception e)
- {
- LOG.error("Error running future for process", e);
- throw (new CompletionException(e));
- }
- });
-
- Map resultForCaller = new HashMap<>();
- try
- {
- RunProcessResult runProcessResult = future.get(3, TimeUnit.SECONDS);
- resultForCaller.put("error", runProcessResult.getError());
- resultForCaller.put("values", runProcessResult.getValues());
- }
- catch(TimeoutException te)
- {
- resultForCaller.put("jobId", "Job is running asynchronously... job id available in a later version.");
- }
- context.result(JsonUtils.toJson(resultForCaller));
- }
- catch(Exception e)
- {
- handleException(context, e);
- }
}
/*******************************************************************************
- ** Run a step in a process (named in path param :process)
+ ** Run a step in a process (named in path param :processName)
**
*******************************************************************************/
- private static void processStep(Context context)
+ private static void processStep(Context context) throws QModuleDispatchException
{
-
+ String processUUID = context.pathParam("processUUID");
+ String lastStep = context.pathParam("step");
+ doProcessInitOrStep(context, processUUID, lastStep);
}
+
+
+ /*******************************************************************************
+ ** Get status for a currently running process (step)
+ *******************************************************************************/
+ private static void processStatus(Context context)
+ {
+ String processUUID = context.pathParam("processUUID");
+ String jobUUID = context.pathParam("jobUUID");
+
+ LOG.info("Request for status of job " + jobUUID);
+ Optional optionalJobStatus = new AsyncJobManager().getJobStatus(jobUUID);
+ if(optionalJobStatus.isEmpty())
+ {
+ handleException(context, new RuntimeException("Could not find status of process step job"));
+ }
+ else
+ {
+ Map resultForCaller = new HashMap<>();
+ AsyncJobStatus jobStatus = optionalJobStatus.get();
+
+ resultForCaller.put("jobStatus", jobStatus);
+ LOG.info("Job status is " + jobStatus.getState() + " for " + jobUUID);
+
+ if(jobStatus.getState().equals(AsyncJobState.COMPLETE))
+ {
+ ///////////////////////////////////////////////////////////////////////////////////////
+ // if the job is complete, get the process result from state provider, and return it //
+ // this output should look like it did if the job finished synchronously!! //
+ ///////////////////////////////////////////////////////////////////////////////////////
+ Optional processState = RunProcessAction.getStateProvider().get(ProcessState.class, new UUIDAndTypeStateKey(UUID.fromString(processUUID), StateType.PROCESS_STATUS));
+ if(processState.isPresent())
+ {
+ RunProcessResult runProcessResult = new RunProcessResult(processState.get());
+ serializeRunProcessResultForCaller(resultForCaller, runProcessResult);
+ }
+ else
+ {
+ handleException(context, new RuntimeException("Could not find process results"));
+ }
+ }
+ else if(jobStatus.getState().equals(AsyncJobState.ERROR))
+ {
+ ///////////////////////////////////////////////////////////////////////////////////////////////////////////
+ // if the job had an error (e.g., a process step threw), "nicely" serialize its exception for the caller //
+ ///////////////////////////////////////////////////////////////////////////////////////////////////////////
+ if(jobStatus.getCaughtException() != null)
+ {
+ serializeRunProcessExceptionForCaller(resultForCaller, jobStatus.getCaughtException());
+ }
+ }
+
+ context.result(JsonUtils.toJson(resultForCaller));
+ }
+ }
}
diff --git a/src/test/java/com/kingsrook/qqq/backend/javalin/QJavalinImplementationTest.java b/src/test/java/com/kingsrook/qqq/backend/javalin/QJavalinImplementationTest.java
index 88011374..860d607c 100644
--- a/src/test/java/com/kingsrook/qqq/backend/javalin/QJavalinImplementationTest.java
+++ b/src/test/java/com/kingsrook/qqq/backend/javalin/QJavalinImplementationTest.java
@@ -27,6 +27,7 @@ import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;
+import com.kingsrook.qqq.backend.core.actions.async.AsyncJobState;
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
import kong.unirest.HttpResponse;
import kong.unirest.Unirest;
@@ -37,6 +38,7 @@ import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -54,6 +56,9 @@ class QJavalinImplementationTest
private static final int PORT = 6262;
private static final String BASE_URL = "http://localhost:" + PORT;
+ private static final int MORE_THAN_TIMEOUT = 500;
+ private static final int LESS_THAN_TIMEOUT = 50;
+
/*******************************************************************************
@@ -64,6 +69,7 @@ class QJavalinImplementationTest
public static void beforeAll()
{
QJavalinImplementation qJavalinImplementation = new QJavalinImplementation(TestUtils.defineInstance());
+ QJavalinImplementation.setAsyncStepTimeoutMillis(250);
qJavalinImplementation.startJavalinServer(PORT);
}
@@ -364,10 +370,7 @@ class QJavalinImplementationTest
@Test
public void test_dataDelete() throws Exception
{
- HttpResponse response = Unirest.delete(BASE_URL + "/data/person/3")
- .header("Content-Type", "application/json")
- .asString();
-
+ HttpResponse response = Unirest.delete(BASE_URL + "/data/person/3").asString();
assertEquals(200, response.getStatus());
JSONObject jsonObject = JsonUtils.toJSONObject(response.getBody());
@@ -394,10 +397,7 @@ class QJavalinImplementationTest
@Test
public void test_processGreetInit()
{
- HttpResponse response = Unirest.get(BASE_URL + "/processes/greet/init")
- .header("Content-Type", "application/json")
- .asString();
-
+ HttpResponse response = Unirest.get(BASE_URL + "/processes/greet/init").asString();
assertEquals(200, response.getStatus());
JSONObject jsonObject = JsonUtils.toJSONObject(response.getBody());
assertNotNull(jsonObject);
@@ -413,14 +413,268 @@ class QJavalinImplementationTest
@Test
public void test_processGreetInitWithQueryValues()
{
- HttpResponse response = Unirest.get(BASE_URL + "/processes/greet/init?greetingPrefix=Hey&greetingSuffix=Jude")
- .header("Content-Type", "application/json")
- .asString();
-
+ HttpResponse response = Unirest.get(BASE_URL + "/processes/greet/init?greetingPrefix=Hey&greetingSuffix=Jude").asString();
assertEquals(200, response.getStatus());
JSONObject jsonObject = JsonUtils.toJSONObject(response.getBody());
assertNotNull(jsonObject);
assertEquals("Hey X Jude", jsonObject.getJSONObject("values").getString("outputMessage"));
}
+
+
+ /*******************************************************************************
+ ** test init'ing a process that goes async
+ **
+ *******************************************************************************/
+ @Test
+ public void test_processInitGoingAsync() throws InterruptedException
+ {
+ String processBasePath = BASE_URL + "/processes/" + TestUtils.PROCESS_NAME_SIMPLE_SLEEP;
+ HttpResponse response = Unirest.get(processBasePath + "/init?" + TestUtils.SleeperStep.FIELD_SLEEP_MILLIS + "=" + MORE_THAN_TIMEOUT).asString();
+
+ JSONObject jsonObject = assertProcessStepWentAsyncResponse(response);
+ String processUUID = jsonObject.getString("processUUID");
+ String jobUUID = jsonObject.getString("jobUUID");
+ assertNotNull(processUUID, "Process UUID should not be null.");
+ assertNotNull(jobUUID, "Job UUID should not be null");
+
+ /////////////////////////////////////////////
+ // request job status before sleep is done //
+ /////////////////////////////////////////////
+ response = Unirest.get(processBasePath + "/" + processUUID + "/status/" + jobUUID).asString();
+ jsonObject = assertProcessStepRunningResponse(response);
+
+ ///////////////////////////////////
+ // sleep, to let that job finish //
+ ///////////////////////////////////
+ Thread.sleep(MORE_THAN_TIMEOUT);
+
+ ////////////////////////////////////////////////////////
+ // request job status again, get back results instead //
+ ////////////////////////////////////////////////////////
+ response = Unirest.get(processBasePath + "/" + processUUID + "/status/" + jobUUID).asString();
+ jsonObject = assertProcessStepCompleteResponse(response);
+ }
+
+
+
+ /*******************************************************************************
+ ** test init'ing a process that does NOT goes async
+ **
+ *******************************************************************************/
+ @Test
+ public void test_processInitNotGoingAsync()
+ {
+ HttpResponse response = Unirest.post(BASE_URL + "/processes/" + TestUtils.PROCESS_NAME_SIMPLE_SLEEP + "/init?" + TestUtils.SleeperStep.FIELD_SLEEP_MILLIS + "=" + LESS_THAN_TIMEOUT)
+ .header("Content-Type", "application/json").asString();
+ assertProcessStepCompleteResponse(response);
+ }
+
+
+
+ /*******************************************************************************
+ ** test running a step a process that goes async
+ **
+ *******************************************************************************/
+ @Test
+ public void test_processStepGoingAsync() throws InterruptedException
+ {
+ /////////////////////////////////////////////
+ // first init the process, to get its UUID //
+ /////////////////////////////////////////////
+ String processBasePath = BASE_URL + "/processes/" + TestUtils.PROCESS_NAME_SLEEP_INTERACTIVE;
+ HttpResponse response = Unirest.post(processBasePath + "/init?" + TestUtils.SleeperStep.FIELD_SLEEP_MILLIS + "=" + MORE_THAN_TIMEOUT)
+ .header("Content-Type", "application/json").asString();
+
+ JSONObject jsonObject = assertProcessStepCompleteResponse(response);
+ String processUUID = jsonObject.getString("processUUID");
+ String nextStep = jsonObject.getString("nextStep");
+ assertNotNull(processUUID, "Process UUID should not be null.");
+ assertNotNull(nextStep, "There should be a next step");
+
+ ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+ // second, run the 'nextStep' (the backend step, that sleeps). run it with a long enough sleep so that it'll go async //
+ ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+ response = Unirest.post(processBasePath + "/" + processUUID + "/step/" + nextStep)
+ .header("Content-Type", "application/json").asString();
+
+ jsonObject = assertProcessStepWentAsyncResponse(response);
+ String jobUUID = jsonObject.getString("jobUUID");
+
+ ///////////////////////////////////
+ // sleep, to let that job finish //
+ ///////////////////////////////////
+ Thread.sleep(MORE_THAN_TIMEOUT);
+
+ ///////////////////////////////
+ // third, request job status //
+ ///////////////////////////////
+ response = Unirest.get(processBasePath + "/" + processUUID + "/status/" + jobUUID).asString();
+
+ jsonObject = assertProcessStepCompleteResponse(response);
+ String nextStep2 = jsonObject.getString("nextStep");
+ assertNotNull(nextStep2, "There be one more next step");
+ assertNotEquals(nextStep, nextStep2, "The next step should be different this time.");
+ }
+
+
+
+ /*******************************************************************************
+ ** test running a step a process that does NOT goes async
+ **
+ *******************************************************************************/
+ @Test
+ public void test_processStepNotGoingAsync()
+ {
+ /////////////////////////////////////////////
+ // first init the process, to get its UUID //
+ /////////////////////////////////////////////
+ String processBasePath = BASE_URL + "/processes/" + TestUtils.PROCESS_NAME_SLEEP_INTERACTIVE;
+ HttpResponse response = Unirest.post(processBasePath + "/init?" + TestUtils.SleeperStep.FIELD_SLEEP_MILLIS + "=" + LESS_THAN_TIMEOUT)
+ .header("Content-Type", "application/json").asString();
+
+ JSONObject jsonObject = assertProcessStepCompleteResponse(response);
+ String processUUID = jsonObject.getString("processUUID");
+ String nextStep = jsonObject.getString("nextStep");
+ assertNotNull(nextStep, "There should be a next step");
+
+ ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+ // second, run the 'nextStep' (the backend step, that sleeps). run it with a short enough sleep so that it won't go async //
+ ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+ response = Unirest.post(processBasePath + "/" + processUUID + "/step/" + nextStep)
+ .header("Content-Type", "application/json").asString();
+
+ jsonObject = assertProcessStepCompleteResponse(response);
+ String nextStep2 = jsonObject.getString("nextStep");
+ assertNotNull(nextStep2, "There be one more next step");
+ assertNotEquals(nextStep, nextStep2, "The next step should be different this time.");
+ }
+
+
+
+ /*******************************************************************************
+ ** test init'ing a process that goes async and then throws
+ **
+ *******************************************************************************/
+ @Test
+ public void test_processInitGoingAsyncThenThrowing() throws InterruptedException
+ {
+ String processBasePath = BASE_URL + "/processes/" + TestUtils.PROCESS_NAME_SIMPLE_THROW;
+ HttpResponse response = Unirest.get(processBasePath + "/init?" + TestUtils.ThrowerStep.FIELD_SLEEP_MILLIS + "=" + MORE_THAN_TIMEOUT).asString();
+
+ JSONObject jsonObject = assertProcessStepWentAsyncResponse(response);
+ String processUUID = jsonObject.getString("processUUID");
+ String jobUUID = jsonObject.getString("jobUUID");
+
+ /////////////////////////////////////////////
+ // request job status before sleep is done //
+ /////////////////////////////////////////////
+ response = Unirest.get(processBasePath + "/" + processUUID + "/status/" + jobUUID).asString();
+ jsonObject = assertProcessStepRunningResponse(response);
+
+ ///////////////////////////////////
+ // sleep, to let that job finish //
+ ///////////////////////////////////
+ Thread.sleep(MORE_THAN_TIMEOUT);
+
+ /////////////////////////////////////////////////////////////
+ // request job status again, get back error status instead //
+ /////////////////////////////////////////////////////////////
+ response = Unirest.get(processBasePath + "/" + processUUID + "/status/" + jobUUID).asString();
+ jsonObject = assertProcessStepErrorResponse(response);
+ }
+
+
+
+ /*******************************************************************************
+ ** test init'ing a process that does NOT goes async, but throws.
+ **
+ *******************************************************************************/
+ @Test
+ public void test_processInitNotGoingAsyncButThrowing()
+ {
+ HttpResponse response = Unirest.post(BASE_URL + "/processes/" + TestUtils.PROCESS_NAME_SIMPLE_THROW + "/init?" + TestUtils.ThrowerStep.FIELD_SLEEP_MILLIS + "=" + LESS_THAN_TIMEOUT)
+ .header("Content-Type", "application/json").asString();
+ assertProcessStepErrorResponse(response);
+ }
+
+
+
+ /*******************************************************************************
+ ** every time a process step (sync or async) has gone async, expect what the
+ ** response should look like
+ *******************************************************************************/
+ private JSONObject assertProcessStepWentAsyncResponse(HttpResponse response)
+ {
+ assertEquals(200, response.getStatus());
+ JSONObject jsonObject = JsonUtils.toJSONObject(response.getBody());
+
+ assertTrue(jsonObject.has("processUUID"), "Async-started response should have a processUUID");
+ assertTrue(jsonObject.has("jobUUID"), "Async-started response should have a jobUUID");
+
+ assertFalse(jsonObject.has("values"), "Async-started response should NOT have values");
+ assertFalse(jsonObject.has("error"), "Async-started response should NOT have error");
+
+ return (jsonObject);
+ }
+
+
+
+ /*******************************************************************************
+ ** every time a process step (sync or async) is still running, expect certain things
+ ** to be (and not to be) in the json response.
+ *******************************************************************************/
+ private JSONObject assertProcessStepRunningResponse(HttpResponse response)
+ {
+ assertEquals(200, response.getStatus());
+ JSONObject jsonObject = JsonUtils.toJSONObject(response.getBody());
+
+ assertTrue(jsonObject.has("jobStatus"), "Step Running response should have a jobStatus");
+
+ assertFalse(jsonObject.has("values"), "Step Running response should NOT have values");
+ assertFalse(jsonObject.has("error"), "Step Running response should NOT have error");
+
+ assertEquals(AsyncJobState.RUNNING.name(), jsonObject.getJSONObject("jobStatus").getString("state"));
+
+ return (jsonObject);
+ }
+
+
+
+ /*******************************************************************************
+ ** every time a process step (sync or async) completes, expect certain things
+ ** to be (and not to be) in the json response.
+ *******************************************************************************/
+ private JSONObject assertProcessStepCompleteResponse(HttpResponse response)
+ {
+ assertEquals(200, response.getStatus());
+ JSONObject jsonObject = JsonUtils.toJSONObject(response.getBody());
+
+ assertTrue(jsonObject.has("values"), "Step Complete response should have values");
+
+ assertFalse(jsonObject.has("jobUUID"), "Step Complete response should not have a jobUUID");
+ assertFalse(jsonObject.has("error"), "Step Complete response should not have an error");
+
+ return (jsonObject);
+ }
+
+
+
+ /*******************************************************************************
+ ** every time a process step (sync or async) has an error, expect certain things
+ ** to be (and not to be) in the json response.
+ *******************************************************************************/
+ private JSONObject assertProcessStepErrorResponse(HttpResponse response)
+ {
+ assertEquals(200, response.getStatus());
+ JSONObject jsonObject = JsonUtils.toJSONObject(response.getBody());
+
+ assertTrue(jsonObject.has("error"), "Step Error response should have an error");
+
+ assertFalse(jsonObject.has("jobUUID"), "Step Error response should not have a jobUUID");
+ assertFalse(jsonObject.has("values"), "Step Error response should not have values");
+
+ return (jsonObject);
+ }
+
}
diff --git a/src/test/java/com/kingsrook/qqq/backend/javalin/TestUtils.java b/src/test/java/com/kingsrook/qqq/backend/javalin/TestUtils.java
index f6dcf37a..5fccd572 100644
--- a/src/test/java/com/kingsrook/qqq/backend/javalin/TestUtils.java
+++ b/src/test/java/com/kingsrook/qqq/backend/javalin/TestUtils.java
@@ -25,7 +25,12 @@ package com.kingsrook.qqq.backend.javalin;
import java.io.InputStream;
import java.sql.Connection;
import java.util.List;
+import com.kingsrook.qqq.backend.core.exceptions.QException;
+import com.kingsrook.qqq.backend.core.exceptions.QValueException;
+import com.kingsrook.qqq.backend.core.interfaces.BackendStep;
import com.kingsrook.qqq.backend.core.interfaces.mock.MockBackendStep;
+import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepRequest;
+import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepResult;
import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.QCodeType;
@@ -54,6 +59,15 @@ import static junit.framework.Assert.assertNotNull;
public class TestUtils
{
public static final String PROCESS_NAME_GREET_PEOPLE_INTERACTIVE = "greetInteractive";
+ public static final String PROCESS_NAME_SIMPLE_SLEEP = "simpleSleep";
+ public static final String PROCESS_NAME_SIMPLE_THROW = "simpleThrow";
+ public static final String PROCESS_NAME_SLEEP_INTERACTIVE = "sleepInteractive";
+
+ public static final String STEP_NAME_SLEEPER = "sleeper";
+ public static final String STEP_NAME_THROWER = "thrower";
+
+ public static final String SCREEN_0 = "screen0";
+ public static final String SCREEN_1 = "screen1";
@@ -104,6 +118,9 @@ public class TestUtils
qInstance.addTable(defineTablePerson());
qInstance.addProcess(defineProcessGreetPeople());
qInstance.addProcess(defineProcessGreetPeopleInteractive());
+ qInstance.addProcess(defineProcessSimpleSleep());
+ qInstance.addProcess(defineProcessScreenThenSleep());
+ qInstance.addProcess(defineProcessSimpleThrow());
return (qInstance);
}
@@ -175,7 +192,7 @@ public class TestUtils
.withCode(new QCodeReference()
.withName(MockBackendStep.class.getName())
.withCodeType(QCodeType.JAVA)
- .withCodeUsage(QCodeUsage.FUNCTION)) // todo - needed, or implied in this context?
+ .withCodeUsage(QCodeUsage.BACKEND_STEP)) // todo - needed, or implied in this context?
.withInputData(new QFunctionInputMetaData()
.withRecordListMetaData(new QRecordListMetaData().withTableName("person"))
.withFieldList(List.of(
@@ -213,7 +230,7 @@ public class TestUtils
.withCode(new QCodeReference()
.withName(MockBackendStep.class.getName())
.withCodeType(QCodeType.JAVA)
- .withCodeUsage(QCodeUsage.FUNCTION)) // todo - needed, or implied in this context?
+ .withCodeUsage(QCodeUsage.BACKEND_STEP)) // todo - needed, or implied in this context?
.withInputData(new QFunctionInputMetaData()
.withRecordListMetaData(new QRecordListMetaData().withTableName("person"))
.withFieldList(List.of(
@@ -234,4 +251,151 @@ public class TestUtils
);
}
+
+
+ /*******************************************************************************
+ ** Define a process with just one step that sleeps
+ *******************************************************************************/
+ private static QProcessMetaData defineProcessSimpleSleep()
+ {
+ return new QProcessMetaData()
+ .withName(PROCESS_NAME_SIMPLE_SLEEP)
+ .addStep(SleeperStep.getMetaData());
+ }
+
+
+
+ /*******************************************************************************
+ ** Define a process with a screen, then a sleep step
+ *******************************************************************************/
+ private static QProcessMetaData defineProcessScreenThenSleep()
+ {
+ return new QProcessMetaData()
+ .withName(PROCESS_NAME_SLEEP_INTERACTIVE)
+ .addStep(new QFrontendStepMetaData()
+ .withName(SCREEN_0)
+ .withFormField(new QFieldMetaData("outputMessage", QFieldType.STRING)))
+ .addStep(SleeperStep.getMetaData())
+ .addStep(new QFrontendStepMetaData()
+ .withName(SCREEN_1)
+ .withFormField(new QFieldMetaData("outputMessage", QFieldType.STRING)));
+ }
+
+
+
+ /*******************************************************************************
+ ** Define a process with just one step that sleeps and then throws
+ *******************************************************************************/
+ private static QProcessMetaData defineProcessSimpleThrow()
+ {
+ return new QProcessMetaData()
+ .withName(PROCESS_NAME_SIMPLE_THROW)
+ .addStep(ThrowerStep.getMetaData());
+ }
+
+
+
+ /*******************************************************************************
+ ** Testing backend step - just sleeps however long you ask it to (or, throws if
+ ** you don't provide a number of seconds to sleep).
+ *******************************************************************************/
+ public static class SleeperStep implements BackendStep
+ {
+ public static final String FIELD_SLEEP_MILLIS = "sleepMillis";
+
+
+
+ /*******************************************************************************
+ ** Execute the backend step - using the request as input, and the result as output.
+ **
+ ******************************************************************************/
+ @Override
+ public void run(RunBackendStepRequest runBackendStepRequest, RunBackendStepResult runBackendStepResult) throws QException
+ {
+ try
+ {
+ Thread.sleep(runBackendStepRequest.getValueInteger(FIELD_SLEEP_MILLIS));
+ }
+ catch(InterruptedException e)
+ {
+ throw (new QException("Interrupted while sleeping..."));
+ }
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ public static QBackendStepMetaData getMetaData()
+ {
+ return (new QBackendStepMetaData()
+ .withName(STEP_NAME_SLEEPER)
+ .withCode(new QCodeReference()
+ .withName(SleeperStep.class.getName())
+ .withCodeType(QCodeType.JAVA)
+ .withCodeUsage(QCodeUsage.BACKEND_STEP))
+ .withInputData(new QFunctionInputMetaData()
+ .addField(new QFieldMetaData(SleeperStep.FIELD_SLEEP_MILLIS, QFieldType.INTEGER))));
+ }
+ }
+
+
+
+ /*******************************************************************************
+ ** Testing backend step - just throws an exception after however long you ask it to sleep.
+ *******************************************************************************/
+ public static class ThrowerStep implements BackendStep
+ {
+ public static final String FIELD_SLEEP_MILLIS = "sleepMillis";
+
+
+
+ /*******************************************************************************
+ ** Execute the backend step - using the request as input, and the result as output.
+ **
+ ******************************************************************************/
+ @Override
+ public void run(RunBackendStepRequest runBackendStepRequest, RunBackendStepResult runBackendStepResult) throws QException
+ {
+ int sleepMillis;
+ try
+ {
+ sleepMillis = runBackendStepRequest.getValueInteger(FIELD_SLEEP_MILLIS);
+ }
+ catch(QValueException qve)
+ {
+ sleepMillis = 50;
+ }
+
+ try
+ {
+ Thread.sleep(sleepMillis);
+ }
+ catch(InterruptedException e)
+ {
+ throw (new QException("Interrupted while sleeping..."));
+ }
+
+ throw (new QException("I always throw."));
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ public static QBackendStepMetaData getMetaData()
+ {
+ return (new QBackendStepMetaData()
+ .withName(STEP_NAME_THROWER)
+ .withCode(new QCodeReference()
+ .withName(ThrowerStep.class.getName())
+ .withCodeType(QCodeType.JAVA)
+ .withCodeUsage(QCodeUsage.BACKEND_STEP))
+ .withInputData(new QFunctionInputMetaData()
+ .addField(new QFieldMetaData(ThrowerStep.FIELD_SLEEP_MILLIS, QFieldType.INTEGER))));
+ }
+ }
+
}