recordList = new ArrayList<>();
+ QRecord record = new QRecord();
+ record.setTableName(table);
+ recordList.add(record);
+
+ Map, ?> map = context.bodyAsClass(Map.class);
+ for(Map.Entry, ?> entry : map.entrySet())
+ {
+ if(StringUtils.hasContent(String.valueOf(entry.getValue())))
+ {
+ record.setValue(String.valueOf(entry.getKey()), (Serializable) entry.getValue());
+ }
+ }
+
+ InsertInput insertInput = new InsertInput(qInstance);
+ setupSession(context, insertInput);
+ insertInput.setTableName(table);
+ insertInput.setRecords(recordList);
+
+ InsertAction insertAction = new InsertAction();
+ InsertOutput insertOutput = insertAction.execute(insertInput);
+
+ context.result(JsonUtils.toJson(insertOutput));
+ }
+ catch(Exception e)
+ {
+ handleException(context, e);
+ }
+ }
+
+
+
+ /*******************************************************************************
+ **
+ ********************************************************************************/
+ private static void dataGet(Context context)
+ {
+ try
+ {
+ String tableName = context.pathParam("table");
+ QTableMetaData table = qInstance.getTable(tableName);
+ String primaryKey = context.pathParam("primaryKey");
+ QueryInput queryInput = new QueryInput(qInstance);
+
+ setupSession(context, queryInput);
+ queryInput.setTableName(tableName);
+
+ // todo - validate that the primary key is of the proper type (e.g,. not a string for an id field)
+ // and throw a 400-series error (tell the user bad-request), rather than, we're doing a 500 (server error)
+
+ ///////////////////////////////////////////////////////
+ // setup a filter for the primaryKey = the path-pram //
+ ///////////////////////////////////////////////////////
+ queryInput.setFilter(new QQueryFilter()
+ .withCriteria(new QFilterCriteria()
+ .withFieldName(table.getPrimaryKeyField())
+ .withOperator(QCriteriaOperator.EQUALS)
+ .withValues(List.of(primaryKey))));
+
+ QueryAction queryAction = new QueryAction();
+ QueryOutput queryOutput = queryAction.execute(queryInput);
+
+ ///////////////////////////////////////////////////////
+ // throw a not found error if the record isn't found //
+ ///////////////////////////////////////////////////////
+ if(queryOutput.getRecords().isEmpty())
+ {
+ throw (new QNotFoundException("Could not find " + table.getLabel() + " with "
+ + table.getFields().get(table.getPrimaryKeyField()).getLabel() + " of " + primaryKey));
+ }
+
+ context.result(JsonUtils.toJson(queryOutput.getRecords().get(0)));
+ }
+ catch(Exception e)
+ {
+ handleException(context, e);
+ }
+ }
+
+
+
+ /*******************************************************************************
+ *
+ * Filter parameter is a serialized QQueryFilter object, that is to say:
+ *
+ * filter=
+ * {"criteria":[
+ * {"fieldName":"id","operator":"EQUALS","values":[1]},
+ * {"fieldName":"name","operator":"IN","values":["Darin","James"]}
+ * ]
+ * }
+ *
+ *******************************************************************************/
+ static void dataCount(Context context)
+ {
+ try
+ {
+ CountInput countInput = new CountInput(qInstance);
+ setupSession(context, countInput);
+ countInput.setTableName(context.pathParam("table"));
+
+ String filter = stringQueryParam(context, "filter");
+ if(filter != null)
+ {
+ countInput.setFilter(JsonUtils.toObject(filter, QQueryFilter.class));
+ }
+
+ CountAction countAction = new CountAction();
+ CountOutput countOutput = countAction.execute(countInput);
+
+ context.result(JsonUtils.toJson(countOutput));
+ }
+ catch(Exception e)
+ {
+ handleException(context, e);
+ }
+ }
+
+
+
+ /*******************************************************************************
+ *
+ * Filter parameter is a serialized QQueryFilter object, that is to say:
+ *
+ * filter=
+ * {"criteria":[
+ * {"fieldName":"id","operator":"EQUALS","values":[1]},
+ * {"fieldName":"name","operator":"IN","values":["Darin","James"]}
+ * ],
+ * "orderBys":[
+ * {"fieldName":"age","isAscending":true}
+ * ]}
+ *
+ *******************************************************************************/
+ static void dataQuery(Context context)
+ {
+ try
+ {
+ QueryInput queryInput = new QueryInput(qInstance);
+ setupSession(context, queryInput);
+ queryInput.setTableName(context.pathParam("table"));
+ queryInput.setSkip(integerQueryParam(context, "skip"));
+ queryInput.setLimit(integerQueryParam(context, "limit"));
+
+ String filter = stringQueryParam(context, "filter");
+ if(filter != null)
+ {
+ queryInput.setFilter(JsonUtils.toObject(filter, QQueryFilter.class));
+ }
+
+ QueryAction queryAction = new QueryAction();
+ QueryOutput queryOutput = queryAction.execute(queryInput);
+
+ context.result(JsonUtils.toJson(queryOutput));
+ }
+ catch(Exception e)
+ {
+ handleException(context, e);
+ }
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ public static void metaData(Context context)
+ {
+ try
+ {
+ MetaDataInput metaDataInput = new MetaDataInput(qInstance);
+ setupSession(context, metaDataInput);
+ MetaDataAction metaDataAction = new MetaDataAction();
+ MetaDataOutput metaDataOutput = metaDataAction.execute(metaDataInput);
+
+ context.result(JsonUtils.toJson(metaDataOutput));
+ }
+ catch(Exception e)
+ {
+ handleException(context, e);
+ }
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ private static void tableMetaData(Context context)
+ {
+ try
+ {
+ TableMetaDataInput tableMetaDataInput = new TableMetaDataInput(qInstance);
+ setupSession(context, tableMetaDataInput);
+ tableMetaDataInput.setTableName(context.pathParam("table"));
+ TableMetaDataAction tableMetaDataAction = new TableMetaDataAction();
+ TableMetaDataOutput tableMetaDataOutput = tableMetaDataAction.execute(tableMetaDataInput);
+
+ context.result(JsonUtils.toJson(tableMetaDataOutput));
+ }
+ catch(Exception e)
+ {
+ handleException(context, e);
+ }
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ private static void processMetaData(Context context)
+ {
+ try
+ {
+ ProcessMetaDataInput processMetaDataInput = new ProcessMetaDataInput(qInstance);
+ setupSession(context, processMetaDataInput);
+ processMetaDataInput.setProcessName(context.pathParam("processName"));
+ ProcessMetaDataAction processMetaDataAction = new ProcessMetaDataAction();
+ ProcessMetaDataOutput processMetaDataOutput = processMetaDataAction.execute(processMetaDataInput);
+
+ context.result(JsonUtils.toJson(processMetaDataOutput));
+ }
+ catch(Exception e)
+ {
+ handleException(context, e);
+ }
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ private static void dataExportWithFilename(Context context)
+ {
+ String filename = context.pathParam("filename");
+ dataExport(context, Optional.of(filename));
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ private static void dataExportWithoutFilename(Context context)
+ {
+ dataExport(context, Optional.empty());
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ private static void dataExport(Context context, Optional optionalFilename)
+ {
+ try
+ {
+ //////////////////////////////////////////
+ // read params from the request context //
+ //////////////////////////////////////////
+ String tableName = context.pathParam("table");
+ String format = context.queryParam("format");
+ String filter = context.queryParam("filter");
+ Integer limit = integerQueryParam(context, "limit");
+
+ /////////////////////////////////////////////////////////////////////////////////////////
+ // if a format query param wasn't given, then try to get file extension from file name //
+ /////////////////////////////////////////////////////////////////////////////////////////
+ if(!StringUtils.hasContent(format) && optionalFilename.isPresent() && StringUtils.hasContent(optionalFilename.get()))
+ {
+ String filename = optionalFilename.get();
+ if(filename.contains("."))
+ {
+ format = filename.substring(filename.lastIndexOf(".") + 1);
+ }
+ }
+
+ ReportFormat reportFormat;
+ try
+ {
+ reportFormat = ReportFormat.fromString(format);
+ }
+ catch(QUserFacingException e)
+ {
+ handleException(HttpStatus.Code.BAD_REQUEST, context, e);
+ return;
+ }
+
+ String filename = optionalFilename.orElse(tableName + "." + reportFormat.toString().toLowerCase(Locale.ROOT));
+
+ /////////////////////////////////////////////
+ // set up the report action's input object //
+ /////////////////////////////////////////////
+ ReportInput reportInput = new ReportInput(qInstance);
+ setupSession(context, reportInput);
+ reportInput.setTableName(tableName);
+ reportInput.setReportFormat(reportFormat);
+ reportInput.setFilename(filename);
+ reportInput.setLimit(limit);
+
+ String fields = stringQueryParam(context, "fields");
+ if(StringUtils.hasContent(fields))
+ {
+ reportInput.setFieldNames(List.of(fields.split(",")));
+ }
+
+ if(filter != null)
+ {
+ reportInput.setQueryFilter(JsonUtils.toObject(filter, QQueryFilter.class));
+ }
+
+ ///////////////////////////////////////////////////////////////////////////////////////////////////////
+ // set up the I/O pipe streams. //
+ // Critically, we must NOT open the outputStream in a try-with-resources. The thread that writes to //
+ // the stream must close it when it's done writing. //
+ ///////////////////////////////////////////////////////////////////////////////////////////////////////
+ PipedOutputStream pipedOutputStream = new PipedOutputStream();
+ PipedInputStream pipedInputStream = new PipedInputStream();
+ pipedOutputStream.connect(pipedInputStream);
+ reportInput.setReportOutputStream(pipedOutputStream);
+
+ ReportAction reportAction = new ReportAction();
+ reportAction.preExecute(reportInput);
+
+ /////////////////////////////////////////////////////////////////////////////////////////////////////
+ // start the async job. //
+ // Critically, this must happen before the pipedInputStream is passed to the javalin result method //
+ /////////////////////////////////////////////////////////////////////////////////////////////////////
+ new AsyncJobManager().startJob("Javalin>ReportAction", (o) ->
+ {
+ try
+ {
+ reportAction.execute(reportInput);
+ return (true);
+ }
+ catch(Exception e)
+ {
+ pipedOutputStream.write(("Error generating report: " + e.getMessage()).getBytes());
+ pipedOutputStream.close();
+ return (false);
+ }
+ });
+
+ ////////////////////////////////////////////
+ // set the response content type & stream //
+ ////////////////////////////////////////////
+ context.contentType(reportFormat.getMimeType());
+ context.header("Content-Disposition", "filename=" + filename);
+ context.result(pipedInputStream);
+
+ ////////////////////////////////////////////////////////////////////////////////////////////
+ // we'd like to check to see if the job failed, and if so, to give the user an error... //
+ // but if we "block" here, then piped streams seem to never flush, so we deadlock things. //
+ ////////////////////////////////////////////////////////////////////////////////////////////
+ // AsyncJobStatus asyncJobStatus = asyncJobManager.waitForJob(jobUUID);
+ // if(asyncJobStatus.getState().equals(AsyncJobState.ERROR))
+ // {
+ // System.out.println("Well, here we are...");
+ // throw (new QUserFacingException("Error running report: " + asyncJobStatus.getCaughtException().getMessage()));
+ // }
+ }
+ catch(Exception e)
+ {
+ handleException(context, e);
+ }
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ public static void handleException(Context context, Exception e)
+ {
+ handleException(null, context, e);
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ public static void handleException(HttpStatus.Code statusCode, Context context, Exception e)
+ {
+ QUserFacingException userFacingException = ExceptionUtils.findClassInRootChain(e, QUserFacingException.class);
+ if(userFacingException != null)
+ {
+ if(userFacingException instanceof QNotFoundException)
+ {
+ int code = Objects.requireNonNullElse(statusCode, HttpStatus.Code.NOT_FOUND).getCode();
+ context.status(code).result("{\"error\":\"" + userFacingException.getMessage() + "\"}");
+ }
+ else
+ {
+ LOG.info("User-facing exception", e);
+ int code = Objects.requireNonNullElse(statusCode, HttpStatus.Code.INTERNAL_SERVER_ERROR).getCode();
+ context.status(code).result("{\"error\":\"" + userFacingException.getMessage() + "\"}");
+ }
+ }
+ else
+ {
+ if(e instanceof QAuthenticationException)
+ {
+ context.status(HttpStatus.UNAUTHORIZED_401).result("{\"error\":\"" + e.getMessage() + "\"}");
+ return;
+ }
+
+ ////////////////////////////////
+ // default exception handling //
+ ////////////////////////////////
+ LOG.warn("Exception in javalin request", e);
+ int code = Objects.requireNonNullElse(statusCode, HttpStatus.Code.INTERNAL_SERVER_ERROR).getCode();
+ context.status(code).result("{\"error\":\"" + e.getClass().getSimpleName() + " (" + e.getMessage() + ")\"}");
+ }
+ }
+
+
+
+ /*******************************************************************************
+ ** Returns Integer if context has a valid int query parameter by the given name,
+ ** Returns null if no param (or empty value).
+ ** Throws QValueException for malformed numbers.
+ *******************************************************************************/
+ public static Integer integerQueryParam(Context context, String name) throws QValueException
+ {
+ String value = context.queryParam(name);
+ if(StringUtils.hasContent(value))
+ {
+ return (ValueUtils.getValueAsInteger(value));
+ }
+
+ return (null);
+ }
+
+
+
+ /*******************************************************************************
+ ** 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)
+ {
+ String value = context.queryParam(name);
+ if(StringUtils.hasContent(value))
+ {
+ return (value);
+ }
+
+ return (null);
+ }
+
+}
diff --git a/src/main/java/com/kingsrook/qqq/backend/javalin/QJavalinProcessHandler.java b/src/main/java/com/kingsrook/qqq/backend/javalin/QJavalinProcessHandler.java
new file mode 100644
index 00000000..08175cdb
--- /dev/null
+++ b/src/main/java/com/kingsrook/qqq/backend/javalin/QJavalinProcessHandler.java
@@ -0,0 +1,489 @@
+/*
+ * QQQ - Low-code Application Framework for Engineers.
+ * Copyright (C) 2021-2022. Kingsrook, LLC
+ * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
+ * contact@kingsrook.com
+ * https://github.com/Kingsrook/
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package com.kingsrook.qqq.backend.javalin;
+
+
+import java.io.IOException;
+import java.io.Serializable;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.UUID;
+import java.util.concurrent.TimeUnit;
+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.actions.processes.QProcessCallback;
+import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
+import com.kingsrook.qqq.backend.core.exceptions.QException;
+import com.kingsrook.qqq.backend.core.exceptions.QModuleDispatchException;
+import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
+import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessState;
+import com.kingsrook.qqq.backend.core.model.actions.processes.QUploadedFile;
+import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput;
+import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessOutput;
+import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
+import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
+import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
+import com.kingsrook.qqq.backend.core.model.data.QRecord;
+import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
+import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
+import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
+import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
+import com.kingsrook.qqq.backend.core.state.StateType;
+import com.kingsrook.qqq.backend.core.state.TempFileStateProvider;
+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 io.javalin.apibuilder.EndpointGroup;
+import io.javalin.http.Context;
+import io.javalin.http.UploadedFile;
+import org.apache.commons.lang.NotImplementedException;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+import static io.javalin.apibuilder.ApiBuilder.get;
+import static io.javalin.apibuilder.ApiBuilder.path;
+import static io.javalin.apibuilder.ApiBuilder.post;
+
+
+/*******************************************************************************
+ ** methods for handling qqq processes in javalin.
+ *******************************************************************************/
+public class QJavalinProcessHandler
+{
+ private static final Logger LOG = LogManager.getLogger(QJavalinProcessHandler.class);
+
+ private static int ASYNC_STEP_TIMEOUT_MILLIS = 3_000;
+
+
+
+ /*******************************************************************************
+ ** Define the routes
+ *******************************************************************************/
+ public static EndpointGroup getRoutes()
+ {
+ return (() ->
+ {
+ path("/processes", () ->
+ {
+ path("/{processName}", () ->
+ {
+ get("/init", QJavalinProcessHandler::processInit);
+ post("/init", QJavalinProcessHandler::processInit);
+
+ path("/{processUUID}", () ->
+ {
+ post("/step/{step}", QJavalinProcessHandler::processStep);
+ get("/status/{jobUUID}", QJavalinProcessHandler::processStatus);
+ get("/records", QJavalinProcessHandler::processRecords);
+ });
+ });
+ });
+ });
+ }
+
+
+
+ /*******************************************************************************
+ ** Init a process (named in path param :process)
+ **
+ *******************************************************************************/
+ public static void processInit(Context context) throws QException
+ {
+ doProcessInitOrStep(context, null, null);
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ private static void doProcessInitOrStep(Context context, String processUUID, String startAfterStep)
+ {
+ Map resultForCaller = new HashMap<>();
+
+ try
+ {
+ if(processUUID == null)
+ {
+ processUUID = UUID.randomUUID().toString();
+ }
+ resultForCaller.put("processUUID", processUUID);
+
+ String processName = context.pathParam("processName");
+ LOG.info(startAfterStep == null ? "Initiating process [" + processName + "] [" + processUUID + "]"
+ : "Resuming process [" + processName + "] [" + processUUID + "] after step [" + startAfterStep + "]");
+
+ RunProcessInput runProcessInput = new RunProcessInput(QJavalinImplementation.qInstance);
+ QJavalinImplementation.setupSession(context, runProcessInput);
+ runProcessInput.setProcessName(processName);
+ runProcessInput.setFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.BREAK);
+ runProcessInput.setProcessUUID(processUUID);
+ runProcessInput.setStartAfterStep(startAfterStep);
+ populateRunProcessRequestWithValuesFromContext(context, runProcessInput);
+
+ ////////////////////////////////////////
+ // run the process as an async action //
+ ////////////////////////////////////////
+ Integer timeout = getTimeoutMillis(context);
+ RunProcessOutput runProcessOutput = new AsyncJobManager().startJob(timeout, TimeUnit.MILLISECONDS, (callback) ->
+ {
+ runProcessInput.setAsyncJobCallback(callback);
+ return (new RunProcessAction().execute(runProcessInput));
+ });
+
+ LOG.info("Process result error? " + runProcessOutput.getException());
+ for(QFieldMetaData outputField : QJavalinImplementation.qInstance.getProcess(runProcessInput.getProcessName()).getOutputFields())
+ {
+ LOG.info("Process result output value: " + outputField.getName() + ": " + runProcessOutput.getValues().get(outputField.getName()));
+ }
+
+ serializeRunProcessResultForCaller(resultForCaller, runProcessOutput);
+ }
+ 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));
+ }
+
+
+
+ /*******************************************************************************
+ ** Whether a step finished synchronously or asynchronously, return its data
+ ** to the caller the same way.
+ *******************************************************************************/
+ private static void serializeRunProcessResultForCaller(Map resultForCaller, RunProcessOutput runProcessOutput)
+ {
+ if(runProcessOutput.getException().isPresent())
+ {
+ ////////////////////////////////////////////////////////////////
+ // per code coverage, this path may never actually get hit... //
+ ////////////////////////////////////////////////////////////////
+ serializeRunProcessExceptionForCaller(resultForCaller, runProcessOutput.getException().get());
+ }
+ resultForCaller.put("values", runProcessOutput.getValues());
+ runProcessOutput.getProcessState().getNextStepName().ifPresent(nextStep -> resultForCaller.put("nextStep", nextStep));
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ 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", "Error message: " + rootException.getMessage());
+ }
+ }
+
+
+
+ /*******************************************************************************
+ ** take values from query-string params, and put them into the run process request
+ ** todo - make query params have a "field-" type of prefix??
+ **
+ *******************************************************************************/
+ private static void populateRunProcessRequestWithValuesFromContext(Context context, RunProcessInput runProcessInput) throws IOException
+ {
+ //////////////////////////
+ // process query string //
+ //////////////////////////
+ for(Map.Entry> queryParam : context.queryParamMap().entrySet())
+ {
+ String fieldName = queryParam.getKey();
+ List values = queryParam.getValue();
+ if(CollectionUtils.nullSafeHasContents(values))
+ {
+ runProcessInput.addValue(fieldName, values.get(0));
+ }
+ }
+
+ ////////////////////////////
+ // process form/post body //
+ ////////////////////////////
+ for(Map.Entry> formParam : context.formParamMap().entrySet())
+ {
+ String fieldName = formParam.getKey();
+ List values = formParam.getValue();
+ if(CollectionUtils.nullSafeHasContents(values))
+ {
+ runProcessInput.addValue(fieldName, values.get(0));
+ }
+ }
+
+ ////////////////////////////
+ // process uploaded files //
+ ////////////////////////////
+ for(UploadedFile uploadedFile : context.uploadedFiles())
+ {
+ QUploadedFile qUploadedFile = new QUploadedFile();
+ qUploadedFile.setBytes(uploadedFile.getContent().readAllBytes());
+ qUploadedFile.setFilename(uploadedFile.getFilename());
+
+ UUIDAndTypeStateKey key = new UUIDAndTypeStateKey(StateType.UPLOADED_FILE);
+ TempFileStateProvider.getInstance().put(key, qUploadedFile);
+ LOG.info("Stored uploaded file in TempFileStateProvider under key: " + key);
+ runProcessInput.addValue(QUploadedFile.DEFAULT_UPLOADED_FILE_FIELD_NAME, key);
+ }
+
+ /////////////////////////////////////////////////////////////
+ // deal with params that specify an initial-records filter //
+ /////////////////////////////////////////////////////////////
+ QQueryFilter initialRecordsFilter = buildProcessInitRecordsFilter(context, runProcessInput);
+ if(initialRecordsFilter != null)
+ {
+ runProcessInput.setCallback(new QProcessCallback()
+ {
+ @Override
+ public QQueryFilter getQueryFilter()
+ {
+ return (initialRecordsFilter);
+ }
+
+
+
+ @Override
+ public Map getFieldValues(List fields)
+ {
+ return (Collections.emptyMap());
+ }
+ });
+ }
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ private static QQueryFilter buildProcessInitRecordsFilter(Context context, RunProcessInput runProcessInput) throws IOException
+ {
+ QInstance instance = runProcessInput.getInstance();
+ QProcessMetaData process = instance.getProcess(runProcessInput.getProcessName());
+ QTableMetaData table = instance.getTable(process.getTableName());
+
+ if(table == null)
+ {
+ LOG.info("No table found in process - so not building an init records filter.");
+ return (null);
+ }
+ String primaryKeyField = table.getPrimaryKeyField();
+
+ String recordsParam = context.queryParam("recordsParam");
+ if(StringUtils.hasContent(recordsParam))
+ {
+ @SuppressWarnings("ConstantConditions")
+ String paramValue = context.queryParam(recordsParam);
+ if(!StringUtils.hasContent(paramValue))
+ {
+ throw (new IllegalArgumentException("Missing value in query parameter: " + recordsParam + " (which was specified as the recordsParam)"));
+ }
+
+ switch(recordsParam)
+ {
+ case "recordIds":
+ @SuppressWarnings("ConstantConditions")
+ Serializable[] idStrings = paramValue.split(",");
+ return (new QQueryFilter().withCriteria(new QFilterCriteria()
+ .withFieldName(primaryKeyField)
+ .withOperator(QCriteriaOperator.IN)
+ .withValues(Arrays.stream(idStrings).toList())));
+ case "filterJSON":
+ return (JsonUtils.toObject(paramValue, QQueryFilter.class));
+ case "filterId":
+ // return (JsonUtils.toObject(context.queryParam(recordsParam), QQueryFilter.class));
+ throw (new NotImplementedException("Saved filters are not yet implemented."));
+ default:
+ throw (new IllegalArgumentException("Unrecognized value [" + recordsParam + "] for query parameter: recordsParam"));
+ }
+ }
+
+ return (null);
+ }
+
+
+
+ /*******************************************************************************
+ ** Run a step in a process (named in path param :processName)
+ **
+ *******************************************************************************/
+ public 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)
+ *******************************************************************************/
+ public static void processStatus(Context context)
+ {
+ String processUUID = context.pathParam("processUUID");
+ String jobUUID = context.pathParam("jobUUID");
+
+ Map resultForCaller = new HashMap<>();
+ resultForCaller.put("processUUID", processUUID);
+
+ LOG.info("Request for status of process " + processUUID + ", job " + jobUUID);
+ Optional optionalJobStatus = new AsyncJobManager().getJobStatus(jobUUID);
+ if(optionalJobStatus.isEmpty())
+ {
+ serializeRunProcessExceptionForCaller(resultForCaller, new RuntimeException("Could not find status of process step job"));
+ }
+ else
+ {
+ 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.getState(processUUID);
+ if(processState.isPresent())
+ {
+ RunProcessOutput runProcessOutput = new RunProcessOutput(processState.get());
+ serializeRunProcessResultForCaller(resultForCaller, runProcessOutput);
+ }
+ else
+ {
+ serializeRunProcessExceptionForCaller(resultForCaller, new RuntimeException("Could not find results for process " + processUUID));
+ }
+ }
+ 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));
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ private static void processRecords(Context context)
+ {
+ try
+ {
+ String processUUID = context.pathParam("processUUID");
+ Integer skip = Objects.requireNonNullElse(QJavalinImplementation.integerQueryParam(context, "skip"), 0);
+ Integer limit = Objects.requireNonNullElse(QJavalinImplementation.integerQueryParam(context, "limit"), 20);
+
+ // todo - potential optimization - if a future state provider could take advantage of it,
+ // we might pass the skip & limit in to a method that fetch just those 'n' rows from state, rather than the whole thing?
+ Optional optionalProcessState = RunProcessAction.getState(processUUID);
+ if(optionalProcessState.isEmpty())
+ {
+ throw (new Exception("Could not find process results."));
+ }
+ ProcessState processState = optionalProcessState.get();
+
+ List records = processState.getRecords();
+ if(CollectionUtils.nullSafeIsEmpty(records))
+ {
+ throw (new Exception("No records were found for the process."));
+ }
+
+ Map resultForCaller = new HashMap<>();
+ List recordPage = CollectionUtils.safelyGetPage(records, skip, limit);
+ resultForCaller.put("records", recordPage);
+ resultForCaller.put("totalRecords", records.size());
+ context.result(JsonUtils.toJson(resultForCaller));
+ }
+ catch(Exception e)
+ {
+ QJavalinImplementation.handleException(context, e);
+ }
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ public static void setAsyncStepTimeoutMillis(int asyncStepTimeoutMillis)
+ {
+ ASYNC_STEP_TIMEOUT_MILLIS = asyncStepTimeoutMillis;
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ private static Integer getTimeoutMillis(Context context)
+ {
+ Integer timeout = QJavalinImplementation.integerQueryParam(context, "_qStepTimeoutMillis");
+ if(timeout == null)
+ {
+ timeout = ASYNC_STEP_TIMEOUT_MILLIS;
+ }
+ return timeout;
+ }
+
+}
diff --git a/src/test/java/com/kingsrook/qqq/backend/javalin/QJavalinImplementationTest.java b/src/test/java/com/kingsrook/qqq/backend/javalin/QJavalinImplementationTest.java
new file mode 100644
index 00000000..a7351f52
--- /dev/null
+++ b/src/test/java/com/kingsrook/qqq/backend/javalin/QJavalinImplementationTest.java
@@ -0,0 +1,473 @@
+/*
+ * QQQ - Low-code Application Framework for Engineers.
+ * Copyright (C) 2021-2022. Kingsrook, LLC
+ * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
+ * contact@kingsrook.com
+ * https://github.com/Kingsrook/
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package com.kingsrook.qqq.backend.javalin;
+
+
+import java.io.Serializable;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.util.HashMap;
+import java.util.Map;
+import com.kingsrook.qqq.backend.core.model.actions.reporting.ReportFormat;
+import com.kingsrook.qqq.backend.core.utils.JsonUtils;
+import kong.unirest.HttpResponse;
+import kong.unirest.Unirest;
+import org.eclipse.jetty.http.HttpStatus;
+import org.json.JSONArray;
+import org.json.JSONObject;
+import org.junit.jupiter.api.Test;
+import static org.assertj.core.api.Assertions.assertThat;
+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;
+
+
+/*******************************************************************************
+ ** Unit test for the QJavalinImplementation
+ **
+ ** based on https://javalin.io/tutorials/testing - starts a javalin instance
+ ** and actually makes http requests into it.
+ **
+ *******************************************************************************/
+class QJavalinImplementationTest extends QJavalinTestBase
+{
+
+
+ /*******************************************************************************
+ ** test the top-level meta-data endpoint
+ **
+ *******************************************************************************/
+ @Test
+ public void test_metaData()
+ {
+ HttpResponse response = Unirest.get(BASE_URL + "/metaData").asString();
+
+ assertEquals(200, response.getStatus());
+ JSONObject jsonObject = JsonUtils.toJSONObject(response.getBody());
+ assertTrue(jsonObject.has("tables"));
+ JSONObject tables = jsonObject.getJSONObject("tables");
+ assertEquals(1, tables.length());
+ JSONObject personTable = tables.getJSONObject("person");
+ assertTrue(personTable.has("name"));
+ assertEquals("person", personTable.getString("name"));
+ assertTrue(personTable.has("label"));
+ assertEquals("Person", personTable.getString("label"));
+ assertFalse(personTable.getBoolean("isHidden"));
+
+ JSONObject processes = jsonObject.getJSONObject("processes");
+ assertTrue(processes.getJSONObject("simpleSleep").getBoolean("isHidden"));
+ }
+
+
+
+ /*******************************************************************************
+ ** test the table-level meta-data endpoint
+ **
+ *******************************************************************************/
+ @Test
+ public void test_tableMetaData()
+ {
+ HttpResponse response = Unirest.get(BASE_URL + "/metaData/table/person").asString();
+
+ assertEquals(200, response.getStatus());
+ JSONObject jsonObject = JsonUtils.toJSONObject(response.getBody());
+ assertEquals(1, jsonObject.keySet().size(), "Number of top-level keys");
+ JSONObject table = jsonObject.getJSONObject("table");
+ assertEquals("person", table.getString("name"));
+ assertEquals("Person", table.getString("label"));
+ assertEquals("id", table.getString("primaryKeyField"));
+ JSONObject fields = table.getJSONObject("fields");
+ JSONObject field0 = fields.getJSONObject("id");
+ assertEquals("id", field0.getString("name"));
+ assertEquals("INTEGER", field0.getString("type"));
+ }
+
+
+
+ /*******************************************************************************
+ ** test the table-level meta-data endpoint for a non-real name
+ **
+ *******************************************************************************/
+ @Test
+ public void test_tableMetaData_notFound()
+ {
+ HttpResponse response = Unirest.get(BASE_URL + "/metaData/table/notAnActualTable").asString();
+
+ assertEquals(HttpStatus.NOT_FOUND_404, response.getStatus());
+ JSONObject jsonObject = JsonUtils.toJSONObject(response.getBody());
+ assertEquals(1, jsonObject.keySet().size(), "Number of top-level keys");
+ String error = jsonObject.getString("error");
+ assertTrue(error.contains("not found"));
+ }
+
+
+
+ /*******************************************************************************
+ ** test the process-level meta-data endpoint
+ **
+ *******************************************************************************/
+ @Test
+ public void test_processMetaData()
+ {
+ HttpResponse response = Unirest.get(BASE_URL + "/metaData/process/greetInteractive").asString();
+
+ assertEquals(200, response.getStatus());
+ JSONObject jsonObject = JsonUtils.toJSONObject(response.getBody());
+ assertEquals(1, jsonObject.keySet().size(), "Number of top-level keys");
+ JSONObject process = jsonObject.getJSONObject("process");
+ assertEquals("greetInteractive", process.getString("name"));
+ assertEquals("Greet Interactive", process.getString("label"));
+ assertEquals("person", process.getString("tableName"));
+
+ JSONArray frontendSteps = process.getJSONArray("frontendSteps");
+ JSONObject setupStep = frontendSteps.getJSONObject(0);
+ assertEquals("Setup", setupStep.getString("label"));
+ JSONArray setupFields = setupStep.getJSONArray("formFields");
+ assertEquals(2, setupFields.length());
+ assertTrue(setupFields.toList().stream().anyMatch(field -> "greetingPrefix".equals(((Map, ?>) field).get("name"))));
+ }
+
+
+
+ /*******************************************************************************
+ ** test the process-level meta-data endpoint for a non-real name
+ **
+ *******************************************************************************/
+ @Test
+ public void test_processMetaData_notFound()
+ {
+ HttpResponse response = Unirest.get(BASE_URL + "/metaData/process/notAnActualProcess").asString();
+
+ assertEquals(HttpStatus.NOT_FOUND_404, response.getStatus());
+ JSONObject jsonObject = JsonUtils.toJSONObject(response.getBody());
+ assertEquals(1, jsonObject.keySet().size(), "Number of top-level keys");
+ String error = jsonObject.getString("error");
+ assertTrue(error.contains("not found"));
+ }
+
+
+
+ /*******************************************************************************
+ ** test a table get (single record)
+ **
+ *******************************************************************************/
+ @Test
+ public void test_dataGet()
+ {
+ HttpResponse response = Unirest.get(BASE_URL + "/data/person/1").asString();
+
+ assertEquals(200, response.getStatus());
+ JSONObject jsonObject = JsonUtils.toJSONObject(response.getBody());
+ assertTrue(jsonObject.has("values"));
+ assertEquals("person", jsonObject.getString("tableName"));
+ JSONObject values = jsonObject.getJSONObject("values");
+ assertTrue(values.has("firstName"));
+ assertTrue(values.has("id"));
+ }
+
+
+
+ /*******************************************************************************
+ ** test a table get (single record) for an id that isn't found
+ **
+ *******************************************************************************/
+ @Test
+ public void test_dataGetNotFound()
+ {
+ HttpResponse response = Unirest.get(BASE_URL + "/data/person/98765").asString();
+ assertEquals(HttpStatus.NOT_FOUND_404, response.getStatus());
+ JSONObject jsonObject = JsonUtils.toJSONObject(response.getBody());
+ assertEquals(1, jsonObject.keySet().size(), "Number of top-level keys");
+ String error = jsonObject.getString("error");
+ assertEquals("Could not find Person with Id of 98765", error);
+ }
+
+
+
+ /*******************************************************************************
+ ** test a table get (single record) for an id that isn't the expected type
+ **
+ *******************************************************************************/
+ @Test
+ public void test_dataGetWrongIdType()
+ {
+ HttpResponse response = Unirest.get(BASE_URL + "/data/person/not-an-integer").asString();
+ assertEquals(HttpStatus.INTERNAL_SERVER_ERROR_500, response.getStatus());
+ JSONObject jsonObject = JsonUtils.toJSONObject(response.getBody());
+ assertEquals(1, jsonObject.keySet().size(), "Number of top-level keys");
+ }
+
+
+
+ /*******************************************************************************
+ ** test a table count
+ **
+ *******************************************************************************/
+ @Test
+ public void test_dataCount()
+ {
+ HttpResponse response = Unirest.get(BASE_URL + "/data/person/count").asString();
+
+ assertEquals(200, response.getStatus());
+ JSONObject jsonObject = JsonUtils.toJSONObject(response.getBody());
+ assertTrue(jsonObject.has("count"));
+ int count = jsonObject.getInt("count");
+ assertEquals(5, count);
+ }
+
+
+
+ /*******************************************************************************
+ ** test a table query
+ **
+ *******************************************************************************/
+ @Test
+ public void test_dataQuery()
+ {
+ HttpResponse response = Unirest.get(BASE_URL + "/data/person").asString();
+
+ assertEquals(200, response.getStatus());
+ JSONObject jsonObject = JsonUtils.toJSONObject(response.getBody());
+ assertTrue(jsonObject.has("records"));
+ JSONArray records = jsonObject.getJSONArray("records");
+ assertEquals(5, records.length());
+ JSONObject record0 = records.getJSONObject(0);
+ assertTrue(record0.has("values"));
+ assertEquals("person", record0.getString("tableName"));
+ JSONObject values0 = record0.getJSONObject("values");
+ assertTrue(values0.has("firstName"));
+ assertTrue(values0.has("id"));
+ }
+
+
+
+ /*******************************************************************************
+ ** test a table query using an actual filter.
+ **
+ *******************************************************************************/
+ @Test
+ public void test_dataQueryWithFilter()
+ {
+ String filterJson = getFirstNameEqualsTimFilterJSON();
+ HttpResponse response = Unirest.get(BASE_URL + "/data/person?filter=" + URLEncoder.encode(filterJson, StandardCharsets.UTF_8)).asString();
+
+ assertEquals(200, response.getStatus());
+ JSONObject jsonObject = JsonUtils.toJSONObject(response.getBody());
+ assertTrue(jsonObject.has("records"));
+ JSONArray records = jsonObject.getJSONArray("records");
+ assertEquals(1, records.length());
+ JSONObject record0 = records.getJSONObject(0);
+ JSONObject values0 = record0.getJSONObject("values");
+ assertEquals("Tim", values0.getString("firstName"));
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ private String getFirstNameEqualsTimFilterJSON()
+ {
+ return """
+ {"criteria":[{"fieldName":"firstName","operator":"EQUALS","values":["Tim"]}]}""";
+ }
+
+
+
+ /*******************************************************************************
+ ** test an insert
+ **
+ *******************************************************************************/
+ @Test
+ public void test_dataInsert()
+ {
+ Map body = new HashMap<>();
+ body.put("firstName", "Bobby");
+ body.put("lastName", "Hull");
+ body.put("email", "bobby@hull.com");
+
+ HttpResponse response = Unirest.post(BASE_URL + "/data/person")
+ .header("Content-Type", "application/json")
+ .body(body)
+ .asString();
+
+ assertEquals(200, response.getStatus());
+ JSONObject jsonObject = JsonUtils.toJSONObject(response.getBody());
+ assertTrue(jsonObject.has("records"));
+ JSONArray records = jsonObject.getJSONArray("records");
+ assertEquals(1, records.length());
+ JSONObject record0 = records.getJSONObject(0);
+ assertTrue(record0.has("values"));
+ assertEquals("person", record0.getString("tableName"));
+ JSONObject values0 = record0.getJSONObject("values");
+ assertTrue(values0.has("firstName"));
+ assertEquals("Bobby", values0.getString("firstName"));
+ assertTrue(values0.has("id"));
+ assertEquals(6, values0.getInt("id"));
+ }
+
+
+
+ /*******************************************************************************
+ ** test an update
+ **
+ *******************************************************************************/
+ @Test
+ public void test_dataUpdate()
+ {
+ Map body = new HashMap<>();
+ body.put("firstName", "Free");
+ //? body.put("id", 4);
+
+ HttpResponse response = Unirest.patch(BASE_URL + "/data/person/4")
+ .header("Content-Type", "application/json")
+ .body(body)
+ .asString();
+
+ assertEquals(200, response.getStatus());
+ JSONObject jsonObject = JsonUtils.toJSONObject(response.getBody());
+ assertTrue(jsonObject.has("records"));
+ JSONArray records = jsonObject.getJSONArray("records");
+ assertEquals(1, records.length());
+ JSONObject record0 = records.getJSONObject(0);
+ assertTrue(record0.has("values"));
+ assertEquals("person", record0.getString("tableName"));
+ JSONObject values0 = record0.getJSONObject("values");
+ assertEquals(4, values0.getInt("id"));
+ assertEquals("Free", values0.getString("firstName"));
+ // mmm, whole record isn't loaded. should it be? assertEquals("Samples", values0.getString("lastName"));
+ }
+
+
+
+ /*******************************************************************************
+ ** test a delete
+ **
+ *******************************************************************************/
+ @Test
+ public void test_dataDelete() throws Exception
+ {
+ HttpResponse response = Unirest.delete(BASE_URL + "/data/person/3").asString();
+ assertEquals(200, response.getStatus());
+
+ JSONObject jsonObject = JsonUtils.toJSONObject(response.getBody());
+ assertNotNull(jsonObject);
+ assertEquals(1, jsonObject.getInt("deletedRecordCount"));
+ TestUtils.runTestSql("SELECT id FROM person", (rs -> {
+ int rowsFound = 0;
+ while(rs.next())
+ {
+ rowsFound++;
+ assertNotEquals(3, rs.getInt(1));
+ }
+ assertEquals(4, rowsFound);
+ }));
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ @Test
+ void testExportCsvPerFileName()
+ {
+ HttpResponse response = Unirest.get(BASE_URL + "/data/person/export/MyPersonExport.csv").asString();
+ assertEquals(200, response.getStatus());
+ assertEquals("text/csv", response.getHeaders().get("Content-Type").get(0));
+ assertEquals("filename=MyPersonExport.csv", response.getHeaders().get("Content-Disposition").get(0));
+ String[] csvLines = response.getBody().split("\n");
+ assertEquals(6, csvLines.length);
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ @Test
+ void testExportNoFormat()
+ {
+ HttpResponse response = Unirest.get(BASE_URL + "/data/person/export/").asString();
+ assertEquals(HttpStatus.BAD_REQUEST_400, response.getStatus());
+ assertThat(response.getBody()).contains("Report format was not specified");
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ @Test
+ void testExportExcelPerFormatQueryParam()
+ {
+ HttpResponse response = Unirest.get(BASE_URL + "/data/person/export/?format=xlsx").asString();
+ assertEquals(200, response.getStatus());
+ assertEquals(ReportFormat.XLSX.getMimeType(), response.getHeaders().get("Content-Type").get(0));
+ assertEquals("filename=person.xlsx", response.getHeaders().get("Content-Disposition").get(0));
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ @Test
+ void testExportFilterQueryParam()
+ {
+ String filterJson = getFirstNameEqualsTimFilterJSON();
+ HttpResponse response = Unirest.get(BASE_URL + "/data/person/export/Favorite People.csv?filter=" + URLEncoder.encode(filterJson, StandardCharsets.UTF_8)).asString();
+ assertEquals("filename=Favorite People.csv", response.getHeaders().get("Content-Disposition").get(0));
+ String[] csvLines = response.getBody().split("\n");
+ assertEquals(2, csvLines.length);
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ @Test
+ void testExportFieldsQueryParam()
+ {
+ HttpResponse response = Unirest.get(BASE_URL + "/data/person/export/People.csv?fields=id,birthDate").asString();
+ String[] csvLines = response.getBody().split("\n");
+ assertEquals("""
+ "Id","Birth Date\"""", csvLines[0]);
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ @Test
+ void testExportSupportedFormat()
+ {
+ HttpResponse response = Unirest.get(BASE_URL + "/data/person/export/?format=docx").asString();
+ assertEquals(HttpStatus.BAD_REQUEST_400, response.getStatus());
+ assertThat(response.getBody()).contains("Unsupported report format");
+ }
+
+}
diff --git a/src/test/java/com/kingsrook/qqq/backend/javalin/QJavalinProcessHandlerTest.java b/src/test/java/com/kingsrook/qqq/backend/javalin/QJavalinProcessHandlerTest.java
new file mode 100644
index 00000000..3effbf77
--- /dev/null
+++ b/src/test/java/com/kingsrook/qqq/backend/javalin/QJavalinProcessHandlerTest.java
@@ -0,0 +1,479 @@
+/*
+ * QQQ - Low-code Application Framework for Engineers.
+ * Copyright (C) 2021-2022. Kingsrook, LLC
+ * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
+ * contact@kingsrook.com
+ * https://github.com/Kingsrook/
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package com.kingsrook.qqq.backend.javalin;
+
+
+import java.net.URLEncoder;
+import java.nio.charset.Charset;
+import java.util.List;
+import com.kingsrook.qqq.backend.core.actions.async.AsyncJobState;
+import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
+import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
+import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
+import com.kingsrook.qqq.backend.core.utils.JsonUtils;
+import kong.unirest.HttpResponse;
+import kong.unirest.Unirest;
+import org.json.JSONArray;
+import org.json.JSONObject;
+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;
+
+
+/*******************************************************************************
+ ** Unit test for the javalin process handler methods.
+ *******************************************************************************/
+class QJavalinProcessHandlerTest extends QJavalinTestBase
+{
+ private static final int MORE_THAN_TIMEOUT = 500;
+ private static final int LESS_THAN_TIMEOUT = 50;
+
+
+
+ /*******************************************************************************
+ ** test running a process
+ **
+ *******************************************************************************/
+ @Test
+ public void test_processGreetInit()
+ {
+ HttpResponse response = Unirest.get(BASE_URL + "/processes/greet/init?recordsParam=recordIds&recordIds=2,3").asString();
+ assertEquals(200, response.getStatus());
+ JSONObject jsonObject = JsonUtils.toJSONObject(response.getBody());
+ assertNotNull(jsonObject);
+ assertEquals("null X null", jsonObject.getJSONObject("values").getString("outputMessage"));
+ }
+
+
+
+ /*******************************************************************************
+ ** test running a process that requires rows, but we didn't tell it how to get them.
+ **
+ *******************************************************************************/
+ @Test
+ public void test_processRequiresRowsButNotSpecified()
+ {
+ HttpResponse response = Unirest.get(BASE_URL + "/processes/greet/init").asString();
+ assertEquals(200, response.getStatus());
+ JSONObject jsonObject = JsonUtils.toJSONObject(response.getBody());
+ assertNotNull(jsonObject);
+ assertTrue(jsonObject.has("error"));
+ assertTrue(jsonObject.getString("error").contains("Missing input records"));
+ }
+
+
+
+ /*******************************************************************************
+ ** test running a process and telling it rows to load via recordIds param
+ **
+ *******************************************************************************/
+ @Test
+ public void test_processRequiresRowsWithRecordIdParam()
+ {
+ HttpResponse response = Unirest.get(BASE_URL + "/processes/greet/init?recordsParam=recordIds&recordIds=2,3").asString();
+ assertEquals(200, response.getStatus());
+ JSONObject jsonObject = JsonUtils.toJSONObject(response.getBody());
+ assertNotNull(jsonObject);
+ String processUUID = jsonObject.getString("processUUID");
+
+ getProcessRecords(processUUID, 2);
+ }
+
+
+
+ /*******************************************************************************
+ ** test running a process and telling it rows to load via filter JSON
+ **
+ *******************************************************************************/
+ @Test
+ public void test_processRequiresRowsWithFilterJSON()
+ {
+ QQueryFilter queryFilter = new QQueryFilter()
+ .withCriteria(new QFilterCriteria()
+ .withFieldName("id")
+ .withOperator(QCriteriaOperator.IN)
+ .withValues(List.of(3, 4, 5)));
+ String filterJSON = JsonUtils.toJson(queryFilter);
+
+ HttpResponse response = Unirest.get(BASE_URL + "/processes/greet/init?recordsParam=filterJSON&filterJSON=" + URLEncoder.encode(filterJSON, Charset.defaultCharset())).asString();
+ assertEquals(200, response.getStatus());
+ JSONObject jsonObject = JsonUtils.toJSONObject(response.getBody());
+ assertNotNull(jsonObject);
+ String processUUID = jsonObject.getString("processUUID");
+
+ getProcessRecords(processUUID, 3);
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ private JSONObject getProcessRecords(String processUUID, int expectedNoOfRecords)
+ {
+ return (getProcessRecords(processUUID, expectedNoOfRecords, 0, 20));
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ private JSONObject getProcessRecords(String processUUID, int expectedNoOfRecords, int skip, int limit)
+ {
+ HttpResponse response;
+ JSONObject jsonObject;
+ response = Unirest.get(BASE_URL + "/processes/greet/" + processUUID + "/records?skip=" + skip + "&limit=" + limit).asString();
+ jsonObject = JsonUtils.toJSONObject(response.getBody());
+ assertNotNull(jsonObject);
+
+ if(expectedNoOfRecords == 0)
+ {
+ assertFalse(jsonObject.has("records"));
+ }
+ else
+ {
+ assertTrue(jsonObject.has("records"));
+ JSONArray records = jsonObject.getJSONArray("records");
+ assertEquals(expectedNoOfRecords, records.length());
+ }
+ return (jsonObject);
+ }
+
+
+
+ /*******************************************************************************
+ ** test running a process with field values on the query string
+ **
+ *******************************************************************************/
+ @Test
+ public void test_processGreetInitWithQueryValues()
+ {
+ HttpResponse response = Unirest.post(BASE_URL + "/processes/greet/init?recordsParam=recordIds&recordIds=2,3&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 (or init) 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);
+ }
+
+
+
+ /*******************************************************************************
+ ** test getting records back from a process
+ **
+ *******************************************************************************/
+ @Test
+ public void test_processRecords()
+ {
+ HttpResponse response = Unirest.get(BASE_URL + "/processes/greet/init?recordsParam=recordIds&recordIds=2,3&greetingPrefix=Hey&greetingSuffix=Jude").asString();
+ assertEquals(200, response.getStatus());
+ JSONObject jsonObject = JsonUtils.toJSONObject(response.getBody());
+ assertNotNull(jsonObject);
+ String processUUID = jsonObject.getString("processUUID");
+
+ jsonObject = getProcessRecords(processUUID, 2);
+ JSONArray records = jsonObject.getJSONArray("records");
+ JSONObject record0 = records.getJSONObject(0);
+ JSONObject values = record0.getJSONObject("values");
+ assertTrue(values.has("id"));
+ assertTrue(values.has("firstName"));
+ }
+
+
+
+ /*******************************************************************************
+ ** test getting records back from a process with skip & Limit
+ **
+ *******************************************************************************/
+ @Test
+ public void test_processRecordsSkipAndLimit()
+ {
+ HttpResponse response = Unirest.get(BASE_URL + "/processes/greet/init?recordsParam=recordIds&recordIds=1,2,3,4,5").asString();
+ JSONObject jsonObject = JsonUtils.toJSONObject(response.getBody());
+ String processUUID = jsonObject.getString("processUUID");
+
+ getProcessRecords(processUUID, 5);
+ getProcessRecords(processUUID, 1, 4, 5);
+ getProcessRecords(processUUID, 0, 5, 5);
+ }
+
+}
\ No newline at end of file
diff --git a/src/test/java/com/kingsrook/qqq/backend/javalin/QJavalinTestBase.java b/src/test/java/com/kingsrook/qqq/backend/javalin/QJavalinTestBase.java
new file mode 100644
index 00000000..168174c2
--- /dev/null
+++ b/src/test/java/com/kingsrook/qqq/backend/javalin/QJavalinTestBase.java
@@ -0,0 +1,78 @@
+/*
+ * QQQ - Low-code Application Framework for Engineers.
+ * Copyright (C) 2021-2022. Kingsrook, LLC
+ * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
+ * contact@kingsrook.com
+ * https://github.com/Kingsrook/
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package com.kingsrook.qqq.backend.javalin;
+
+
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+
+
+/*******************************************************************************
+ ** base class for javalin implementation tests.
+ *******************************************************************************/
+public class QJavalinTestBase
+{
+ private static final int PORT = 6262;
+ protected static final String BASE_URL = "http://localhost:" + PORT;
+
+ private static QJavalinImplementation qJavalinImplementation;
+
+
+
+ /*******************************************************************************
+ ** Before the class (all) runs, start a javalin server.
+ **
+ *******************************************************************************/
+ @BeforeAll
+ public static void beforeAll()
+ {
+ qJavalinImplementation = new QJavalinImplementation(TestUtils.defineInstance());
+ QJavalinProcessHandler.setAsyncStepTimeoutMillis(250);
+ qJavalinImplementation.startJavalinServer(PORT);
+ }
+
+
+
+ /*******************************************************************************
+ ** Before the class (all) runs, start a javalin server.
+ **
+ *******************************************************************************/
+ @AfterAll
+ public static void afterAll()
+ {
+ qJavalinImplementation.stopJavalinServer();
+ }
+
+
+
+ /*******************************************************************************
+ ** Fully rebuild the test-database before each test runs, for completely known state.
+ **
+ *******************************************************************************/
+ @BeforeEach
+ public void beforeEach() throws Exception
+ {
+ TestUtils.primeTestDatabase();
+ }
+
+}
diff --git a/src/test/java/com/kingsrook/qqq/backend/javalin/TestUtils.java b/src/test/java/com/kingsrook/qqq/backend/javalin/TestUtils.java
new file mode 100644
index 00000000..818c2b53
--- /dev/null
+++ b/src/test/java/com/kingsrook/qqq/backend/javalin/TestUtils.java
@@ -0,0 +1,403 @@
+/*
+ * QQQ - Low-code Application Framework for Engineers.
+ * Copyright (C) 2021-2022. Kingsrook, LLC
+ * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
+ * contact@kingsrook.com
+ * https://github.com/Kingsrook/
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+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.actions.processes.BackendStep;
+import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType;
+import com.kingsrook.qqq.backend.core.processes.implementations.mock.MockBackendStep;
+import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
+import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
+import com.kingsrook.qqq.backend.core.modules.authentication.metadata.QAuthenticationMetaData;
+import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
+import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeType;
+import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeUsage;
+import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
+import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
+import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
+import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
+import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
+import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendStepMetaData;
+import com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData;
+import com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionOutputMetaData;
+import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
+import com.kingsrook.qqq.backend.core.model.metadata.processes.QRecordListMetaData;
+import com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager;
+import com.kingsrook.qqq.backend.module.rdbms.jdbc.QueryManager;
+import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData;
+import org.apache.commons.io.IOUtils;
+import static junit.framework.Assert.assertNotNull;
+
+
+/*******************************************************************************
+ ** Utility methods for unit tests.
+ **
+ *******************************************************************************/
+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";
+
+
+
+ /*******************************************************************************
+ ** Prime a test database (e.g., h2, in-memory)
+ **
+ *******************************************************************************/
+ @SuppressWarnings("unchecked")
+ public static void primeTestDatabase() throws Exception
+ {
+ ConnectionManager connectionManager = new ConnectionManager();
+ Connection connection = connectionManager.getConnection(TestUtils.defineBackend());
+ InputStream primeTestDatabaseSqlStream = TestUtils.class.getResourceAsStream("/prime-test-database.sql");
+ assertNotNull(primeTestDatabaseSqlStream);
+ List lines = (List) IOUtils.readLines(primeTestDatabaseSqlStream);
+ lines = lines.stream().filter(line -> !line.startsWith("-- ")).toList();
+ String joinedSQL = String.join("\n", lines);
+ for(String sql : joinedSQL.split(";"))
+ {
+ QueryManager.executeUpdate(connection, sql);
+ }
+ }
+
+
+
+ /*******************************************************************************
+ ** Run an SQL Query in the test database
+ **
+ *******************************************************************************/
+ public static void runTestSql(String sql, QueryManager.ResultSetProcessor resultSetProcessor) throws Exception
+ {
+ ConnectionManager connectionManager = new ConnectionManager();
+ Connection connection = connectionManager.getConnection(defineBackend());
+ QueryManager.executeStatement(connection, sql, resultSetProcessor);
+ }
+
+
+
+ /*******************************************************************************
+ ** Define the q-instance for testing (h2 rdbms and 'person' table)
+ **
+ *******************************************************************************/
+ public static QInstance defineInstance()
+ {
+ QInstance qInstance = new QInstance();
+ qInstance.setAuthentication(defineAuthentication());
+ qInstance.addBackend(defineBackend());
+ qInstance.addTable(defineTablePerson());
+ qInstance.addProcess(defineProcessGreetPeople());
+ qInstance.addProcess(defineProcessGreetPeopleInteractive());
+ qInstance.addProcess(defineProcessSimpleSleep());
+ qInstance.addProcess(defineProcessScreenThenSleep());
+ qInstance.addProcess(defineProcessSimpleThrow());
+ return (qInstance);
+ }
+
+
+
+ /*******************************************************************************
+ ** Define the authentication used in standard tests - using 'mock' type.
+ **
+ *******************************************************************************/
+ private static QAuthenticationMetaData defineAuthentication()
+ {
+ return new QAuthenticationMetaData()
+ .withName("mock")
+ .withType(QAuthenticationType.MOCK);
+ }
+
+
+
+ /*******************************************************************************
+ ** Define the h2 rdbms backend
+ **
+ *******************************************************************************/
+ public static RDBMSBackendMetaData defineBackend()
+ {
+ RDBMSBackendMetaData rdbmsBackendMetaData = new RDBMSBackendMetaData()
+ .withVendor("h2")
+ .withHostName("mem")
+ .withDatabaseName("test_database")
+ .withUsername("sa")
+ .withPassword("");
+ rdbmsBackendMetaData.setName("default");
+ return (rdbmsBackendMetaData);
+ }
+
+
+
+ /*******************************************************************************
+ ** Define the person table
+ **
+ *******************************************************************************/
+ public static QTableMetaData defineTablePerson()
+ {
+ return new QTableMetaData()
+ .withName("person")
+ .withLabel("Person")
+ .withBackendName(defineBackend().getName())
+ .withPrimaryKeyField("id")
+ .withField(new QFieldMetaData("id", QFieldType.INTEGER))
+ .withField(new QFieldMetaData("createDate", QFieldType.DATE_TIME).withBackendName("create_date"))
+ .withField(new QFieldMetaData("modifyDate", QFieldType.DATE_TIME).withBackendName("modify_date"))
+ .withField(new QFieldMetaData("firstName", QFieldType.STRING).withBackendName("first_name"))
+ .withField(new QFieldMetaData("lastName", QFieldType.STRING).withBackendName("last_name"))
+ .withField(new QFieldMetaData("birthDate", QFieldType.DATE).withBackendName("birth_date"))
+ .withField(new QFieldMetaData("email", QFieldType.STRING));
+ }
+
+
+
+ /*******************************************************************************
+ ** Define the 'greet people' process
+ *******************************************************************************/
+ private static QProcessMetaData defineProcessGreetPeople()
+ {
+ return new QProcessMetaData()
+ .withName("greet")
+ .withTableName("person")
+ .addStep(new QBackendStepMetaData()
+ .withName("prepare")
+ .withCode(new QCodeReference()
+ .withName(MockBackendStep.class.getName())
+ .withCodeType(QCodeType.JAVA)
+ .withCodeUsage(QCodeUsage.BACKEND_STEP)) // todo - needed, or implied in this context?
+ .withInputData(new QFunctionInputMetaData()
+ .withRecordListMetaData(new QRecordListMetaData().withTableName("person"))
+ .withFieldList(List.of(
+ new QFieldMetaData("greetingPrefix", QFieldType.STRING),
+ new QFieldMetaData("greetingSuffix", QFieldType.STRING)
+ )))
+ .withOutputMetaData(new QFunctionOutputMetaData()
+ .withRecordListMetaData(new QRecordListMetaData()
+ .withTableName("person")
+ .addField(new QFieldMetaData("fullGreeting", QFieldType.STRING))
+ )
+ .withFieldList(List.of(new QFieldMetaData("outputMessage", QFieldType.STRING))))
+ );
+ }
+
+
+
+ /*******************************************************************************
+ ** Define an interactive version of the 'greet people' process
+ *******************************************************************************/
+ private static QProcessMetaData defineProcessGreetPeopleInteractive()
+ {
+ return new QProcessMetaData()
+ .withName(PROCESS_NAME_GREET_PEOPLE_INTERACTIVE)
+ .withTableName("person")
+
+ .addStep(new QFrontendStepMetaData()
+ .withName("setup")
+ .withFormField(new QFieldMetaData("greetingPrefix", QFieldType.STRING))
+ .withFormField(new QFieldMetaData("greetingSuffix", QFieldType.STRING))
+ )
+
+ .addStep(new QBackendStepMetaData()
+ .withName("doWork")
+ .withCode(new QCodeReference()
+ .withName(MockBackendStep.class.getName())
+ .withCodeType(QCodeType.JAVA)
+ .withCodeUsage(QCodeUsage.BACKEND_STEP)) // todo - needed, or implied in this context?
+ .withInputData(new QFunctionInputMetaData()
+ .withRecordListMetaData(new QRecordListMetaData().withTableName("person"))
+ .withFieldList(List.of(
+ new QFieldMetaData("greetingPrefix", QFieldType.STRING),
+ new QFieldMetaData("greetingSuffix", QFieldType.STRING)
+ )))
+ .withOutputMetaData(new QFunctionOutputMetaData()
+ .withRecordListMetaData(new QRecordListMetaData()
+ .withTableName("person")
+ .addField(new QFieldMetaData("fullGreeting", QFieldType.STRING))
+ )
+ .withFieldList(List.of(new QFieldMetaData("outputMessage", QFieldType.STRING))))
+ )
+
+ .addStep(new QFrontendStepMetaData()
+ .withName("results")
+ .withFormField(new QFieldMetaData("outputMessage", QFieldType.STRING))
+ );
+ }
+
+
+
+ /*******************************************************************************
+ ** Define a process with just one step that sleeps
+ *******************************************************************************/
+ private static QProcessMetaData defineProcessSimpleSleep()
+ {
+ return new QProcessMetaData()
+ .withName(PROCESS_NAME_SIMPLE_SLEEP)
+ .withIsHidden(true)
+ .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(RunBackendStepInput runBackendStepInput, RunBackendStepOutput runBackendStepOutput) throws QException
+ {
+ try
+ {
+ Thread.sleep(runBackendStepInput.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(RunBackendStepInput runBackendStepInput, RunBackendStepOutput runBackendStepOutput) throws QException
+ {
+ int sleepMillis;
+ try
+ {
+ sleepMillis = runBackendStepInput.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))));
+ }
+ }
+
+}
diff --git a/src/test/resources/prime-test-database.sql b/src/test/resources/prime-test-database.sql
new file mode 100644
index 00000000..be858987
--- /dev/null
+++ b/src/test/resources/prime-test-database.sql
@@ -0,0 +1,39 @@
+--
+-- QQQ - Low-code Application Framework for Engineers.
+-- Copyright (C) 2021-2022. Kingsrook, LLC
+-- 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
+-- contact@kingsrook.com
+-- https://github.com/Kingsrook/
+--
+-- This program is free software: you can redistribute it and/or modify
+-- it under the terms of the GNU Affero General Public License as
+-- published by the Free Software Foundation, either version 3 of the
+-- License, or (at your option) any later version.
+--
+-- This program is distributed in the hope that it will be useful,
+-- but WITHOUT ANY WARRANTY; without even the implied warranty of
+-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+-- GNU Affero General Public License for more details.
+--
+-- You should have received a copy of the GNU Affero General Public License
+-- along with this program. If not, see .
+--
+
+DROP TABLE IF EXISTS person;
+CREATE TABLE person
+(
+ id INT AUTO_INCREMENT,
+ create_date TIMESTAMP DEFAULT now(),
+ modify_date TIMESTAMP DEFAULT now(),
+
+ first_name VARCHAR(80) NOT NULL,
+ last_name VARCHAR(80) NOT NULL,
+ birth_date DATE,
+ email VARCHAR(250) NOT NULL
+);
+
+INSERT INTO person (id, first_name, last_name, birth_date, email) VALUES (1, 'Darin', 'Kelkhoff', '1980-05-31', 'darin.kelkhoff@gmail.com');
+INSERT INTO person (id, first_name, last_name, birth_date, email) VALUES (2, 'James', 'Maes', '1980-05-15', 'jmaes@mmltholdings.com');
+INSERT INTO person (id, first_name, last_name, birth_date, email) VALUES (3, 'Tim', 'Chamberlain', '1976-05-28', 'tchamberlain@mmltholdings.com');
+INSERT INTO person (id, first_name, last_name, birth_date, email) VALUES (4, 'Tyler', 'Samples', '1990-01-01', 'tsamples@mmltholdings.com');
+INSERT INTO person (id, first_name, last_name, birth_date, email) VALUES (5, 'Garret', 'Richardson', '1981-01-01', 'grichardson@mmltholdings.com');