diff --git a/.circleci/config.yml b/.circleci/config.yml index 3e2ff6f9..da983b05 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -51,10 +51,18 @@ commands: module: qqq-backend-module-filesystem - store_jacoco_site: module: qqq-backend-module-rdbms + - store_jacoco_site: + module: qqq-backend-module-api + - store_jacoco_site: + module: qqq-middleware-api - store_jacoco_site: module: qqq-middleware-javalin - store_jacoco_site: module: qqq-middleware-picocli + - store_jacoco_site: + module: qqq-middleware-slack + - store_jacoco_site: + module: qqq-language-support-javascript - store_jacoco_site: module: qqq-sample-project - run: diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/automation/polling/PollingAutomationPerTableRunner.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/automation/polling/PollingAutomationPerTableRunner.java index 89eab8f2..0322a823 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/automation/polling/PollingAutomationPerTableRunner.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/automation/polling/PollingAutomationPerTableRunner.java @@ -278,6 +278,7 @@ public class PollingAutomationPerTableRunner implements Runnable .withPriority(record.getValueInteger("priority")) .withCodeReference(new QCodeReference(RunRecordScriptAutomationHandler.class)) .withValues(MapBuilder.of("scriptId", record.getValue("scriptId"))) + .withIncludeRecordAssociations(true) ); } } @@ -392,6 +393,8 @@ public class PollingAutomationPerTableRunner implements Runnable queryInput.setFilter(filter); + queryInput.setIncludeAssociations(action.getIncludeRecordAssociations()); + return (new QueryAction().execute(queryInput).getRecords()); } diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/interfaces/DeleteInterface.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/interfaces/DeleteInterface.java index 669967d2..43781917 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/interfaces/DeleteInterface.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/interfaces/DeleteInterface.java @@ -50,4 +50,13 @@ public interface DeleteInterface return (false); } + /******************************************************************************* + ** Specify whether this particular module's delete action can & should fetch + ** records before deleting them, e.g., for audits or "not-found-checks" + *******************************************************************************/ + default boolean supportsPreFetchQuery() + { + return (true); + } + } diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/interfaces/UpdateInterface.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/interfaces/UpdateInterface.java index fa87f5e8..e89baeed 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/interfaces/UpdateInterface.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/interfaces/UpdateInterface.java @@ -37,4 +37,14 @@ public interface UpdateInterface ** *******************************************************************************/ UpdateOutput execute(UpdateInput updateInput) throws QException; + + /******************************************************************************* + ** Specify whether this particular module's update action can & should fetch + ** records before updating them, e.g., for audits or "not-found-checks" + *******************************************************************************/ + default boolean supportsPreFetchQuery() + { + return (true); + } + } diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/reporting/BufferedRecordPipe.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/reporting/BufferedRecordPipe.java index 811827c8..0dc1ec09 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/reporting/BufferedRecordPipe.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/reporting/BufferedRecordPipe.java @@ -24,6 +24,7 @@ package com.kingsrook.qqq.backend.core.actions.reporting; import java.util.ArrayList; import java.util.List; +import com.kingsrook.qqq.backend.core.exceptions.QException; import com.kingsrook.qqq.backend.core.model.data.QRecord; @@ -63,7 +64,7 @@ public class BufferedRecordPipe extends RecordPipe ** *******************************************************************************/ @Override - public void addRecord(QRecord record) + public void addRecord(QRecord record) throws QException { buffer.add(record); if(buffer.size() >= bufferSize) @@ -78,7 +79,7 @@ public class BufferedRecordPipe extends RecordPipe /******************************************************************************* ** *******************************************************************************/ - public void finalFlush() + public void finalFlush() throws QException { if(!buffer.isEmpty()) { diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/reporting/RecordPipe.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/reporting/RecordPipe.java index b95e754e..dff2c4de 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/reporting/RecordPipe.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/reporting/RecordPipe.java @@ -26,10 +26,11 @@ import java.util.ArrayList; import java.util.List; import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.TimeUnit; -import java.util.function.Consumer; +import com.kingsrook.qqq.backend.core.exceptions.QException; import com.kingsrook.qqq.backend.core.logging.QLogger; import com.kingsrook.qqq.backend.core.model.data.QRecord; import com.kingsrook.qqq.backend.core.utils.SleepUtils; +import com.kingsrook.qqq.backend.core.utils.lambdas.UnsafeConsumer; /******************************************************************************* @@ -47,7 +48,7 @@ public class RecordPipe private boolean isTerminated = false; - private Consumer> postRecordActions = null; + private UnsafeConsumer, QException> postRecordActions = null; ///////////////////////////////////// // See usage below for explanation // @@ -93,7 +94,7 @@ public class RecordPipe /******************************************************************************* ** Add a record to the pipe. Will block if the pipe is full. Will noop if pipe is terminated. *******************************************************************************/ - public void addRecord(QRecord record) + public void addRecord(QRecord record) throws QException { if(isTerminated) { @@ -109,7 +110,7 @@ public class RecordPipe // (which we'll create as a field in this class, to avoid always re-constructing) // //////////////////////////////////////////////////////////////////////////////////// singleRecordListForPostRecordActions.add(record); - postRecordActions.accept(singleRecordListForPostRecordActions); + postRecordActions.run(singleRecordListForPostRecordActions); record = singleRecordListForPostRecordActions.remove(0); } @@ -152,11 +153,11 @@ public class RecordPipe /******************************************************************************* ** Add a list of records to the pipe. Will block if the pipe is full. Will noop if pipe is terminated. *******************************************************************************/ - public void addRecords(List records) + public void addRecords(List records) throws QException { if(postRecordActions != null) { - postRecordActions.accept(records); + postRecordActions.run(records); } ////////////////////////////////////////////////////////////////////////////////////////////////// @@ -207,7 +208,7 @@ public class RecordPipe /******************************************************************************* ** *******************************************************************************/ - public void setPostRecordActions(Consumer> postRecordActions) + public void setPostRecordActions(UnsafeConsumer, QException> postRecordActions) { this.postRecordActions = postRecordActions; } diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/reporting/RecordPipeBufferedWrapper.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/reporting/RecordPipeBufferedWrapper.java new file mode 100644 index 00000000..08117c06 --- /dev/null +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/reporting/RecordPipeBufferedWrapper.java @@ -0,0 +1,79 @@ +/* + * 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.core.actions.reporting; + + +import java.util.List; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.data.QRecord; + + +/******************************************************************************* + ** Subclass of BufferedRecordPipe, which ultimately sends records down to an + ** original RecordPipe. + ** + ** Meant to be used where: someone passed in a RecordPipe (so they have a reference + ** to it, and they are waiting to read from it), but the producer knows that + ** it will be better to buffer the records, so they want to use a buffered pipe + ** (but they still need the records to end up in the original pipe - thus - + ** it gets wrapped by an object of this class). + *******************************************************************************/ +public class RecordPipeBufferedWrapper extends BufferedRecordPipe +{ + private RecordPipe wrappedPipe; + + + + /******************************************************************************* + ** Constructor - uses default buffer size + ** + *******************************************************************************/ + public RecordPipeBufferedWrapper(RecordPipe wrappedPipe) + { + this.wrappedPipe = wrappedPipe; + } + + + + /******************************************************************************* + ** Constructor - customize buffer size. + ** + *******************************************************************************/ + public RecordPipeBufferedWrapper(Integer bufferSize, RecordPipe wrappedPipe) + { + super(bufferSize); + this.wrappedPipe = wrappedPipe; + } + + + + /******************************************************************************* + ** when it's time to actually add records into the pipe, actually add them + ** into the wrapped pipe! + *******************************************************************************/ + @Override + public void addRecords(List records) throws QException + { + wrappedPipe.addRecords(records); + } + +} diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/scripts/AssociatedScriptContextPrimerInterface.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/scripts/AssociatedScriptContextPrimerInterface.java new file mode 100644 index 00000000..77292400 --- /dev/null +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/scripts/AssociatedScriptContextPrimerInterface.java @@ -0,0 +1,41 @@ +/* + * 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.core.actions.scripts; + + +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.scripts.ExecuteCodeInput; +import com.kingsrook.qqq.backend.core.model.scripts.ScriptRevision; + + +/******************************************************************************* + ** + *******************************************************************************/ +public interface AssociatedScriptContextPrimerInterface +{ + + /******************************************************************************* + ** + *******************************************************************************/ + void primeContext(ExecuteCodeInput executeCodeInput, ScriptRevision scriptRevision) throws QException; + +} diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/scripts/ExecuteCodeAction.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/scripts/ExecuteCodeAction.java index 44db3df9..09f6ebba 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/scripts/ExecuteCodeAction.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/scripts/ExecuteCodeAction.java @@ -25,13 +25,21 @@ package com.kingsrook.qqq.backend.core.actions.scripts; import java.io.Serializable; import java.util.HashMap; import java.util.Map; +import java.util.Objects; import com.kingsrook.qqq.backend.core.actions.scripts.logging.Log4jCodeExecutionLogger; import com.kingsrook.qqq.backend.core.actions.scripts.logging.QCodeExecutionLoggerInterface; +import com.kingsrook.qqq.backend.core.actions.scripts.logging.ScriptExecutionLoggerInterface; +import com.kingsrook.qqq.backend.core.actions.scripts.logging.StoreScriptLogAndScriptLogLineExecutionLogger; import com.kingsrook.qqq.backend.core.exceptions.QCodeException; import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.logging.QLogger; +import com.kingsrook.qqq.backend.core.model.actions.scripts.AbstractRunScriptInput; import com.kingsrook.qqq.backend.core.model.actions.scripts.ExecuteCodeInput; import com.kingsrook.qqq.backend.core.model.actions.scripts.ExecuteCodeOutput; 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.scripts.ScriptRevision; +import com.kingsrook.qqq.backend.core.utils.StringUtils; /******************************************************************************* @@ -49,6 +57,9 @@ import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; *******************************************************************************/ public class ExecuteCodeAction { + private static final QLogger LOG = QLogger.getLogger(ExecuteCodeAction.class); + + /******************************************************************************* ** @@ -68,10 +79,10 @@ public class ExecuteCodeAction try { String languageExecutor = switch(codeReference.getCodeType()) - { - case JAVA -> "com.kingsrook.qqq.backend.core.actions.scripts.QJavaExecutor"; - case JAVA_SCRIPT -> "com.kingsrook.qqq.languages.javascript.QJavaScriptExecutor"; - }; + { + case JAVA -> "com.kingsrook.qqq.backend.core.actions.scripts.QJavaExecutor"; + case JAVA_SCRIPT -> "com.kingsrook.qqq.languages.javascript.QJavaScriptExecutor"; + }; @SuppressWarnings("unchecked") Class executorClass = (Class) Class.forName(languageExecutor); @@ -108,6 +119,91 @@ public class ExecuteCodeAction + /******************************************************************************* + ** + *******************************************************************************/ + public static ExecuteCodeInput setupExecuteCodeInput(AbstractRunScriptInput input, ScriptRevision scriptRevision) + { + ExecuteCodeInput executeCodeInput = new ExecuteCodeInput(); + executeCodeInput.setInput(new HashMap<>(Objects.requireNonNullElseGet(input.getInputValues(), HashMap::new))); + executeCodeInput.setContext(new HashMap<>()); + + Map context = executeCodeInput.getContext(); + if(input.getOutputObject() != null) + { + context.put("output", input.getOutputObject()); + } + + if(input.getScriptUtils() != null) + { + context.put("scriptUtils", input.getScriptUtils()); + } + + executeCodeInput.setCodeReference(new QCodeReference().withInlineCode(scriptRevision.getContents()).withCodeType(QCodeType.JAVA_SCRIPT)); // todo - code type as attribute of script!! + + ExecuteCodeAction.addApiUtilityToContext(context, scriptRevision); + ExecuteCodeAction.setExecutionLoggerInExecuteCodeInput(input, scriptRevision, executeCodeInput); + + return (executeCodeInput); + } + + + + /******************************************************************************* + ** Try to (dynamically) load the ApiScriptUtils object from the api middleware + ** module -- in case the runtime doesn't have that module deployed (e.g, not in + ** the project pom). + *******************************************************************************/ + public static void addApiUtilityToContext(Map context, ScriptRevision scriptRevision) + { + if(!StringUtils.hasContent(scriptRevision.getApiName()) || !StringUtils.hasContent(scriptRevision.getApiVersion())) + { + return; + } + + try + { + Class apiScriptUtilsClass = Class.forName("com.kingsrook.qqq.api.utils.ApiScriptUtils"); + Object apiScriptUtilsObject = apiScriptUtilsClass.getConstructor(String.class, String.class).newInstance(scriptRevision.getApiName(), scriptRevision.getApiVersion()); + context.put("api", (Serializable) apiScriptUtilsObject); + } + catch(ClassNotFoundException e) + { + ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // this is the only exception we're kinda expecting here - so catch for it specifically, and just log.trace - others, warn // + ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + LOG.trace("Couldn't load ApiScriptUtils class - qqq-middleware-api not on the classpath?"); + } + catch(Exception e) + { + LOG.warn("Error adding api utility to script context", e); + } + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private static void setExecutionLoggerInExecuteCodeInput(AbstractRunScriptInput input, ScriptRevision scriptRevision, ExecuteCodeInput executeCodeInput) + { + ///////////////////////////////////////////////////////////////////////////////////////////////// + // let caller supply a logger, or by default use StoreScriptLogAndScriptLogLineExecutionLogger // + ///////////////////////////////////////////////////////////////////////////////////////////////// + QCodeExecutionLoggerInterface executionLogger = Objects.requireNonNullElseGet(input.getLogger(), () -> new StoreScriptLogAndScriptLogLineExecutionLogger(scriptRevision.getScriptId(), scriptRevision.getId())); + executeCodeInput.setExecutionLogger(executionLogger); + if(executionLogger instanceof ScriptExecutionLoggerInterface scriptExecutionLoggerInterface) + { + //////////////////////////////////////////////////////////////////////////////////////////////////// + // if logger is aware of scripts (as opposed to a generic CodeExecution logger), give it the ids. // + //////////////////////////////////////////////////////////////////////////////////////////////////// + scriptExecutionLoggerInterface.setScriptId(scriptRevision.getScriptId()); + scriptExecutionLoggerInterface.setScriptRevisionId(scriptRevision.getId()); + } + } + + + /******************************************************************************* ** *******************************************************************************/ diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/scripts/RunAdHocRecordScriptAction.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/scripts/RunAdHocRecordScriptAction.java index d38e4d63..f440e16f 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/scripts/RunAdHocRecordScriptAction.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/scripts/RunAdHocRecordScriptAction.java @@ -22,15 +22,14 @@ package com.kingsrook.qqq.backend.core.actions.scripts; +import java.io.Serializable; +import java.lang.reflect.Method; import java.util.ArrayList; import java.util.HashMap; +import java.util.List; import java.util.Map; -import java.util.Objects; import java.util.Optional; import com.kingsrook.qqq.backend.core.actions.ActionHelper; -import com.kingsrook.qqq.backend.core.actions.scripts.logging.QCodeExecutionLoggerInterface; -import com.kingsrook.qqq.backend.core.actions.scripts.logging.ScriptExecutionLoggerInterface; -import com.kingsrook.qqq.backend.core.actions.scripts.logging.StoreScriptLogAndScriptLogLineExecutionLogger; import com.kingsrook.qqq.backend.core.actions.tables.GetAction; import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; import com.kingsrook.qqq.backend.core.context.QContext; @@ -49,8 +48,6 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryJoin; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput; import com.kingsrook.qqq.backend.core.model.metadata.code.AdHocScriptCodeReference; -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.tables.QTableMetaData; import com.kingsrook.qqq.backend.core.model.scripts.Script; import com.kingsrook.qqq.backend.core.model.scripts.ScriptRevision; @@ -96,6 +93,7 @@ public class RunAdHocRecordScriptAction QueryInput queryInput = new QueryInput(); queryInput.setTableName(input.getTableName()); queryInput.setFilter(new QQueryFilter(new QFilterCriteria(table.getPrimaryKeyField(), QCriteriaOperator.IN, input.getRecordPrimaryKeyList()))); + queryInput.setIncludeAssociations(true); QueryOutput queryOutput = new QueryAction().execute(queryInput); input.setRecordList(queryOutput.getRecords()); } @@ -112,43 +110,14 @@ public class RunAdHocRecordScriptAction ///////////// // run it! // ///////////// - ExecuteCodeInput executeCodeInput = new ExecuteCodeInput(); - executeCodeInput.setInput(new HashMap<>(Objects.requireNonNullElseGet(input.getInputValues(), HashMap::new))); - executeCodeInput.getInput().put("records", new ArrayList<>(input.getRecordList())); - executeCodeInput.setContext(new HashMap<>()); - if(input.getOutputObject() != null) - { - executeCodeInput.getContext().put("output", input.getOutputObject()); - } - - if(input.getScriptUtils() != null) - { - executeCodeInput.getContext().put("scriptUtils", input.getScriptUtils()); - } - - executeCodeInput.getContext().put("api", new ScriptApi()); - - executeCodeInput.setCodeReference(new QCodeReference().withInlineCode(scriptRevision.getContents()).withCodeType(QCodeType.JAVA_SCRIPT)); // todo - code type as attribute of script!! - - ///////////////////////////////////////////////////////////////////////////////////////////////// - // let caller supply a logger, or by default use StoreScriptLogAndScriptLogLineExecutionLogger // - ///////////////////////////////////////////////////////////////////////////////////////////////// - QCodeExecutionLoggerInterface executionLogger = Objects.requireNonNullElseGet(input.getLogger(), () -> new StoreScriptLogAndScriptLogLineExecutionLogger(scriptRevision.getScriptId(), scriptRevision.getId())); - executeCodeInput.setExecutionLogger(executionLogger); - if(executionLogger instanceof ScriptExecutionLoggerInterface scriptExecutionLoggerInterface) - { - //////////////////////////////////////////////////////////////////////////////////////////////////// - // if logger is aware of scripts (as opposed to a generic CodeExecution logger), give it the ids. // - //////////////////////////////////////////////////////////////////////////////////////////////////// - scriptExecutionLoggerInterface.setScriptId(scriptRevision.getScriptId()); - scriptExecutionLoggerInterface.setScriptRevisionId(scriptRevision.getId()); - } + ExecuteCodeInput executeCodeInput = ExecuteCodeAction.setupExecuteCodeInput(input, scriptRevision); + executeCodeInput.getInput().put("records", getRecordsForScript(input, scriptRevision)); ExecuteCodeOutput executeCodeOutput = new ExecuteCodeOutput(); new ExecuteCodeAction().run(executeCodeInput, executeCodeOutput); output.setOutput(executeCodeOutput.getOutput()); - output.setLogger(executionLogger); + output.setLogger(executeCodeInput.getExecutionLogger()); } catch(Exception e) { @@ -158,6 +127,37 @@ public class RunAdHocRecordScriptAction + /******************************************************************************* + ** + *******************************************************************************/ + private static ArrayList getRecordsForScript(RunAdHocRecordScriptInput input, ScriptRevision scriptRevision) + { + try + { + Class apiScriptUtilsClass = Class.forName("com.kingsrook.qqq.api.utils.ApiScriptUtils"); + Method qRecordListToApiRecordList = apiScriptUtilsClass.getMethod("qRecordListToApiRecordList", List.class, String.class, String.class, String.class); + Object apiRecordList = qRecordListToApiRecordList.invoke(null, input.getRecordList(), input.getTableName(), scriptRevision.getApiName(), scriptRevision.getApiVersion()); + + // noinspection unchecked + return (ArrayList) apiRecordList; + } + catch(ClassNotFoundException e) + { + ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // this is the only exception we're kinda expecting here - so catch for it specifically, and just log.trace - others, warn // + ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + LOG.trace("Couldn't load ApiScriptUtils class - qqq-middleware-api not on the classpath?"); + } + catch(Exception e) + { + LOG.warn("Error converting QRecord list to api record list", e); + } + + return (new ArrayList<>(input.getRecordList())); + } + + + /******************************************************************************* ** *******************************************************************************/ diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/scripts/RunAssociatedScriptAction.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/scripts/RunAssociatedScriptAction.java index 3f1d2c8a..53054839 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/scripts/RunAssociatedScriptAction.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/scripts/RunAssociatedScriptAction.java @@ -25,11 +25,7 @@ package com.kingsrook.qqq.backend.core.actions.scripts; import java.io.Serializable; import java.util.HashMap; import java.util.Map; -import java.util.Objects; import com.kingsrook.qqq.backend.core.actions.ActionHelper; -import com.kingsrook.qqq.backend.core.actions.scripts.logging.QCodeExecutionLoggerInterface; -import com.kingsrook.qqq.backend.core.actions.scripts.logging.ScriptExecutionLoggerInterface; -import com.kingsrook.qqq.backend.core.actions.scripts.logging.StoreScriptLogAndScriptLogLineExecutionLogger; import com.kingsrook.qqq.backend.core.actions.tables.GetAction; import com.kingsrook.qqq.backend.core.exceptions.QException; import com.kingsrook.qqq.backend.core.exceptions.QNotFoundException; @@ -40,8 +36,6 @@ import com.kingsrook.qqq.backend.core.model.actions.scripts.RunAssociatedScriptO import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput; import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetOutput; import com.kingsrook.qqq.backend.core.model.metadata.code.AssociatedScriptCodeReference; -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.scripts.Script; import com.kingsrook.qqq.backend.core.model.scripts.ScriptRevision; @@ -54,6 +48,7 @@ public class RunAssociatedScriptAction private Map scriptRevisionCache = new HashMap<>(); + /******************************************************************************* ** *******************************************************************************/ @@ -61,35 +56,12 @@ public class RunAssociatedScriptAction { ActionHelper.validateSession(input); - ScriptRevision scriptRevision = getScriptRevision(input); + ScriptRevision scriptRevision = getScriptRevision(input); + ExecuteCodeInput executeCodeInput = ExecuteCodeAction.setupExecuteCodeInput(input, scriptRevision); - ExecuteCodeInput executeCodeInput = new ExecuteCodeInput(); - executeCodeInput.setInput(new HashMap<>(input.getInputValues())); - executeCodeInput.setContext(new HashMap<>()); - if(input.getOutputObject() != null) + if(input.getAssociatedScriptContextPrimerInterface() != null) { - executeCodeInput.getContext().put("output", input.getOutputObject()); - } - - if(input.getScriptUtils() != null) - { - executeCodeInput.getContext().put("scriptUtils", input.getScriptUtils()); - } - - executeCodeInput.setCodeReference(new QCodeReference().withInlineCode(scriptRevision.getContents()).withCodeType(QCodeType.JAVA_SCRIPT)); // todo - code type as attribute of script!! - - ///////////////////////////////////////////////////////////////////////////////////////////////// - // let caller supply a logger, or by default use StoreScriptLogAndScriptLogLineExecutionLogger // - ///////////////////////////////////////////////////////////////////////////////////////////////// - QCodeExecutionLoggerInterface executionLogger = Objects.requireNonNullElseGet(input.getLogger(), () -> new StoreScriptLogAndScriptLogLineExecutionLogger(scriptRevision.getScriptId(), scriptRevision.getId())); - executeCodeInput.setExecutionLogger(executionLogger); - if(executionLogger instanceof ScriptExecutionLoggerInterface scriptExecutionLoggerInterface) - { - //////////////////////////////////////////////////////////////////////////////////////////////////// - // if logger is aware of scripts (as opposed to a generic CodeExecution logger), give it the ids. // - //////////////////////////////////////////////////////////////////////////////////////////////////// - scriptExecutionLoggerInterface.setScriptId(scriptRevision.getScriptId()); - scriptExecutionLoggerInterface.setScriptRevisionId(scriptRevision.getId()); + input.getAssociatedScriptContextPrimerInterface().primeContext(executeCodeInput, scriptRevision); } ExecuteCodeOutput executeCodeOutput = new ExecuteCodeOutput(); diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/scripts/ScriptApi.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/scripts/ScriptApi.java index c8fcd9dd..fa2f7154 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/scripts/ScriptApi.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/scripts/ScriptApi.java @@ -47,6 +47,11 @@ import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; /******************************************************************************* ** Object made available to scripts for access to qqq api (e.g., query, insert, ** etc, plus object constructors). + ** + ** Before scripts knew about the API, this class made sense and was used. + ** But, now that scripts do know about the API, it feels like this class could + ** be deleted... but, what about, a QQQ deployment without the API module... + ** In that case, we might still want this class... think about it. *******************************************************************************/ public class ScriptApi implements Serializable { diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/scripts/StoreAssociatedScriptAction.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/scripts/StoreAssociatedScriptAction.java index 1d2d8848..04b1d51f 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/scripts/StoreAssociatedScriptAction.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/scripts/StoreAssociatedScriptAction.java @@ -184,6 +184,8 @@ public class StoreAssociatedScriptAction QRecord scriptRevision = new QRecord() .withValue("scriptId", script.getValue("id")) .withValue("contents", input.getCode()) + .withValue("apiName", input.getApiName()) + .withValue("apiVersion", input.getApiVersion()) .withValue("commitMessage", commitMessage) .withValue("sequenceNo", nextSequenceNo); diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/scripts/TestScriptActionInterface.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/scripts/TestScriptActionInterface.java index 47d741f8..92499b87 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/scripts/TestScriptActionInterface.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/scripts/TestScriptActionInterface.java @@ -32,6 +32,7 @@ import com.kingsrook.qqq.backend.core.model.actions.scripts.ExecuteCodeOutput; import com.kingsrook.qqq.backend.core.model.actions.scripts.TestScriptInput; import com.kingsrook.qqq.backend.core.model.actions.scripts.TestScriptOutput; import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; +import com.kingsrook.qqq.backend.core.model.scripts.ScriptRevision; /******************************************************************************* @@ -47,7 +48,7 @@ public interface TestScriptActionInterface ** Note - such a method may want or need to put an "output" object into the ** executeCodeInput's context map. *******************************************************************************/ - void setupTestScriptInput(TestScriptInput testScriptInput, ExecuteCodeInput executeCodeInput); + void setupTestScriptInput(TestScriptInput testScriptInput, ExecuteCodeInput executeCodeInput) throws QException; /******************************************************************************* @@ -87,12 +88,21 @@ public interface TestScriptActionInterface BuildScriptLogAndScriptLogLineExecutionLogger executionLogger = new BuildScriptLogAndScriptLogLineExecutionLogger(null, null); executeCodeInput.setExecutionLogger(executionLogger); - setupTestScriptInput(input, executeCodeInput); - - ExecuteCodeOutput executeCodeOutput = new ExecuteCodeOutput(); - try { + setupTestScriptInput(input, executeCodeInput); + + ScriptRevision scriptRevision = new ScriptRevision().withApiName(input.getApiName()).withApiVersion(input.getApiVersion()); + + if(this instanceof AssociatedScriptContextPrimerInterface associatedScriptContextPrimerInterface) + { + associatedScriptContextPrimerInterface.primeContext(executeCodeInput, scriptRevision); + } + + ExecuteCodeOutput executeCodeOutput = new ExecuteCodeOutput(); + + ExecuteCodeAction.addApiUtilityToContext(executeCodeInput.getContext(), scriptRevision); + new ExecuteCodeAction().run(executeCodeInput, executeCodeOutput); output.setOutputObject(processTestScriptOutput(executeCodeOutput)); } diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/tables/DeleteAction.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/tables/DeleteAction.java index 195200e7..ec240c2c 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/tables/DeleteAction.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/tables/DeleteAction.java @@ -76,13 +76,13 @@ public class DeleteAction QBackendModuleDispatcher qBackendModuleDispatcher = new QBackendModuleDispatcher(); QBackendModuleInterface qModule = qBackendModuleDispatcher.getQBackendModule(deleteInput.getBackend()); + DeleteInterface deleteInterface = qModule.getDeleteInterface(); if(CollectionUtils.nullSafeHasContents(deleteInput.getPrimaryKeys()) && deleteInput.getQueryFilter() != null) { throw (new QException("A delete request may not contain both a list of primary keys and a query filter.")); } - DeleteInterface deleteInterface = qModule.getDeleteInterface(); if(deleteInput.getQueryFilter() != null && !deleteInterface.supportsQueryFilterInput()) { LOG.info("Querying for primary keys, for backend module " + qModule.getBackendType() + " which does not support queryFilter input for deletes"); @@ -99,8 +99,8 @@ public class DeleteAction } } - List recordListForAudit = getRecordListForAuditIfNeeded(deleteInput); - List recordsWithValidationErrors = validateRecordsExistAndCanBeAccessed(deleteInput, recordListForAudit); + List recordListForAudit = deleteInterface.supportsPreFetchQuery() ? getRecordListForAuditIfNeeded(deleteInput) : new ArrayList<>(); + List recordsWithValidationErrors = deleteInterface.supportsPreFetchQuery() ? validateRecordsExistAndCanBeAccessed(deleteInput, recordListForAudit) : new ArrayList<>(); DeleteOutput deleteOutput = deleteInterface.execute(deleteInput); diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/tables/QueryAction.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/tables/QueryAction.java index 2154e7a9..49464945 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/tables/QueryAction.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/tables/QueryAction.java @@ -35,6 +35,7 @@ import com.kingsrook.qqq.backend.core.actions.customizers.AbstractPostQueryCusto import com.kingsrook.qqq.backend.core.actions.customizers.QCodeLoader; import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers; import com.kingsrook.qqq.backend.core.actions.reporting.BufferedRecordPipe; +import com.kingsrook.qqq.backend.core.actions.reporting.RecordPipeBufferedWrapper; import com.kingsrook.qqq.backend.core.actions.values.QPossibleValueTranslator; import com.kingsrook.qqq.backend.core.actions.values.QValueFormatter; import com.kingsrook.qqq.backend.core.context.QContext; @@ -85,14 +86,15 @@ public class QueryAction if(queryInput.getRecordPipe() != null) { queryInput.getRecordPipe().setPostRecordActions(this::postRecordActions); - } - if(queryInput.getIncludeAssociations() && queryInput.getRecordPipe() != null) - { - ////////////////////////////////////////////// - // todo - support this in the future maybe? // - ////////////////////////////////////////////// - throw (new QException("Associations may not be fetched into a RecordPipe.")); + if(queryInput.getIncludeAssociations()) + { + ////////////////////////////////////////////////////////////////////////////////////////// + // if the user requested to include associations, it's important that that is buffered, // + // (for performance reasons), so, wrap the user's pipe with a buffer // + ////////////////////////////////////////////////////////////////////////////////////////// + queryInput.setRecordPipe(new RecordPipeBufferedWrapper(queryInput.getRecordPipe())); + } } QBackendModuleDispatcher qBackendModuleDispatcher = new QBackendModuleDispatcher(); @@ -111,11 +113,6 @@ public class QueryAction postRecordActions(queryOutput.getRecords()); } - if(queryInput.getIncludeAssociations()) - { - manageAssociations(queryInput, queryOutput); - } - return queryOutput; } @@ -124,8 +121,9 @@ public class QueryAction /******************************************************************************* ** *******************************************************************************/ - private void manageAssociations(QueryInput queryInput, QueryOutput queryOutput) throws QException + private void manageAssociations(QueryInput queryInput, List queryOutputRecords) throws QException { + LOG.info("In manageAssociations for " + queryInput.getTableName() + " with " + queryOutputRecords.size() + " records"); QTableMetaData table = queryInput.getTable(); for(Association association : CollectionUtils.nonNullList(table.getAssociations())) { @@ -149,7 +147,7 @@ public class QueryAction { JoinOn joinOn = join.getJoinOns().get(0); Set values = new HashSet<>(); - for(QRecord record : queryOutput.getRecords()) + for(QRecord record : queryOutputRecords) { Serializable value = record.getValue(joinOn.getLeftField()); values.add(value); @@ -161,7 +159,7 @@ public class QueryAction { filter.setBooleanOperator(QQueryFilter.BooleanOperator.OR); - for(QRecord record : queryOutput.getRecords()) + for(QRecord record : queryOutputRecords) { QQueryFilter subFilter = new QQueryFilter(); filter.addSubFilter(subFilter); @@ -229,7 +227,7 @@ public class QueryAction ** not one created via List.of()). This may include setting display values, ** translating possible values, and running post-record customizations. *******************************************************************************/ - public void postRecordActions(List records) + public void postRecordActions(List records) throws QException { if(this.postQueryRecordCustomizer.isPresent()) { @@ -250,6 +248,11 @@ public class QueryAction QValueFormatter.setDisplayValuesInRecords(queryInput.getTable(), records); } + if(queryInput.getIncludeAssociations()) + { + manageAssociations(queryInput, records); + } + ////////////////////////////// // mask any password fields // ////////////////////////////// diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/tables/UpdateAction.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/tables/UpdateAction.java index 0ada6d9e..17663aa7 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/tables/UpdateAction.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/tables/UpdateAction.java @@ -33,6 +33,7 @@ import com.kingsrook.qqq.backend.core.actions.ActionHelper; import com.kingsrook.qqq.backend.core.actions.audits.DMLAuditAction; import com.kingsrook.qqq.backend.core.actions.automation.AutomationStatus; import com.kingsrook.qqq.backend.core.actions.automation.RecordAutomationStatusUpdater; +import com.kingsrook.qqq.backend.core.actions.interfaces.UpdateInterface; import com.kingsrook.qqq.backend.core.actions.tables.helpers.ValidateRecordSecurityLockHelper; import com.kingsrook.qqq.backend.core.actions.values.ValueBehaviorApplier; import com.kingsrook.qqq.backend.core.context.QContext; @@ -87,18 +88,24 @@ public class UpdateAction ValueBehaviorApplier.applyFieldBehaviors(updateInput.getInstance(), updateInput.getTable(), updateInput.getRecords()); // todo - need to handle records with errors coming out of here... - List oldRecordList = getOldRecordListForAuditIfNeeded(updateInput); - QBackendModuleDispatcher qBackendModuleDispatcher = new QBackendModuleDispatcher(); QBackendModuleInterface qModule = qBackendModuleDispatcher.getQBackendModule(updateInput.getBackend()); + UpdateInterface updateInterface = qModule.getUpdateInterface(); + + List oldRecordList = updateInterface.supportsPreFetchQuery() ? getOldRecordListForAuditIfNeeded(updateInput) : new ArrayList<>(); validatePrimaryKeysAreGiven(updateInput); - validateRecordsExistAndCanBeAccessed(updateInput, oldRecordList); + + if(updateInterface.supportsPreFetchQuery()) + { + validateRecordsExistAndCanBeAccessed(updateInput, oldRecordList); + } + validateRequiredFields(updateInput); ValidateRecordSecurityLockHelper.validateSecurityFields(updateInput.getTable(), updateInput.getRecords(), ValidateRecordSecurityLockHelper.Action.UPDATE); // todo pre-customization - just get to modify the request? - UpdateOutput updateOutput = qModule.getUpdateInterface().execute(updateInput); + UpdateOutput updateOutput = updateInterface.execute(updateInput); // todo post-customization - can do whatever w/ the result if you want List errors = updateOutput.getRecords().stream().flatMap(r -> r.getErrors().stream()).toList(); diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/tables/helpers/ValidateRecordSecurityLockHelper.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/tables/helpers/ValidateRecordSecurityLockHelper.java index c69a2fc0..ae44f8fe 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/tables/helpers/ValidateRecordSecurityLockHelper.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/tables/helpers/ValidateRecordSecurityLockHelper.java @@ -261,7 +261,7 @@ public class ValidateRecordSecurityLockHelper QSecurityKeyType securityKeyType = QContext.getQInstance().getSecurityKeyType(recordSecurityLock.getSecurityKeyType()); if(StringUtils.hasContent(securityKeyType.getAllAccessKeyName()) && QContext.getQSession().hasSecurityKeyValue(securityKeyType.getAllAccessKeyName(), true, QFieldType.BOOLEAN)) { - LOG.debug("Session has " + securityKeyType.getAllAccessKeyName() + " - not checking this lock."); + LOG.trace("Session has " + securityKeyType.getAllAccessKeyName() + " - not checking this lock."); } else { diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/adapters/CsvToQRecordAdapter.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/adapters/CsvToQRecordAdapter.java index 48dffd16..46f51d58 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/adapters/CsvToQRecordAdapter.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/adapters/CsvToQRecordAdapter.java @@ -31,6 +31,7 @@ import java.util.List; import java.util.Map; import java.util.function.Consumer; import com.kingsrook.qqq.backend.core.actions.reporting.RecordPipe; +import com.kingsrook.qqq.backend.core.exceptions.QException; import com.kingsrook.qqq.backend.core.model.actions.shared.mapping.AbstractQFieldMapping; import com.kingsrook.qqq.backend.core.model.data.QRecord; import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; @@ -61,7 +62,7 @@ public class CsvToQRecordAdapter ** using a given mapping. ** *******************************************************************************/ - public void buildRecordsFromCsv(RecordPipe recordPipe, String csv, QTableMetaData table, AbstractQFieldMapping mapping, Consumer recordCustomizer) + public void buildRecordsFromCsv(RecordPipe recordPipe, String csv, QTableMetaData table, AbstractQFieldMapping mapping, Consumer recordCustomizer) throws QException { buildRecordsFromCsv(new InputWrapper().withRecordPipe(recordPipe).withCsv(csv).withTable(table).withMapping(mapping).withRecordCustomizer(recordCustomizer)); } @@ -73,7 +74,7 @@ public class CsvToQRecordAdapter ** using a given mapping. ** *******************************************************************************/ - public List buildRecordsFromCsv(String csv, QTableMetaData table, AbstractQFieldMapping mapping) + public List buildRecordsFromCsv(String csv, QTableMetaData table, AbstractQFieldMapping mapping) throws QException { buildRecordsFromCsv(new InputWrapper().withCsv(csv).withTable(table).withMapping(mapping)); return (recordList); @@ -87,7 +88,7 @@ public class CsvToQRecordAdapter ** ** todo - meta-data validation, type handling *******************************************************************************/ - public void buildRecordsFromCsv(InputWrapper inputWrapper) + public void buildRecordsFromCsv(InputWrapper inputWrapper) throws QException { String csv = inputWrapper.getCsv(); AbstractQFieldMapping mapping = inputWrapper.getMapping(); @@ -297,7 +298,7 @@ public class CsvToQRecordAdapter /******************************************************************************* ** Add a record - either to the pipe, or list, whichever we're building. *******************************************************************************/ - private void addRecord(QRecord record) + private void addRecord(QRecord record) throws QException { if(recordPipe != null) { diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/scripts/AbstractRunScriptInput.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/scripts/AbstractRunScriptInput.java new file mode 100644 index 00000000..f6d76a1f --- /dev/null +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/scripts/AbstractRunScriptInput.java @@ -0,0 +1,198 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2023. 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.core.model.actions.scripts; + + +import java.io.Serializable; +import java.util.Map; +import com.kingsrook.qqq.backend.core.actions.scripts.logging.QCodeExecutionLoggerInterface; +import com.kingsrook.qqq.backend.core.model.actions.AbstractTableActionInput; +import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; + + +/******************************************************************************* + ** Base class for input wrappers that end up running scripts (ExecuteCodeAction) + *******************************************************************************/ +public class AbstractRunScriptInput extends AbstractTableActionInput +{ + private C codeReference; + private Map inputValues; + private QCodeExecutionLoggerInterface logger; + private Serializable outputObject; + private Serializable scriptUtils; + + + + /******************************************************************************* + ** Getter for codeReference + *******************************************************************************/ + public C getCodeReference() + { + return (this.codeReference); + } + + + + /******************************************************************************* + ** Setter for codeReference + *******************************************************************************/ + public void setCodeReference(C codeReference) + { + this.codeReference = codeReference; + } + + + + /******************************************************************************* + ** Fluent setter for codeReference + *******************************************************************************/ + public AbstractRunScriptInput withCodeReference(C codeReference) + { + this.codeReference = codeReference; + return (this); + } + + + + /******************************************************************************* + ** Getter for inputValues + *******************************************************************************/ + public Map getInputValues() + { + return (this.inputValues); + } + + + + /******************************************************************************* + ** Setter for inputValues + *******************************************************************************/ + public void setInputValues(Map inputValues) + { + this.inputValues = inputValues; + } + + + + /******************************************************************************* + ** Fluent setter for inputValues + *******************************************************************************/ + public AbstractRunScriptInput withInputValues(Map inputValues) + { + this.inputValues = inputValues; + return (this); + } + + + + /******************************************************************************* + ** Getter for logger + *******************************************************************************/ + public QCodeExecutionLoggerInterface getLogger() + { + return (this.logger); + } + + + + /******************************************************************************* + ** Setter for logger + *******************************************************************************/ + public void setLogger(QCodeExecutionLoggerInterface logger) + { + this.logger = logger; + } + + + + /******************************************************************************* + ** Fluent setter for logger + *******************************************************************************/ + public AbstractRunScriptInput withLogger(QCodeExecutionLoggerInterface logger) + { + this.logger = logger; + return (this); + } + + + + /******************************************************************************* + ** Getter for outputObject + *******************************************************************************/ + public Serializable getOutputObject() + { + return (this.outputObject); + } + + + + /******************************************************************************* + ** Setter for outputObject + *******************************************************************************/ + public void setOutputObject(Serializable outputObject) + { + this.outputObject = outputObject; + } + + + + /******************************************************************************* + ** Fluent setter for outputObject + *******************************************************************************/ + public AbstractRunScriptInput withOutputObject(Serializable outputObject) + { + this.outputObject = outputObject; + return (this); + } + + + + /******************************************************************************* + ** Getter for scriptUtils + *******************************************************************************/ + public Serializable getScriptUtils() + { + return (this.scriptUtils); + } + + + + /******************************************************************************* + ** Setter for scriptUtils + *******************************************************************************/ + public void setScriptUtils(Serializable scriptUtils) + { + this.scriptUtils = scriptUtils; + } + + + + /******************************************************************************* + ** Fluent setter for scriptUtils + *******************************************************************************/ + public AbstractRunScriptInput withScriptUtils(Serializable scriptUtils) + { + this.scriptUtils = scriptUtils; + return (this); + } + +} diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/scripts/RunAdHocRecordScriptInput.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/scripts/RunAdHocRecordScriptInput.java index cf097f4f..f8191b57 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/scripts/RunAdHocRecordScriptInput.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/scripts/RunAdHocRecordScriptInput.java @@ -24,9 +24,6 @@ package com.kingsrook.qqq.backend.core.model.actions.scripts; import java.io.Serializable; import java.util.List; -import java.util.Map; -import com.kingsrook.qqq.backend.core.actions.scripts.logging.QCodeExecutionLoggerInterface; -import com.kingsrook.qqq.backend.core.model.actions.AbstractTableActionInput; import com.kingsrook.qqq.backend.core.model.data.QRecord; import com.kingsrook.qqq.backend.core.model.metadata.code.AdHocScriptCodeReference; @@ -34,18 +31,10 @@ import com.kingsrook.qqq.backend.core.model.metadata.code.AdHocScriptCodeReferen /******************************************************************************* ** *******************************************************************************/ -public class RunAdHocRecordScriptInput extends AbstractTableActionInput +public class RunAdHocRecordScriptInput extends AbstractRunScriptInput { - private AdHocScriptCodeReference codeReference; - private Map inputValues; - private List recordPrimaryKeyList; // can either supply recordList, or recordPrimaryKeyList - private List recordList; - private String tableName; - private QCodeExecutionLoggerInterface logger; - - private Serializable outputObject; - - private Serializable scriptUtils; + private List recordPrimaryKeyList; // can either supply recordList, or recordPrimaryKeyList + private List recordList; @@ -58,189 +47,6 @@ public class RunAdHocRecordScriptInput extends AbstractTableActionInput - /******************************************************************************* - ** Getter for inputValues - ** - *******************************************************************************/ - public Map getInputValues() - { - return inputValues; - } - - - - /******************************************************************************* - ** Setter for inputValues - ** - *******************************************************************************/ - public void setInputValues(Map inputValues) - { - this.inputValues = inputValues; - } - - - - /******************************************************************************* - ** Fluent setter for inputValues - ** - *******************************************************************************/ - public RunAdHocRecordScriptInput withInputValues(Map inputValues) - { - this.inputValues = inputValues; - return (this); - } - - - - /******************************************************************************* - ** Getter for outputObject - ** - *******************************************************************************/ - public Serializable getOutputObject() - { - return outputObject; - } - - - - /******************************************************************************* - ** Setter for outputObject - ** - *******************************************************************************/ - public void setOutputObject(Serializable outputObject) - { - this.outputObject = outputObject; - } - - - - /******************************************************************************* - ** Fluent setter for outputObject - ** - *******************************************************************************/ - public RunAdHocRecordScriptInput withOutputObject(Serializable outputObject) - { - this.outputObject = outputObject; - return (this); - } - - - - /******************************************************************************* - ** Getter for logger - *******************************************************************************/ - public QCodeExecutionLoggerInterface getLogger() - { - return (this.logger); - } - - - - /******************************************************************************* - ** Setter for logger - *******************************************************************************/ - public void setLogger(QCodeExecutionLoggerInterface logger) - { - this.logger = logger; - } - - - - /******************************************************************************* - ** Fluent setter for logger - *******************************************************************************/ - public RunAdHocRecordScriptInput withLogger(QCodeExecutionLoggerInterface logger) - { - this.logger = logger; - return (this); - } - - - - /******************************************************************************* - ** Getter for scriptUtils - ** - *******************************************************************************/ - public Serializable getScriptUtils() - { - return scriptUtils; - } - - - - /******************************************************************************* - ** Setter for scriptUtils - ** - *******************************************************************************/ - public void setScriptUtils(Serializable scriptUtils) - { - this.scriptUtils = scriptUtils; - } - - - - /******************************************************************************* - ** Getter for codeReference - *******************************************************************************/ - public AdHocScriptCodeReference getCodeReference() - { - return (this.codeReference); - } - - - - /******************************************************************************* - ** Setter for codeReference - *******************************************************************************/ - public void setCodeReference(AdHocScriptCodeReference codeReference) - { - this.codeReference = codeReference; - } - - - - /******************************************************************************* - ** Fluent setter for codeReference - *******************************************************************************/ - public RunAdHocRecordScriptInput withCodeReference(AdHocScriptCodeReference codeReference) - { - this.codeReference = codeReference; - return (this); - } - - - - /******************************************************************************* - ** Getter for tableName - *******************************************************************************/ - public String getTableName() - { - return (this.tableName); - } - - - - /******************************************************************************* - ** Setter for tableName - *******************************************************************************/ - public void setTableName(String tableName) - { - this.tableName = tableName; - } - - - - /******************************************************************************* - ** Fluent setter for tableName - *******************************************************************************/ - public RunAdHocRecordScriptInput withTableName(String tableName) - { - this.tableName = tableName; - return (this); - } - - - /******************************************************************************* ** Getter for recordList *******************************************************************************/ diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/scripts/RunAssociatedScriptInput.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/scripts/RunAssociatedScriptInput.java index 4da5c558..7c6a5187 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/scripts/RunAssociatedScriptInput.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/scripts/RunAssociatedScriptInput.java @@ -22,187 +22,46 @@ package com.kingsrook.qqq.backend.core.model.actions.scripts; -import java.io.Serializable; -import java.util.Map; -import com.kingsrook.qqq.backend.core.actions.scripts.logging.QCodeExecutionLoggerInterface; -import com.kingsrook.qqq.backend.core.model.actions.AbstractTableActionInput; +import com.kingsrook.qqq.backend.core.actions.scripts.AssociatedScriptContextPrimerInterface; import com.kingsrook.qqq.backend.core.model.metadata.code.AssociatedScriptCodeReference; /******************************************************************************* ** *******************************************************************************/ -public class RunAssociatedScriptInput extends AbstractTableActionInput +public class RunAssociatedScriptInput extends AbstractRunScriptInput { - private AssociatedScriptCodeReference codeReference; - private Map inputValues; - private QCodeExecutionLoggerInterface logger; - - private Serializable outputObject; - - private Serializable scriptUtils; + private AssociatedScriptContextPrimerInterface associatedScriptContextPrimerInterface; /******************************************************************************* - ** + ** Getter for associatedScriptContextPrimerInterface *******************************************************************************/ - public RunAssociatedScriptInput() + public AssociatedScriptContextPrimerInterface getAssociatedScriptContextPrimerInterface() { + return (this.associatedScriptContextPrimerInterface); } /******************************************************************************* - ** Getter for codeReference - ** + ** Setter for associatedScriptContextPrimerInterface *******************************************************************************/ - public AssociatedScriptCodeReference getCodeReference() + public void setAssociatedScriptContextPrimerInterface(AssociatedScriptContextPrimerInterface associatedScriptContextPrimerInterface) { - return codeReference; + this.associatedScriptContextPrimerInterface = associatedScriptContextPrimerInterface; } /******************************************************************************* - ** Setter for codeReference - ** + ** Fluent setter for associatedScriptContextPrimerInterface *******************************************************************************/ - public void setCodeReference(AssociatedScriptCodeReference codeReference) + public RunAssociatedScriptInput withAssociatedScriptContextPrimerInterface(AssociatedScriptContextPrimerInterface associatedScriptContextPrimerInterface) { - this.codeReference = codeReference; - } - - - - /******************************************************************************* - ** Fluent setter for codeReference - ** - *******************************************************************************/ - public RunAssociatedScriptInput withCodeReference(AssociatedScriptCodeReference codeReference) - { - this.codeReference = codeReference; + this.associatedScriptContextPrimerInterface = associatedScriptContextPrimerInterface; return (this); } - - - /******************************************************************************* - ** Getter for inputValues - ** - *******************************************************************************/ - public Map getInputValues() - { - return inputValues; - } - - - - /******************************************************************************* - ** Setter for inputValues - ** - *******************************************************************************/ - public void setInputValues(Map inputValues) - { - this.inputValues = inputValues; - } - - - - /******************************************************************************* - ** Fluent setter for inputValues - ** - *******************************************************************************/ - public RunAssociatedScriptInput withInputValues(Map inputValues) - { - this.inputValues = inputValues; - return (this); - } - - - - /******************************************************************************* - ** Getter for outputObject - ** - *******************************************************************************/ - public Serializable getOutputObject() - { - return outputObject; - } - - - - /******************************************************************************* - ** Setter for outputObject - ** - *******************************************************************************/ - public void setOutputObject(Serializable outputObject) - { - this.outputObject = outputObject; - } - - - - /******************************************************************************* - ** Fluent setter for outputObject - ** - *******************************************************************************/ - public RunAssociatedScriptInput withOutputObject(Serializable outputObject) - { - this.outputObject = outputObject; - return (this); - } - - - - /******************************************************************************* - ** Getter for logger - *******************************************************************************/ - public QCodeExecutionLoggerInterface getLogger() - { - return (this.logger); - } - - - - /******************************************************************************* - ** Setter for logger - *******************************************************************************/ - public void setLogger(QCodeExecutionLoggerInterface logger) - { - this.logger = logger; - } - - - - /******************************************************************************* - ** Fluent setter for logger - *******************************************************************************/ - public RunAssociatedScriptInput withLogger(QCodeExecutionLoggerInterface logger) - { - this.logger = logger; - return (this); - } - - - - /******************************************************************************* - ** Getter for scriptUtils - ** - *******************************************************************************/ - public Serializable getScriptUtils() - { - return scriptUtils; - } - - - - /******************************************************************************* - ** Setter for scriptUtils - ** - *******************************************************************************/ - public void setScriptUtils(Serializable scriptUtils) - { - this.scriptUtils = scriptUtils; - } } diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/scripts/StoreAssociatedScriptInput.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/scripts/StoreAssociatedScriptInput.java index 1d522c38..e2e52510 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/scripts/StoreAssociatedScriptInput.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/scripts/StoreAssociatedScriptInput.java @@ -35,6 +35,8 @@ public class StoreAssociatedScriptInput extends AbstractTableActionInput private Serializable recordPrimaryKey; private String code; + private String apiName; + private String apiVersion; private String commitMessage; @@ -183,4 +185,66 @@ public class StoreAssociatedScriptInput extends AbstractTableActionInput return (this); } + + + /******************************************************************************* + ** Getter for apiName + *******************************************************************************/ + public String getApiName() + { + return (this.apiName); + } + + + + /******************************************************************************* + ** Setter for apiName + *******************************************************************************/ + public void setApiName(String apiName) + { + this.apiName = apiName; + } + + + + /******************************************************************************* + ** Fluent setter for apiName + *******************************************************************************/ + public StoreAssociatedScriptInput withApiName(String apiName) + { + this.apiName = apiName; + return (this); + } + + + + /******************************************************************************* + ** Getter for apiVersion + *******************************************************************************/ + public String getApiVersion() + { + return (this.apiVersion); + } + + + + /******************************************************************************* + ** Setter for apiVersion + *******************************************************************************/ + public void setApiVersion(String apiVersion) + { + this.apiVersion = apiVersion; + } + + + + /******************************************************************************* + ** Fluent setter for apiVersion + *******************************************************************************/ + public StoreAssociatedScriptInput withApiVersion(String apiVersion) + { + this.apiVersion = apiVersion; + return (this); + } + } diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/scripts/TestScriptInput.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/scripts/TestScriptInput.java index 8a116221..8b5c4939 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/scripts/TestScriptInput.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/scripts/TestScriptInput.java @@ -36,6 +36,9 @@ public class TestScriptInput extends AbstractTableActionInput private Map inputValues; private QCodeReference codeReference; + private String apiName; + private String apiVersion; + /******************************************************************************* @@ -113,4 +116,66 @@ public class TestScriptInput extends AbstractTableActionInput return (this); } + + + /******************************************************************************* + ** Getter for apiName + *******************************************************************************/ + public String getApiName() + { + return (this.apiName); + } + + + + /******************************************************************************* + ** Setter for apiName + *******************************************************************************/ + public void setApiName(String apiName) + { + this.apiName = apiName; + } + + + + /******************************************************************************* + ** Fluent setter for apiName + *******************************************************************************/ + public TestScriptInput withApiName(String apiName) + { + this.apiName = apiName; + return (this); + } + + + + /******************************************************************************* + ** Getter for apiVersion + *******************************************************************************/ + public String getApiVersion() + { + return (this.apiVersion); + } + + + + /******************************************************************************* + ** Setter for apiVersion + *******************************************************************************/ + public void setApiVersion(String apiVersion) + { + this.apiVersion = apiVersion; + } + + + + /******************************************************************************* + ** Fluent setter for apiVersion + *******************************************************************************/ + public TestScriptInput withApiVersion(String apiVersion) + { + this.apiVersion = apiVersion; + return (this); + } + } diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/query/QueryOutput.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/query/QueryOutput.java index a9e19342..da9dad45 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/query/QueryOutput.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/query/QueryOutput.java @@ -24,6 +24,7 @@ package com.kingsrook.qqq.backend.core.model.actions.tables.query; import java.io.Serializable; import java.util.List; +import com.kingsrook.qqq.backend.core.exceptions.QException; import com.kingsrook.qqq.backend.core.model.actions.AbstractActionOutput; import com.kingsrook.qqq.backend.core.model.data.QRecord; @@ -63,7 +64,7 @@ public class QueryOutput extends AbstractActionOutput implements Serializable ** that could be read asynchronously, at any time, by another thread - SO - only ** completely populated records should be passed into this method. *******************************************************************************/ - public void addRecord(QRecord record) + public void addRecord(QRecord record) throws QException { storage.addRecord(record); } @@ -73,7 +74,7 @@ public class QueryOutput extends AbstractActionOutput implements Serializable /******************************************************************************* ** add a list of records to this output *******************************************************************************/ - public void addRecords(List records) + public void addRecords(List records) throws QException { storage.addRecords(records); } diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/query/QueryOutputRecordPipe.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/query/QueryOutputRecordPipe.java index b4984a2e..6ca24f02 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/query/QueryOutputRecordPipe.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/query/QueryOutputRecordPipe.java @@ -24,6 +24,7 @@ package com.kingsrook.qqq.backend.core.model.actions.tables.query; import java.util.List; import com.kingsrook.qqq.backend.core.actions.reporting.RecordPipe; +import com.kingsrook.qqq.backend.core.exceptions.QException; import com.kingsrook.qqq.backend.core.logging.QLogger; import com.kingsrook.qqq.backend.core.model.data.QRecord; @@ -53,7 +54,7 @@ class QueryOutputRecordPipe implements QueryOutputStorageInterface ** add a record to this output *******************************************************************************/ @Override - public void addRecord(QRecord record) + public void addRecord(QRecord record) throws QException { recordPipe.addRecord(record); } @@ -64,7 +65,7 @@ class QueryOutputRecordPipe implements QueryOutputStorageInterface ** add a list of records to this output *******************************************************************************/ @Override - public void addRecords(List records) + public void addRecords(List records) throws QException { recordPipe.addRecords(records); } diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/query/QueryOutputStorageInterface.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/query/QueryOutputStorageInterface.java index 95046765..c010b21e 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/query/QueryOutputStorageInterface.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/query/QueryOutputStorageInterface.java @@ -23,6 +23,7 @@ package com.kingsrook.qqq.backend.core.model.actions.tables.query; import java.util.List; +import com.kingsrook.qqq.backend.core.exceptions.QException; import com.kingsrook.qqq.backend.core.model.data.QRecord; @@ -36,13 +37,13 @@ interface QueryOutputStorageInterface /******************************************************************************* ** add a records to this output *******************************************************************************/ - void addRecord(QRecord record); + void addRecord(QRecord record) throws QException; /******************************************************************************* ** add a list of records to this output *******************************************************************************/ - void addRecords(List records); + void addRecords(List records) throws QException; /******************************************************************************* ** Get all stored records diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/tables/automation/TableAutomationAction.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/tables/automation/TableAutomationAction.java index d44fa9c1..aa043a8b 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/tables/automation/TableAutomationAction.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/tables/automation/TableAutomationAction.java @@ -38,6 +38,12 @@ public class TableAutomationAction private Integer priority = 500; private QQueryFilter filter; + //////////////////////////////////////////////////////////////////////// + // flag that will cause the records to cause their associations to be // + // fetched, when they are looked up for passing into the action // + //////////////////////////////////////////////////////////////////////// + private boolean includeRecordAssociations = false; + private Map values; //////////////////////////////// @@ -292,4 +298,35 @@ public class TableAutomationAction return (this); } + + + /******************************************************************************* + ** Getter for includeRecordAssociations + *******************************************************************************/ + public boolean getIncludeRecordAssociations() + { + return (this.includeRecordAssociations); + } + + + + /******************************************************************************* + ** Setter for includeRecordAssociations + *******************************************************************************/ + public void setIncludeRecordAssociations(boolean includeRecordAssociations) + { + this.includeRecordAssociations = includeRecordAssociations; + } + + + + /******************************************************************************* + ** Fluent setter for includeRecordAssociations + *******************************************************************************/ + public TableAutomationAction withIncludeRecordAssociations(boolean includeRecordAssociations) + { + this.includeRecordAssociations = includeRecordAssociations; + return (this); + } + } diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/scripts/ScriptRevision.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/scripts/ScriptRevision.java index 0169c561..917ebfb2 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/scripts/ScriptRevision.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/scripts/ScriptRevision.java @@ -27,6 +27,7 @@ import com.kingsrook.qqq.backend.core.exceptions.QException; import com.kingsrook.qqq.backend.core.model.data.QField; import com.kingsrook.qqq.backend.core.model.data.QRecord; import com.kingsrook.qqq.backend.core.model.data.QRecordEntity; +import com.kingsrook.qqq.backend.core.model.metadata.fields.ValueTooLongBehavior; /******************************************************************************* @@ -48,16 +49,22 @@ public class ScriptRevision extends QRecordEntity @QField(possibleValueSourceName = "script") private Integer scriptId; + @QField(possibleValueSourceName = "apiVersion", label = "API Version") + private String apiVersion; + + @QField(possibleValueSourceName = "apiName", label = "API Name") + private String apiName; + @QField() private String contents; @QField() private Integer sequenceNo; - @QField() + @QField(maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.TRUNCATE_ELLIPSIS) private String commitMessage; - @QField() + @QField(maxLength = 100, valueTooLongBehavior = ValueTooLongBehavior.TRUNCATE_ELLIPSIS) private String author; @@ -353,4 +360,66 @@ public class ScriptRevision extends QRecordEntity return (this); } + + + /******************************************************************************* + ** Getter for apiVersion + *******************************************************************************/ + public String getApiVersion() + { + return (this.apiVersion); + } + + + + /******************************************************************************* + ** Setter for apiVersion + *******************************************************************************/ + public void setApiVersion(String apiVersion) + { + this.apiVersion = apiVersion; + } + + + + /******************************************************************************* + ** Fluent setter for apiVersion + *******************************************************************************/ + public ScriptRevision withApiVersion(String apiVersion) + { + this.apiVersion = apiVersion; + return (this); + } + + + + /******************************************************************************* + ** Getter for apiName + *******************************************************************************/ + public String getApiName() + { + return (this.apiName); + } + + + + /******************************************************************************* + ** Setter for apiName + *******************************************************************************/ + public void setApiName(String apiName) + { + this.apiName = apiName; + } + + + + /******************************************************************************* + ** Fluent setter for apiName + *******************************************************************************/ + public ScriptRevision withApiName(String apiName) + { + this.apiName = apiName; + return (this); + } + } diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/scripts/ScriptsMetaDataProvider.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/scripts/ScriptsMetaDataProvider.java index d8235a2b..703690cf 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/scripts/ScriptsMetaDataProvider.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/scripts/ScriptsMetaDataProvider.java @@ -389,6 +389,23 @@ public class ScriptsMetaDataProvider tableMetaData.getField("contents").withFieldAdornment(new FieldAdornment(AdornmentType.CODE_EDITOR).withValue(AdornmentType.CodeEditorValues.languageMode("javascript"))); tableMetaData.getField("scriptId").withFieldAdornment(AdornmentType.Size.LARGE.toAdornment()); + try + { + //////////////////////////////////////////////////////////////////////////////////////////////////// + // if the api module is loaded, then add a section to the table for the api name & version fields // + //////////////////////////////////////////////////////////////////////////////////////////////////// + Class.forName("com.kingsrook.qqq.api.model.metadata.ApiInstanceMetaDataProvider"); + tableMetaData.getSections().add(1, new QFieldSection("api", "API", new QIcon().withName("code"), Tier.T2, List.of("apiName", "apiVersion"))); + } + catch(ClassNotFoundException e) + { + ///////////////////////////////////////////////////////////////////////////////////////////////////// + // if the api module is not loaded, then make sure we don't have these fields in our scripts table // + ///////////////////////////////////////////////////////////////////////////////////////////////////// + tableMetaData.getFields().remove("apiName"); + tableMetaData.getFields().remove("apiVersion"); + } + return (tableMetaData); } diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/modules/backend/implementations/memory/MemoryQueryAction.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/modules/backend/implementations/memory/MemoryQueryAction.java index cd0e8bf7..962fe46a 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/modules/backend/implementations/memory/MemoryQueryAction.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/modules/backend/implementations/memory/MemoryQueryAction.java @@ -26,6 +26,7 @@ import com.kingsrook.qqq.backend.core.actions.interfaces.QueryInterface; import com.kingsrook.qqq.backend.core.exceptions.QException; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; /******************************************************************************* @@ -43,7 +44,16 @@ public class MemoryQueryAction implements QueryInterface try { QueryOutput queryOutput = new QueryOutput(queryInput); - queryOutput.addRecords(MemoryRecordStore.getInstance().query(queryInput)); + + /////////////////////////////////////////////////////////////////////////////////////////////////////// + // add the records to the output one-by-one -- this more closely matches how "real" backends perform // + // and works better w/ pipes // + /////////////////////////////////////////////////////////////////////////////////////////////////////// + for(QRecord qRecord : MemoryRecordStore.getInstance().query(queryInput)) + { + queryOutput.addRecord(qRecord); + } + return (queryOutput); } catch(Exception e) diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/implementations/etl/streamedwithfrontend/ExtractViaQueryStep.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/implementations/etl/streamedwithfrontend/ExtractViaQueryStep.java index 414b0c54..ee23b94f 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/implementations/etl/streamedwithfrontend/ExtractViaQueryStep.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/implementations/etl/streamedwithfrontend/ExtractViaQueryStep.java @@ -83,6 +83,9 @@ public class ExtractViaQueryStep extends AbstractExtractStep queryInput.setRecordPipe(getRecordPipe()); queryInput.setLimit(getLimit()); queryInput.setAsyncJobCallback(runBackendStepInput.getAsyncJobCallback()); + + customizeInputPreQuery(queryInput); + new QueryAction().execute(queryInput); /////////////////////////////////////////////////////////////////// @@ -92,6 +95,16 @@ public class ExtractViaQueryStep extends AbstractExtractStep + /******************************************************************************* + ** chance for sub-classes to change things about the query input, if they want. + *******************************************************************************/ + protected void customizeInputPreQuery(QueryInput queryInput) + { + + } + + + /******************************************************************************* ** *******************************************************************************/ diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/implementations/scripts/RunRecordScriptExtractStep.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/implementations/scripts/RunRecordScriptExtractStep.java index 4599b0c8..6dda828d 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/implementations/scripts/RunRecordScriptExtractStep.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/implementations/scripts/RunRecordScriptExtractStep.java @@ -28,6 +28,7 @@ import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInpu import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput; import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput; import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetOutput; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; import com.kingsrook.qqq.backend.core.model.scripts.Script; import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ExtractViaQueryStep; import com.kingsrook.qqq.backend.core.utils.StringUtils; @@ -70,4 +71,16 @@ public class RunRecordScriptExtractStep extends ExtractViaQueryStep super.preRun(runBackendStepInput, runBackendStepOutput); } + + + /******************************************************************************* + ** Make sure associations are fetched (so api records have children!) + *******************************************************************************/ + @Override + protected void customizeInputPreQuery(QueryInput queryInput) + { + super.customizeInputPreQuery(queryInput); + queryInput.setIncludeAssociations(true); + } + } diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/implementations/scripts/StoreScriptRevisionProcessStep.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/implementations/scripts/StoreScriptRevisionProcessStep.java index d1a0fb82..db523e40 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/implementations/scripts/StoreScriptRevisionProcessStep.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/implementations/scripts/StoreScriptRevisionProcessStep.java @@ -112,6 +112,8 @@ public class StoreScriptRevisionProcessStep implements BackendStep QRecord scriptRevision = new QRecord() .withValue("scriptId", script.getValue("id")) .withValue("contents", input.getValueString("contents")) + .withValue("apiName", input.getValueString("apiName")) + .withValue("apiVersion", input.getValueString("apiVersion")) .withValue("commitMessage", commitMessage) .withValue("sequenceNo", nextSequenceNo); diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/implementations/scripts/TestScriptProcessStep.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/implementations/scripts/TestScriptProcessStep.java index 0abab72c..2cd3544b 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/implementations/scripts/TestScriptProcessStep.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/implementations/scripts/TestScriptProcessStep.java @@ -74,11 +74,12 @@ public class TestScriptProcessStep implements BackendStep // get inputs // //////////////// Integer scriptId = input.getValueInteger("scriptId"); - String code = input.getValueString("code"); ScriptRevision scriptRevision = new ScriptRevision(); scriptRevision.setScriptId(scriptId); - scriptRevision.setContents(code); + scriptRevision.setContents(input.getValueString("code")); + scriptRevision.setApiName(input.getValueString("apiName")); + scriptRevision.setApiVersion(input.getValueString("apiVersion")); BuildScriptLogAndScriptLogLineExecutionLogger executionLogger = new BuildScriptLogAndScriptLogLineExecutionLogger(null, null); @@ -106,6 +107,7 @@ public class TestScriptProcessStep implements BackendStep QueryInput queryInput = new QueryInput(); queryInput.setTableName(tableName); queryInput.setFilter(new QQueryFilter(new QFilterCriteria(table.getPrimaryKeyField(), QCriteriaOperator.IN, recordPrimaryKeyList.split(",")))); + queryInput.setIncludeAssociations(true); QueryOutput queryOutput = new QueryAction().execute(queryInput); if(CollectionUtils.nullSafeIsEmpty(queryOutput.getRecords())) { diff --git a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/tables/QueryActionTest.java b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/tables/QueryActionTest.java index 564cdf36..87d0032d 100644 --- a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/tables/QueryActionTest.java +++ b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/tables/QueryActionTest.java @@ -26,6 +26,7 @@ import java.io.Serializable; import java.util.ArrayList; import java.util.List; import com.kingsrook.qqq.backend.core.BaseTest; +import com.kingsrook.qqq.backend.core.actions.async.AsyncRecordPipeLoop; import com.kingsrook.qqq.backend.core.actions.reporting.RecordPipe; import com.kingsrook.qqq.backend.core.context.QContext; import com.kingsrook.qqq.backend.core.exceptions.QException; @@ -189,6 +190,77 @@ class QueryActionTest extends BaseTest + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testQueryAssociationsWithPipe() throws QException + { + QContext.getQSession().withSecurityKeyValue(TestUtils.SECURITY_KEY_TYPE_STORE_ALL_ACCESS, true); + insert2OrdersWith3Lines3LineExtrinsicsAnd4OrderExtrinsicAssociations(); + + RecordPipe pipe = new RecordPipe(); + QueryInput queryInput = new QueryInput(); + queryInput.setTableName(TestUtils.TABLE_NAME_ORDER); + queryInput.setRecordPipe(pipe); + queryInput.setIncludeAssociations(true); + QueryOutput queryOutput = new QueryAction().execute(queryInput); + assertNotNull(queryOutput); + + List records = pipe.consumeAvailableRecords(); + assertThat(records).isNotEmpty(); + + QRecord order0 = records.get(0); + assertEquals(2, order0.getAssociatedRecords().get("orderLine").size()); + assertEquals(3, order0.getAssociatedRecords().get("extrinsics").size()); + + QRecord orderLine00 = order0.getAssociatedRecords().get("orderLine").get(0); + assertEquals(1, orderLine00.getAssociatedRecords().get("extrinsics").size()); + QRecord orderLine01 = order0.getAssociatedRecords().get("orderLine").get(1); + assertEquals(2, orderLine01.getAssociatedRecords().get("extrinsics").size()); + + QRecord order1 = records.get(1); + assertEquals(1, order1.getAssociatedRecords().get("orderLine").size()); + assertEquals(1, order1.getAssociatedRecords().get("extrinsics").size()); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testQueryManyRecordsAssociationsWithPipe() throws QException + { + QContext.getQSession().withSecurityKeyValue(TestUtils.SECURITY_KEY_TYPE_STORE_ALL_ACCESS, true); + insertNOrdersWithAssociations(2500); + + RecordPipe pipe = new RecordPipe(1000); + QueryInput queryInput = new QueryInput(); + queryInput.setTableName(TestUtils.TABLE_NAME_ORDER); + queryInput.setRecordPipe(pipe); + queryInput.setIncludeAssociations(true); + + int recordsConsumed = new AsyncRecordPipeLoop().run("Test", null, pipe, (callback) -> + { + new QueryAction().execute(queryInput); + return (true); + }, () -> + { + List records = pipe.consumeAvailableRecords(); + for(QRecord record : records) + { + assertEquals(1, record.getAssociatedRecords().get("orderLine").size()); + assertEquals(1, record.getAssociatedRecords().get("extrinsics").size()); + } + return (records.size()); + }); + + assertEquals(2500, recordsConsumed); + } + + + /******************************************************************************* ** *******************************************************************************/ @@ -345,4 +417,25 @@ class QueryActionTest extends BaseTest )); new InsertAction().execute(insertInput); } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private static void insertNOrdersWithAssociations(int n) throws QException + { + List recordList = new ArrayList<>(); + for(int i = 0; i < n; i++) + { + recordList.add(new QRecord().withValue("storeId", 1).withValue("orderNo", "ORD" + i) + .withAssociatedRecord("orderLine", new QRecord().withValue("sku", "BASIC1").withValue("quantity", 3)) + .withAssociatedRecord("extrinsics", new QRecord().withValue("key", "YOUR-FIELD").withValue("value", "YOUR-VALUE"))); + } + + InsertInput insertInput = new InsertInput(); + insertInput.setTableName(TestUtils.TABLE_NAME_ORDER); + insertInput.setRecords(recordList); + new InsertAction().execute(insertInput); + } } diff --git a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/adapters/CsvToQRecordAdapterTest.java b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/adapters/CsvToQRecordAdapterTest.java index 345e71ca..e1fba082 100644 --- a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/adapters/CsvToQRecordAdapterTest.java +++ b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/adapters/CsvToQRecordAdapterTest.java @@ -24,6 +24,7 @@ package com.kingsrook.qqq.backend.core.adapters; import java.util.List; import com.kingsrook.qqq.backend.core.BaseTest; +import com.kingsrook.qqq.backend.core.exceptions.QException; import com.kingsrook.qqq.backend.core.model.actions.shared.mapping.QIndexBasedFieldMapping; import com.kingsrook.qqq.backend.core.model.actions.shared.mapping.QKeyBasedFieldMapping; import com.kingsrook.qqq.backend.core.model.data.QRecord; @@ -48,7 +49,7 @@ class CsvToQRecordAdapterTest extends BaseTest ** *******************************************************************************/ @Test - public void test_buildRecordsFromCsv_nullInput() + public void test_buildRecordsFromCsv_nullInput() throws QException { testExpectedToThrow(null); } @@ -59,7 +60,7 @@ class CsvToQRecordAdapterTest extends BaseTest ** *******************************************************************************/ @Test - public void test_buildRecordsFromCsv_emptyStringInput() + public void test_buildRecordsFromCsv_emptyStringInput() throws QException { testExpectedToThrow(""); } @@ -69,7 +70,7 @@ class CsvToQRecordAdapterTest extends BaseTest /******************************************************************************* ** *******************************************************************************/ - private void testExpectedToThrow(String csv) + private void testExpectedToThrow(String csv) throws QException { try { @@ -92,7 +93,7 @@ class CsvToQRecordAdapterTest extends BaseTest ** *******************************************************************************/ @Test - public void test_buildRecordsFromCsv_emptyList() + public void test_buildRecordsFromCsv_emptyList() throws QException { CsvToQRecordAdapter csvToQRecordAdapter = new CsvToQRecordAdapter(); List qRecords = csvToQRecordAdapter.buildRecordsFromCsv(getPersonCsvHeader(), TestUtils.defineTablePerson(), null); @@ -142,7 +143,7 @@ class CsvToQRecordAdapterTest extends BaseTest ** *******************************************************************************/ @Test - public void test_buildRecordsFromCsv_oneRowStandardHeaderNoMapping() + public void test_buildRecordsFromCsv_oneRowStandardHeaderNoMapping() throws QException { CsvToQRecordAdapter csvToQRecordAdapter = new CsvToQRecordAdapter(); List qRecords = csvToQRecordAdapter.buildRecordsFromCsv(getPersonCsvHeader() + getPersonCsvRow1(), TestUtils.defineTablePerson(), null); @@ -159,7 +160,7 @@ class CsvToQRecordAdapterTest extends BaseTest ** *******************************************************************************/ @Test - public void test_buildRecordsFromCsv_twoRowsStandardHeaderNoMapping() + public void test_buildRecordsFromCsv_twoRowsStandardHeaderNoMapping() throws QException { CsvToQRecordAdapter csvToQRecordAdapter = new CsvToQRecordAdapter(); List qRecords = csvToQRecordAdapter.buildRecordsFromCsv(getPersonCsvHeader() + getPersonCsvRow1() + getPersonCsvRow2(), TestUtils.defineTablePerson(), null); @@ -179,7 +180,7 @@ class CsvToQRecordAdapterTest extends BaseTest ** *******************************************************************************/ @Test - public void test_buildRecordsFromCsv_oneRowCustomKeyBasedMapping() + public void test_buildRecordsFromCsv_oneRowCustomKeyBasedMapping() throws QException { String csvCustomHeader = """ "id","created","modified","first","last","birthday","email"\r @@ -209,7 +210,7 @@ class CsvToQRecordAdapterTest extends BaseTest ** *******************************************************************************/ @Test - public void test_buildRecordsFromCsv_twoRowsCustomIndexBasedMapping() + public void test_buildRecordsFromCsv_twoRowsCustomIndexBasedMapping() throws QException { int index = 1; QIndexBasedFieldMapping mapping = new QIndexBasedFieldMapping() @@ -241,7 +242,7 @@ class CsvToQRecordAdapterTest extends BaseTest ** header names on the RHS. *******************************************************************************/ @Test - public void test_duplicatedColumnHeaders() + public void test_duplicatedColumnHeaders() throws QException { QKeyBasedFieldMapping mapping = new QKeyBasedFieldMapping() .withMapping("id", "id") @@ -291,7 +292,7 @@ class CsvToQRecordAdapterTest extends BaseTest ** *******************************************************************************/ @Test - void testByteOrderMarker() + void testByteOrderMarker() throws QException { CsvToQRecordAdapter csvToQRecordAdapter = new CsvToQRecordAdapter(); @@ -313,7 +314,7 @@ class CsvToQRecordAdapterTest extends BaseTest ** Fix an IndexOutOfBounds that we used to throw. *******************************************************************************/ @Test - void testTooFewBodyColumns() + void testTooFewBodyColumns() throws QException { CsvToQRecordAdapter csvToQRecordAdapter = new CsvToQRecordAdapter(); List records = csvToQRecordAdapter.buildRecordsFromCsv(""" @@ -331,7 +332,7 @@ class CsvToQRecordAdapterTest extends BaseTest ** *******************************************************************************/ @Test - public void testTooFewColumnsIndexMapping() + public void testTooFewColumnsIndexMapping() throws QException { int index = 1; QIndexBasedFieldMapping mapping = new QIndexBasedFieldMapping() @@ -353,7 +354,7 @@ class CsvToQRecordAdapterTest extends BaseTest ** *******************************************************************************/ @Test - void testCaseSensitiveHeaders() + void testCaseSensitiveHeaders() throws QException { CsvToQRecordAdapter csvToQRecordAdapter = new CsvToQRecordAdapter(); csvToQRecordAdapter.buildRecordsFromCsv(new CsvToQRecordAdapter.InputWrapper() @@ -376,7 +377,7 @@ class CsvToQRecordAdapterTest extends BaseTest ** *******************************************************************************/ @Test - void testCaseInsensitiveHeaders() + void testCaseInsensitiveHeaders() throws QException { CsvToQRecordAdapter csvToQRecordAdapter = new CsvToQRecordAdapter(); csvToQRecordAdapter.buildRecordsFromCsv(new CsvToQRecordAdapter.InputWrapper() diff --git a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/processes/implementations/tablesync/TableSyncProcessTest.java b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/processes/implementations/tablesync/TableSyncProcessTest.java index 30fe95a9..22d02a26 100644 --- a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/processes/implementations/tablesync/TableSyncProcessTest.java +++ b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/processes/implementations/tablesync/TableSyncProcessTest.java @@ -222,7 +222,10 @@ class TableSyncProcessTest extends BaseTest public void run(RunBackendStepInput runBackendStepInput, RunBackendStepOutput runBackendStepOutput) throws QException { List qRecords = TestUtils.queryTable(QContext.getQInstance(), TestUtils.TABLE_NAME_PERSON_MEMORY); - qRecords.forEach(r -> getRecordPipe().addRecord(r)); + for(QRecord qRecord : qRecords) + { + getRecordPipe().addRecord(qRecord); + } //////////////////////////////////////// // re-add records 1 and 5 to the pipe // diff --git a/qqq-backend-module-api/pom.xml b/qqq-backend-module-api/pom.xml index f86fb9d8..e390f623 100644 --- a/qqq-backend-module-api/pom.xml +++ b/qqq-backend-module-api/pom.xml @@ -34,10 +34,6 @@ - - - 0.00 - 0.00 diff --git a/qqq-backend-module-api/src/main/java/com/kingsrook/qqq/backend/module/api/actions/APIUpdateAction.java b/qqq-backend-module-api/src/main/java/com/kingsrook/qqq/backend/module/api/actions/APIUpdateAction.java index 89cafaf5..e0af323d 100644 --- a/qqq-backend-module-api/src/main/java/com/kingsrook/qqq/backend/module/api/actions/APIUpdateAction.java +++ b/qqq-backend-module-api/src/main/java/com/kingsrook/qqq/backend/module/api/actions/APIUpdateAction.java @@ -45,4 +45,16 @@ public class APIUpdateAction extends AbstractAPIAction implements UpdateInterfac return (apiActionUtil.doUpdate(table, updateInput)); } + + + /******************************************************************************* + ** Specify whether this particular module's update action can & should fetch + ** records before updating them, e.g., for audits or "not-found-checks" + *******************************************************************************/ + @Override + public boolean supportsPreFetchQuery() + { + return (false); + } + } diff --git a/qqq-backend-module-api/src/main/java/com/kingsrook/qqq/backend/module/api/actions/BaseAPIActionUtil.java b/qqq-backend-module-api/src/main/java/com/kingsrook/qqq/backend/module/api/actions/BaseAPIActionUtil.java index d8ecf17a..f3cd1107 100644 --- a/qqq-backend-module-api/src/main/java/com/kingsrook/qqq/backend/module/api/actions/BaseAPIActionUtil.java +++ b/qqq-backend-module-api/src/main/java/com/kingsrook/qqq/backend/module/api/actions/BaseAPIActionUtil.java @@ -634,7 +634,7 @@ public class BaseAPIActionUtil request.setEntity(new StringEntity(postBody)); request.addHeader("Content-Type", "application/x-www-form-urlencoded; charset=utf-8"); - HttpResponse response = client.execute(request); + HttpResponse response = executeOAuthTokenRequest(client, request); int statusCode = response.getStatusLine().getStatusCode(); HttpEntity entity = response.getEntity(); String resultString = EntityUtils.toString(entity); @@ -669,6 +669,16 @@ public class BaseAPIActionUtil + /******************************************************************************* + ** one-line method, factored out so mock/tests can override + *******************************************************************************/ + protected CloseableHttpResponse executeOAuthTokenRequest(CloseableHttpClient client, HttpPost request) throws IOException + { + return client.execute(request); + } + + + /******************************************************************************* ** As part of making a request - set up its content-type header. *******************************************************************************/ @@ -880,7 +890,7 @@ public class BaseAPIActionUtil LOG.info("POST contents [" + ((HttpPost) request).getEntity().toString() + "]"); } - try(CloseableHttpResponse response = httpClient.execute(request)) + try(CloseableHttpResponse response = executeHttpRequest(request, httpClient)) { QHttpResponse qResponse = new QHttpResponse(response); @@ -924,7 +934,7 @@ public class BaseAPIActionUtil rateLimitsCaught++; if(rateLimitsCaught > getMaxAllowedRateLimitErrors()) { - LOG.error("Giving up POST to [" + table.getName() + "] after too many rate-limit errors (" + getMaxAllowedRateLimitErrors() + ")"); + LOG.error("Giving up " + request.getMethod() + " to [" + table.getName() + "] after too many rate-limit errors (" + getMaxAllowedRateLimitErrors() + ")"); throw (new QException(rle)); } @@ -950,6 +960,16 @@ public class BaseAPIActionUtil + /******************************************************************************* + ** one-line method, factored out so mock/tests can override + *******************************************************************************/ + protected CloseableHttpResponse executeHttpRequest(HttpRequestBase request, CloseableHttpClient httpClient) throws IOException + { + return httpClient.execute(request); + } + + + /******************************************************************************* ** *******************************************************************************/ diff --git a/qqq-backend-module-api/src/test/java/com/kingsrook/qqq/backend/module/api/TestUtils.java b/qqq-backend-module-api/src/test/java/com/kingsrook/qqq/backend/module/api/TestUtils.java index 1217445b..27a12a55 100644 --- a/qqq-backend-module-api/src/test/java/com/kingsrook/qqq/backend/module/api/TestUtils.java +++ b/qqq-backend-module-api/src/test/java/com/kingsrook/qqq/backend/module/api/TestUtils.java @@ -32,6 +32,8 @@ 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.tables.QTableMetaData; +import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryBackendModule; +import com.kingsrook.qqq.backend.module.api.mocks.MockApiActionUtils; import com.kingsrook.qqq.backend.module.api.model.AuthorizationType; import com.kingsrook.qqq.backend.module.api.model.metadata.APIBackendMetaData; import com.kingsrook.qqq.backend.module.api.model.metadata.APITableBackendDetails; @@ -42,7 +44,10 @@ import com.kingsrook.qqq.backend.module.api.model.metadata.APITableBackendDetail *******************************************************************************/ public class TestUtils { + public static final String MEMORY_BACKEND_NAME = "memory"; public static final String EASYPOST_BACKEND_NAME = "easypost"; + public static final String MOCK_BACKEND_NAME = "mock"; + public static final String MOCK_TABLE_NAME = "mock"; @@ -52,14 +57,69 @@ public class TestUtils public static QInstance defineInstance() { QInstance qInstance = new QInstance(); - qInstance.addBackend(defineBackend()); - qInstance.addTable(defineTableEasypostTracker()); qInstance.setAuthentication(defineAuthentication()); + + qInstance.addBackend(defineMemoryBackend()); + + qInstance.addBackend(defineMockBackend()); + qInstance.addTable(defineMockTable()); + + qInstance.addBackend(defineEasypostBackend()); + qInstance.addTable(defineTableEasypostTracker()); + return (qInstance); } + /******************************************************************************* + ** Define the in-memory backend used in standard tests + *******************************************************************************/ + public static QBackendMetaData defineMemoryBackend() + { + return new QBackendMetaData() + .withName(MEMORY_BACKEND_NAME) + .withBackendType(MemoryBackendModule.class); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private static QBackendMetaData defineMockBackend() + { + return (new APIBackendMetaData() + .withName(MOCK_BACKEND_NAME) + .withAuthorizationType(AuthorizationType.API_KEY_HEADER) + .withBaseUrl("http://localhost:9999/mock") + .withContentType("application/json") + .withActionUtil(new QCodeReference(MockApiActionUtils.class, QCodeUsage.CUSTOMIZER)) + ); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private static QTableMetaData defineMockTable() + { + return (new QTableMetaData() + .withName(MOCK_TABLE_NAME) + .withBackendName(MOCK_BACKEND_NAME) + .withField(new QFieldMetaData("id", QFieldType.STRING)) + .withField(new QFieldMetaData("name", QFieldType.STRING)) + .withPrimaryKeyField("id") + .withBackendDetails(new APITableBackendDetails() + .withTablePath("mock") + .withTableWrapperObjectName("mocks") + ) + ); + } + + + /******************************************************************************* ** Define the authentication used in standard tests - using 'mock' type. ** @@ -76,7 +136,7 @@ public class TestUtils /******************************************************************************* ** *******************************************************************************/ - public static QBackendMetaData defineBackend() + public static QBackendMetaData defineEasypostBackend() { String apiKey = new QMetaDataVariableInterpreter().interpret("${env.EASYPOST_API_KEY}"); diff --git a/qqq-backend-module-api/src/test/java/com/kingsrook/qqq/backend/module/api/actions/BaseAPIActionUtilTest.java b/qqq-backend-module-api/src/test/java/com/kingsrook/qqq/backend/module/api/actions/BaseAPIActionUtilTest.java new file mode 100644 index 00000000..3930d5b6 --- /dev/null +++ b/qqq-backend-module-api/src/test/java/com/kingsrook/qqq/backend/module/api/actions/BaseAPIActionUtilTest.java @@ -0,0 +1,685 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2023. 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.module.api.actions; + + +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import com.kingsrook.qqq.backend.core.actions.tables.CountAction; +import com.kingsrook.qqq.backend.core.actions.tables.GetAction; +import com.kingsrook.qqq.backend.core.actions.tables.InsertAction; +import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; +import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountOutput; +import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetOutput; +import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertOutput; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput; +import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateOutput; +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.tables.UniqueKey; +import com.kingsrook.qqq.backend.core.utils.CollectionUtils; +import com.kingsrook.qqq.backend.core.utils.SleepUtils; +import com.kingsrook.qqq.backend.module.api.BaseTest; +import com.kingsrook.qqq.backend.module.api.TestUtils; +import com.kingsrook.qqq.backend.module.api.exceptions.RateLimitException; +import com.kingsrook.qqq.backend.module.api.mocks.MockApiActionUtils; +import com.kingsrook.qqq.backend.module.api.mocks.MockApiUtilsHelper; +import com.kingsrook.qqq.backend.module.api.model.AuthorizationType; +import com.kingsrook.qqq.backend.module.api.model.OutboundAPILog; +import com.kingsrook.qqq.backend.module.api.model.OutboundAPILogMetaDataProvider; +import com.kingsrook.qqq.backend.module.api.model.metadata.APIBackendMetaData; +import org.apache.http.Header; +import org.json.JSONArray; +import org.json.JSONObject; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + + +/******************************************************************************* + ** Unit test for com.kingsrook.qqq.backend.module.api.actions.BaseAPIActionUtil + *******************************************************************************/ +class BaseAPIActionUtilTest extends BaseTest +{ + private static MockApiUtilsHelper mockApiUtilsHelper = new MockApiUtilsHelper(); + + + + /******************************************************************************* + ** + *******************************************************************************/ + @BeforeEach + void beforeEach() + { + mockApiUtilsHelper = new MockApiUtilsHelper(); + mockApiUtilsHelper.setUseMock(true); + MockApiActionUtils.mockApiUtilsHelper = mockApiUtilsHelper; + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testCount() throws QException + { + mockApiUtilsHelper.enqueueMockResponse(""" + [ + {"id": 1, "name": "Homer"}, + {"id": 2, "name": "Marge"}, + {"id": 3, "name": "Bart"}, + {"id": 4, "name": "Lisa"}, + {"id": 5, "name": "Maggie"} + ] + """); + + CountInput countInput = new CountInput(); + countInput.setTableName(TestUtils.MOCK_TABLE_NAME); + CountOutput countOutput = new CountAction().execute(countInput); + assertEquals(5, countOutput.getCount()); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testCountError() throws QException + { + //////////////////////////////////////// + // avoid the fully mocked makeRequest // + //////////////////////////////////////// + mockApiUtilsHelper.setUseMock(false); + mockApiUtilsHelper.enqueueMockResponse(new QHttpResponse().withStatusCode(500).withContent(""" + {"error": "Server error"} + """)); + + CountInput countInput = new CountInput(); + countInput.setTableName(TestUtils.MOCK_TABLE_NAME); + assertThatThrownBy(() -> new CountAction().execute(countInput)).hasRootCauseInstanceOf(Exception.class); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testGet() throws QException + { + mockApiUtilsHelper.enqueueMockResponse(""" + {"id": 3, "name": "Bart"}, + """); + + GetInput getInput = new GetInput(); + getInput.setTableName(TestUtils.MOCK_TABLE_NAME); + getInput.setPrimaryKey(3); + GetOutput getOutput = new GetAction().execute(getInput); + assertEquals(3, getOutput.getRecord().getValueInteger("id")); + assertEquals("Bart", getOutput.getRecord().getValueString("name")); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testGetByKey() throws QException + { + QContext.getQInstance().getTable(TestUtils.MOCK_TABLE_NAME).withUniqueKey(new UniqueKey("id")); + + mockApiUtilsHelper.enqueueMockResponse(""" + {"id": 3, "name": "Bart"}, + """); + + GetInput getInput = new GetInput(); + getInput.setTableName(TestUtils.MOCK_TABLE_NAME); + getInput.setUniqueKey(Map.of("id", 3)); + GetOutput getOutput = new GetAction().execute(getInput); + assertEquals(3, getOutput.getRecord().getValueInteger("id")); + assertEquals("Bart", getOutput.getRecord().getValueString("name")); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testQuery() throws QException + { + mockApiUtilsHelper.enqueueMockResponse(""" + [ + {"id": 1, "name": "Homer"}, + {"id": 2, "name": "Marge"}, + {"id": 3, "name": "Bart"}, + {"id": 4, "name": "Lisa"}, + {"id": 5, "name": "Maggie"} + ] + """); + + QueryInput queryInput = new QueryInput(); + queryInput.setTableName(TestUtils.MOCK_TABLE_NAME); + QueryOutput queryOutput = new QueryAction().execute(queryInput); + assertEquals(5, queryOutput.getRecords().size()); + assertEquals(1, queryOutput.getRecords().get(0).getValueInteger("id")); + assertEquals("Homer", queryOutput.getRecords().get(0).getValueString("name")); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testQueryObjectWrappingList() throws QException + { + mockApiUtilsHelper.enqueueMockResponse(""" + {"mocks": [ + {"id": 1, "name": "Homer"}, + {"id": 2, "name": "Marge"}, + {"id": 3, "name": "Bart"}, + {"id": 4, "name": "Lisa"}, + {"id": 5, "name": "Maggie"} + ]} + """); + + QueryInput queryInput = new QueryInput(); + queryInput.setTableName(TestUtils.MOCK_TABLE_NAME); + QueryOutput queryOutput = new QueryAction().execute(queryInput); + assertEquals(5, queryOutput.getRecords().size()); + assertEquals(1, queryOutput.getRecords().get(0).getValueInteger("id")); + assertEquals("Homer", queryOutput.getRecords().get(0).getValueString("name")); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testQueryObjectWrappingSingleObject() throws QException + { + mockApiUtilsHelper.enqueueMockResponse(""" + {"mocks": + {"id": 1, "name": "Homer"} + } + """); + + QueryInput queryInput = new QueryInput(); + queryInput.setTableName(TestUtils.MOCK_TABLE_NAME); + QueryOutput queryOutput = new QueryAction().execute(queryInput); + assertEquals(1, queryOutput.getRecords().size()); + assertEquals(1, queryOutput.getRecords().get(0).getValueInteger("id")); + assertEquals("Homer", queryOutput.getRecords().get(0).getValueString("name")); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testQueryPaginate() throws QException + { + String oneObject = """ + {"id": 1, "name": "Homer"} + """; + StringBuilder response = new StringBuilder("["); + for(int i = 0; i < 19; i++) + { + response.append(oneObject).append(","); + } + response.append(oneObject); + response.append("]"); + mockApiUtilsHelper.enqueueMockResponse(response.toString()); + mockApiUtilsHelper.enqueueMockResponse(response.toString()); + mockApiUtilsHelper.enqueueMockResponse(response.toString()); + mockApiUtilsHelper.enqueueMockResponse("[]"); + + QueryInput queryInput = new QueryInput(); + queryInput.setTableName(TestUtils.MOCK_TABLE_NAME); + QueryOutput queryOutput = new QueryAction().execute(queryInput); + assertEquals(60, queryOutput.getRecords().size()); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testQueryError() throws QException + { + //////////////////////////////////////// + // avoid the fully mocked makeRequest // + //////////////////////////////////////// + mockApiUtilsHelper.setUseMock(false); + mockApiUtilsHelper.enqueueMockResponse(new QHttpResponse().withStatusCode(500).withContent(""" + {"error": "Server error"} + """)); + + QueryInput queryInput = new QueryInput(); + queryInput.setTableName(TestUtils.MOCK_TABLE_NAME); + assertThatThrownBy(() -> new QueryAction().execute(queryInput)).hasRootCauseInstanceOf(Exception.class); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testInsert() throws QException + { + mockApiUtilsHelper.enqueueMockResponse(""" + {"id": 6} + """); + + InsertInput insertInput = new InsertInput(); + insertInput.setTableName(TestUtils.MOCK_TABLE_NAME); + insertInput.setRecords(List.of(new QRecord().withValue("name", "Milhouse"))); + InsertOutput insertOutput = new InsertAction().execute(insertInput); + assertEquals(6, insertOutput.getRecords().get(0).getValueInteger("id")); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testInsertEmptyInputList() throws QException + { + InsertInput insertInput = new InsertInput(); + insertInput.setTableName(TestUtils.MOCK_TABLE_NAME); + insertInput.setRecords(List.of()); + InsertOutput insertOutput = new InsertAction().execute(insertInput); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testInsertError() throws QException + { + //////////////////////////////////////// + // avoid the fully mocked makeRequest // + //////////////////////////////////////// + mockApiUtilsHelper.setUseMock(false); + mockApiUtilsHelper.enqueueMockResponse(new QHttpResponse().withStatusCode(500).withContent(""" + {"error": "Server error"} + """)); + + InsertInput insertInput = new InsertInput(); + insertInput.setRecords(List.of(new QRecord().withValue("name", "Milhouse"))); + insertInput.setTableName(TestUtils.MOCK_TABLE_NAME); + InsertOutput insertOutput = new InsertAction().execute(insertInput); + assertTrue(CollectionUtils.nullSafeHasContents(insertOutput.getRecords().get(0).getErrors())); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testUpdate() throws QException + { + mockApiUtilsHelper.enqueueMockResponse(""); + + mockApiUtilsHelper.setMockRequestAsserter(httpRequestBase -> + { + String requestBody = MockApiUtilsHelper.readRequestBody(httpRequestBase); + JSONObject requestObject = new JSONObject(requestBody); + + JSONArray mocks = requestObject.getJSONArray("mocks"); + JSONObject record = mocks.getJSONObject(0); + + assertEquals("Bartholomew", record.getString("name")); + assertEquals(3, record.getInt("id")); + }); + + UpdateInput updateInput = new UpdateInput(); + updateInput.setTableName(TestUtils.MOCK_TABLE_NAME); + updateInput.setRecords(List.of(new QRecord().withValue("id", "3").withValue("name", "Bartholomew"))); + UpdateOutput updateOutput = new UpdateAction().execute(updateInput); + + // not sure what to assert in here... + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testUpdateEmptyInputList() throws QException + { + UpdateInput updateInput = new UpdateInput(); + updateInput.setTableName(TestUtils.MOCK_TABLE_NAME); + updateInput.setRecords(List.of()); + UpdateOutput updateOutput = new UpdateAction().execute(updateInput); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testUpdateError() throws QException + { + //////////////////////////////////////// + // avoid the fully mocked makeRequest // + //////////////////////////////////////// + mockApiUtilsHelper.setUseMock(false); + mockApiUtilsHelper.enqueueMockResponse(new QHttpResponse().withStatusCode(500).withContent(""" + {"error": "Server error"} + """)); + + UpdateInput updateInput = new UpdateInput(); + updateInput.setRecords(List.of(new QRecord().withValue("name", "Milhouse"))); + updateInput.setTableName(TestUtils.MOCK_TABLE_NAME); + + ///////////////////////////////////////////////////////////////////////////////// + // note - right now this is inconsistent with insertAction (and rdbms update), // + // where errors are placed in the records, rather than thrown... // + ///////////////////////////////////////////////////////////////////////////////// + assertThatThrownBy(() -> new UpdateAction().execute(updateInput)).hasRootCauseInstanceOf(Exception.class); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testMakeRequest() throws QException + { + //////////////////////////////////////////////////////////////////////////////////////////// + // this will make it not use the mock makeRequest method, // + // but instead the mock executeHttpRequest, so we can test code from the base makeRequest // + //////////////////////////////////////////////////////////////////////////////////////////// + mockApiUtilsHelper.setUseMock(false); + mockApiUtilsHelper.enqueueMockResponse(""" + {"id": 3, "name": "Bart"}, + """); + + GetInput getInput = new GetInput(); + getInput.setTableName(TestUtils.MOCK_TABLE_NAME); + getInput.setPrimaryKey(3); + GetOutput getOutput = new GetAction().execute(getInput); + assertEquals(3, getOutput.getRecord().getValueInteger("id")); + assertEquals("Bart", getOutput.getRecord().getValueString("name")); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void test429Then200() throws QException + { + //////////////////////////////////////////////////////////////////////////////////////////// + // this will make it not use the mock makeRequest method, // + // but instead the mock executeHttpRequest, so we can test code from the base makeRequest // + // specifically, that we can get one 429, and then eventually a 200 // + //////////////////////////////////////////////////////////////////////////////////////////// + mockApiUtilsHelper.setUseMock(false); + mockApiUtilsHelper.enqueueMockResponse(new QHttpResponse().withStatusCode(429).withContent("Try again")); + mockApiUtilsHelper.enqueueMockResponse(""" + {"id": 3, "name": "Bart"}, + """); + + GetInput getInput = new GetInput(); + getInput.setTableName(TestUtils.MOCK_TABLE_NAME); + getInput.setPrimaryKey(3); + GetOutput getOutput = new GetAction().execute(getInput); + assertEquals(3, getOutput.getRecord().getValueInteger("id")); + assertEquals("Bart", getOutput.getRecord().getValueString("name")); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testTooMany429() throws QException + { + //////////////////////////////////////////////////////////////////////////////////////////// + // this will make it not use the mock makeRequest method, // + // but instead the mock executeHttpRequest, so we can test code from the base makeRequest // + // specifically, that after too many 429's we get an error // + //////////////////////////////////////////////////////////////////////////////////////////// + mockApiUtilsHelper.setUseMock(false); + mockApiUtilsHelper.enqueueMockResponse(new QHttpResponse().withStatusCode(429).withContent("Try again")); + mockApiUtilsHelper.enqueueMockResponse(new QHttpResponse().withStatusCode(429).withContent("Try again")); + mockApiUtilsHelper.enqueueMockResponse(new QHttpResponse().withStatusCode(429).withContent("Try again")); + mockApiUtilsHelper.enqueueMockResponse(new QHttpResponse().withStatusCode(429).withContent("Try again")); + + GetInput getInput = new GetInput(); + getInput.setTableName(TestUtils.MOCK_TABLE_NAME); + getInput.setPrimaryKey(3); + + assertThatThrownBy(() -> new GetAction().execute(getInput)).hasRootCauseInstanceOf(RateLimitException.class); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testApiLogs() throws QException + { + QInstance qInstance = QContext.getQInstance(); + OutboundAPILogMetaDataProvider.defineAll(qInstance, TestUtils.MEMORY_BACKEND_NAME, null); + + mockApiUtilsHelper.setUseMock(false); + mockApiUtilsHelper.enqueueMockResponse(""" + {"id": 6} + """); + + InsertInput insertInput = new InsertInput(); + insertInput.setTableName(TestUtils.MOCK_TABLE_NAME); + insertInput.setRecords(List.of(new QRecord().withValue("name", "Milhouse"))); + InsertOutput insertOutput = new InsertAction().execute(insertInput); + assertEquals(6, insertOutput.getRecords().get(0).getValueInteger("id")); + + ////////////////////////////////////////////////////////////////////////////////////////// + // the outbound api log is inserted async, so... do or do not, and sleep some if needed // + ////////////////////////////////////////////////////////////////////////////////////////// + QueryOutput apiLogRecords = null; + int tries = 0; + do + { + SleepUtils.sleep(10, TimeUnit.MILLISECONDS); + QueryInput queryInput = new QueryInput(); + queryInput.setTableName(OutboundAPILog.TABLE_NAME); + apiLogRecords = new QueryAction().execute(queryInput); + } + while(apiLogRecords.getRecords().isEmpty() && tries++ < 10); + + assertEquals(1, apiLogRecords.getRecords().size()); + assertEquals("POST", apiLogRecords.getRecords().get(0).getValueString("method")); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testBasicAuthApiKey() throws QException + { + APIBackendMetaData backend = (APIBackendMetaData) QContext.getQInstance().getBackend(TestUtils.MOCK_BACKEND_NAME); + backend.setAuthorizationType(AuthorizationType.BASIC_AUTH_API_KEY); + backend.setApiKey("9876-WXYZ"); + + //////////////////////////////////////////////////////////////////////////////////////////// + // this will make it not use the mock makeRequest method, // + // but instead the mock executeHttpRequest, so we can test code from the base makeRequest // + //////////////////////////////////////////////////////////////////////////////////////////// + mockApiUtilsHelper.setUseMock(false); + mockApiUtilsHelper.enqueueMockResponse(""" + {"id": 3, "name": "Bart"}, + """); + + mockApiUtilsHelper.setMockRequestAsserter(request -> + { + Header authHeader = request.getFirstHeader("Authorization"); + assertTrue(authHeader.getValue().startsWith("Basic ")); + String apiKey = new String(Base64.getDecoder().decode(authHeader.getValue().replace("Basic ", "")), StandardCharsets.UTF_8); + assertEquals("9876-WXYZ", apiKey); + }); + + runSimpleGetAction(); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testBasicAuthUsernamePassword() throws QException + { + APIBackendMetaData backend = (APIBackendMetaData) QContext.getQInstance().getBackend(TestUtils.MOCK_BACKEND_NAME); + backend.setAuthorizationType(AuthorizationType.BASIC_AUTH_USERNAME_PASSWORD); + backend.setUsername("god"); + backend.setPassword("5fingers"); + + //////////////////////////////////////////////////////////////////////////////////////////// + // this will make it not use the mock makeRequest method, // + // but instead the mock executeHttpRequest, so we can test code from the base makeRequest // + //////////////////////////////////////////////////////////////////////////////////////////// + mockApiUtilsHelper.setUseMock(false); + mockApiUtilsHelper.enqueueMockResponse(""" + {"id": 3, "name": "Bart"}, + """); + + mockApiUtilsHelper.setMockRequestAsserter(request -> + { + Header authHeader = request.getFirstHeader("Authorization"); + assertTrue(authHeader.getValue().startsWith("Basic ")); + String usernamePassword = new String(Base64.getDecoder().decode(authHeader.getValue().replace("Basic ", "")), StandardCharsets.UTF_8); + assertEquals("god:5fingers", usernamePassword); + }); + + runSimpleGetAction(); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testOAuth2ValidToken() throws QException + { + APIBackendMetaData backend = (APIBackendMetaData) QContext.getQInstance().getBackend(TestUtils.MOCK_BACKEND_NAME); + backend.setAuthorizationType(AuthorizationType.OAUTH2); + backend.withCustomValue("accessToken", "validToken"); + + //////////////////////////////////////////////////////////////////////////////////////////// + // this will make it not use the mock makeRequest method, // + // but instead the mock executeHttpRequest, so we can test code from the base makeRequest // + //////////////////////////////////////////////////////////////////////////////////////////// + mockApiUtilsHelper.setUseMock(false); + mockApiUtilsHelper.enqueueMockResponse(""" + {"id": 3, "name": "Bart"}, + """); + + mockApiUtilsHelper.setMockRequestAsserter(request -> + { + Header authHeader = request.getFirstHeader("Authorization"); + assertTrue(authHeader.getValue().startsWith("Bearer ")); + String token = authHeader.getValue().replace("Bearer ", ""); + assertEquals("validToken", token); + }); + + runSimpleGetAction(); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testOAuth2NullToken() throws QException + { + APIBackendMetaData backend = (APIBackendMetaData) QContext.getQInstance().getBackend(TestUtils.MOCK_BACKEND_NAME); + backend.setAuthorizationType(AuthorizationType.OAUTH2); + + //////////////////////////////////////////////////////////////////////////////////////////// + // this will make it not use the mock makeRequest method, // + // but instead the mock executeHttpRequest, so we can test code from the base makeRequest // + //////////////////////////////////////////////////////////////////////////////////////////// + mockApiUtilsHelper.setUseMock(false); + mockApiUtilsHelper.enqueueMockResponse(""" + {"access_token": "myNewToken"} + """); + mockApiUtilsHelper.enqueueMockResponse(""" + {"id": 3, "name": "Bart"}, + """); + + GetOutput getOutput = runSimpleGetAction(); + assertEquals(3, getOutput.getRecord().getValueInteger("id")); + assertEquals("Bart", getOutput.getRecord().getValueString("name")); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private static GetOutput runSimpleGetAction() throws QException + { + GetInput getInput = new GetInput(); + getInput.setTableName(TestUtils.MOCK_TABLE_NAME); + getInput.setPrimaryKey(3); + return (new GetAction().execute(getInput)); + } + +} \ No newline at end of file diff --git a/qqq-backend-module-api/src/test/java/com/kingsrook/qqq/backend/module/api/mocks/MockApiActionUtils.java b/qqq-backend-module-api/src/test/java/com/kingsrook/qqq/backend/module/api/mocks/MockApiActionUtils.java new file mode 100644 index 00000000..b54f37d7 --- /dev/null +++ b/qqq-backend-module-api/src/test/java/com/kingsrook/qqq/backend/module/api/mocks/MockApiActionUtils.java @@ -0,0 +1,108 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2023. 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.module.api.mocks; + + +import java.io.IOException; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; +import com.kingsrook.qqq.backend.module.api.actions.BaseAPIActionUtil; +import com.kingsrook.qqq.backend.module.api.actions.QHttpResponse; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.client.methods.HttpRequestBase; +import org.apache.http.impl.client.CloseableHttpClient; + + +/******************************************************************************* + ** + *******************************************************************************/ +public class MockApiActionUtils extends BaseAPIActionUtil +{ + public static MockApiUtilsHelper mockApiUtilsHelper; + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + public QHttpResponse makeRequest(QTableMetaData table, HttpRequestBase request) throws QException + { + return (mockApiUtilsHelper.defaultMockMakeRequest(mockApiUtilsHelper, table, request, () -> super.makeRequest(table, request))); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + protected CloseableHttpResponse executeHttpRequest(HttpRequestBase request, CloseableHttpClient httpClient) throws IOException + { + runMockAsserter(request); + return new MockHttpResponse(mockApiUtilsHelper); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private static void runMockAsserter(HttpRequestBase request) + { + if(mockApiUtilsHelper.getMockRequestAsserter() != null) + { + try + { + mockApiUtilsHelper.getMockRequestAsserter().run(request); + } + catch(Exception e) + { + throw (new RuntimeException("Error running mock request asserter", e)); + } + } + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + protected CloseableHttpResponse executeOAuthTokenRequest(CloseableHttpClient client, HttpPost request) throws IOException + { + runMockAsserter(request); + return new MockHttpResponse(mockApiUtilsHelper); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + protected int getInitialRateLimitBackoffMillis() + { + return (1); + } + +} diff --git a/qqq-backend-module-api/src/test/java/com/kingsrook/qqq/backend/module/api/mocks/MockApiUtilsHelper.java b/qqq-backend-module-api/src/test/java/com/kingsrook/qqq/backend/module/api/mocks/MockApiUtilsHelper.java new file mode 100644 index 00000000..65b973df --- /dev/null +++ b/qqq-backend-module-api/src/test/java/com/kingsrook/qqq/backend/module/api/mocks/MockApiUtilsHelper.java @@ -0,0 +1,226 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2023. 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.module.api.mocks; + + +import java.io.IOException; +import java.util.ArrayDeque; +import java.util.Deque; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.logging.QLogger; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; +import com.kingsrook.qqq.backend.core.utils.StringUtils; +import com.kingsrook.qqq.backend.core.utils.lambdas.UnsafeConsumer; +import com.kingsrook.qqq.backend.core.utils.lambdas.UnsafeSupplier; +import com.kingsrook.qqq.backend.module.api.actions.QHttpResponse; +import org.apache.commons.io.IOUtils; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.client.methods.HttpRequestBase; +import static org.junit.jupiter.api.Assertions.fail; + + +/******************************************************************************* + ** + *******************************************************************************/ +public class MockApiUtilsHelper +{ + private static final QLogger LOG = QLogger.getLogger(MockApiUtilsHelper.class); + + private boolean useMock = true; + private Deque mockResponseQueue = new ArrayDeque<>(); + private UnsafeConsumer mockRequestAsserter = null; + + + + /******************************************************************************* + ** + *******************************************************************************/ + public void enqueueMockResponse(String json) + { + mockResponseQueue.addLast(new QHttpResponse() + .withStatusCode(200) + .withContent(json) + ); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + public void enqueueMockResponse(QHttpResponse qHttpResponse) + { + mockResponseQueue.addLast(qHttpResponse); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + public QHttpResponse returnMockResponseFromQueue(HttpRequestBase request) throws QException + { + if(getMockRequestAsserter() != null) + { + try + { + getMockRequestAsserter().run(request); + } + catch(Exception e) + { + throw (new QException("Error running mock request asserter", e)); + } + } + + if(mockResponseQueue.isEmpty()) + { + fail("No mock response is in the queue for " + request.getMethod() + " " + request.getURI()); + } + + LOG.info("Returning mock http response for " + request.getMethod() + " " + request.getURI()); + return (mockResponseQueue.removeFirst()); + } + + + + /******************************************************************************* + ** Getter for useMock + *******************************************************************************/ + public boolean getUseMock() + { + return (this.useMock); + } + + + + /******************************************************************************* + ** Setter for useMock + *******************************************************************************/ + public void setUseMock(boolean useMock) + { + this.useMock = useMock; + } + + + + /******************************************************************************* + ** Fluent setter for useMock + *******************************************************************************/ + public MockApiUtilsHelper withUseMock(boolean useMock) + { + this.useMock = useMock; + return (this); + } + + + + /******************************************************************************* + ** Getter for mockResponseQueue + *******************************************************************************/ + public Deque getMockResponseQueue() + { + return (this.mockResponseQueue); + } + + + + /******************************************************************************* + ** Setter for mockResponseQueue + *******************************************************************************/ + public void setMockResponseQueue(Deque mockResponseQueue) + { + this.mockResponseQueue = mockResponseQueue; + } + + + + /******************************************************************************* + ** Fluent setter for mockResponseQueue + *******************************************************************************/ + public MockApiUtilsHelper withMockResponseQueue(Deque mockResponseQueue) + { + this.mockResponseQueue = mockResponseQueue; + return (this); + } + + + + /******************************************************************************* + ** Getter for mockRequestAsserter + *******************************************************************************/ + public UnsafeConsumer getMockRequestAsserter() + { + return (this.mockRequestAsserter); + } + + + + /******************************************************************************* + ** Setter for mockRequestAsserter + *******************************************************************************/ + public void setMockRequestAsserter(UnsafeConsumer mockRequestAsserter) + { + this.mockRequestAsserter = mockRequestAsserter; + } + + + + /******************************************************************************* + ** Fluent setter for mockRequestAsserter + *******************************************************************************/ + public MockApiUtilsHelper withMockRequestAsserter(UnsafeConsumer mockRequestAsserter) + { + this.mockRequestAsserter = mockRequestAsserter; + return (this); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + public QHttpResponse defaultMockMakeRequest(MockApiUtilsHelper mockApiUtilsHelper, QTableMetaData table, HttpRequestBase request, UnsafeSupplier superMethod) throws QException + { + if(!mockApiUtilsHelper.getUseMock()) + { + QHttpResponse superResponse = superMethod.get(); + System.out.println("== non-mock response content: =="); + System.out.println("Code: " + superResponse.getStatusCode()); + System.out.println(superResponse.getContent()); + System.out.println("== =="); + return (superResponse); + } + + return mockApiUtilsHelper.returnMockResponseFromQueue(request); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @SuppressWarnings("unchecked") + public static String readRequestBody(HttpRequestBase request) throws IOException + { + return (StringUtils.join("\n", IOUtils.readLines(((HttpPost) request).getEntity().getContent()))); + } +} diff --git a/qqq-backend-module-api/src/test/java/com/kingsrook/qqq/backend/module/api/mocks/MockHttpResponse.java b/qqq-backend-module-api/src/test/java/com/kingsrook/qqq/backend/module/api/mocks/MockHttpResponse.java new file mode 100644 index 00000000..dbc61f69 --- /dev/null +++ b/qqq-backend-module-api/src/test/java/com/kingsrook/qqq/backend/module/api/mocks/MockHttpResponse.java @@ -0,0 +1,302 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2023. 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.module.api.mocks; + + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.util.Locale; +import com.kingsrook.qqq.backend.module.api.actions.QHttpResponse; +import org.apache.http.Header; +import org.apache.http.HeaderIterator; +import org.apache.http.HttpEntity; +import org.apache.http.ProtocolVersion; +import org.apache.http.StatusLine; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.entity.BasicHttpEntity; +import org.apache.http.message.BasicStatusLine; +import org.apache.http.params.HttpParams; + + +/******************************************************************************* + ** + *******************************************************************************/ +public class MockHttpResponse implements CloseableHttpResponse +{ + private final MockApiUtilsHelper mockApiUtilsHelper; + + + + /******************************************************************************* + ** Constructor + ** + *******************************************************************************/ + public MockHttpResponse(MockApiUtilsHelper mockApiUtilsHelper) + { + this.mockApiUtilsHelper = mockApiUtilsHelper; + } + + + + @Override + public void close() throws IOException + { + + } + + + + @Override + public StatusLine getStatusLine() + { + ProtocolVersion protocolVersion = new ProtocolVersion("HTTP", 1, 1); + + if(!mockApiUtilsHelper.getMockResponseQueue().isEmpty()) + { + QHttpResponse qHttpResponse = mockApiUtilsHelper.getMockResponseQueue().peekFirst(); + return (new BasicStatusLine(protocolVersion, qHttpResponse.getStatusCode(), qHttpResponse.getStatusReasonPhrase())); + } + else + { + return (new BasicStatusLine(protocolVersion, 200, "OK")); + } + } + + + + @Override + public void setStatusLine(StatusLine statusLine) + { + + } + + + + @Override + public void setStatusLine(ProtocolVersion protocolVersion, int i) + { + + } + + + + @Override + public void setStatusLine(ProtocolVersion protocolVersion, int i, String s) + { + + } + + + + @Override + public void setStatusCode(int i) throws IllegalStateException + { + + } + + + + @Override + public void setReasonPhrase(String s) throws IllegalStateException + { + + } + + + + @Override + public HttpEntity getEntity() + { + BasicHttpEntity basicHttpEntity = new BasicHttpEntity(); + + if(!mockApiUtilsHelper.getMockResponseQueue().isEmpty()) + { + QHttpResponse qHttpResponse = mockApiUtilsHelper.getMockResponseQueue().removeFirst(); + basicHttpEntity.setContent(new ByteArrayInputStream(qHttpResponse.getContent().getBytes())); + } + else + { + basicHttpEntity.setContent(new ByteArrayInputStream("".getBytes())); + } + return (basicHttpEntity); + } + + + + @Override + public void setEntity(HttpEntity httpEntity) + { + + } + + + + @Override + public Locale getLocale() + { + return null; + } + + + + @Override + public void setLocale(Locale locale) + { + + } + + + + @Override + public ProtocolVersion getProtocolVersion() + { + return null; + } + + + + @Override + public boolean containsHeader(String s) + { + return false; + } + + + + @Override + public Header[] getHeaders(String s) + { + return new Header[0]; + } + + + + @Override + public Header getFirstHeader(String s) + { + return null; + } + + + + @Override + public Header getLastHeader(String s) + { + return null; + } + + + + @Override + public Header[] getAllHeaders() + { + return new Header[0]; + } + + + + @Override + public void addHeader(Header header) + { + + } + + + + @Override + public void addHeader(String s, String s1) + { + + } + + + + @Override + public void setHeader(Header header) + { + + } + + + + @Override + public void setHeader(String s, String s1) + { + + } + + + + @Override + public void setHeaders(Header[] headers) + { + + } + + + + @Override + public void removeHeader(Header header) + { + + } + + + + @Override + public void removeHeaders(String s) + { + + } + + + + @Override + public HeaderIterator headerIterator() + { + return null; + } + + + + @Override + public HeaderIterator headerIterator(String s) + { + return null; + } + + + + @Override + public HttpParams getParams() + { + return null; + } + + + + @Override + public void setParams(HttpParams httpParams) + { + + } +} diff --git a/qqq-backend-module-rdbms/src/main/java/com/kingsrook/qqq/backend/module/rdbms/jdbc/QueryManager.java b/qqq-backend-module-rdbms/src/main/java/com/kingsrook/qqq/backend/module/rdbms/jdbc/QueryManager.java index 1fedb981..1f9dfbdd 100644 --- a/qqq-backend-module-rdbms/src/main/java/com/kingsrook/qqq/backend/module/rdbms/jdbc/QueryManager.java +++ b/qqq-backend-module-rdbms/src/main/java/com/kingsrook/qqq/backend/module/rdbms/jdbc/QueryManager.java @@ -50,6 +50,7 @@ import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; +import com.kingsrook.qqq.backend.core.exceptions.QException; import com.kingsrook.qqq.backend.core.logging.QLogger; import com.kingsrook.qqq.backend.core.utils.CollectionUtils; import com.kingsrook.qqq.backend.core.utils.StringUtils; @@ -87,7 +88,7 @@ public class QueryManager /******************************************************************************* ** *******************************************************************************/ - void processResultSet(ResultSet rs) throws SQLException; + void processResultSet(ResultSet rs) throws SQLException, QException; } @@ -95,7 +96,7 @@ public class QueryManager /******************************************************************************* ** *******************************************************************************/ - public static void executeStatement(Connection connection, String sql, ResultSetProcessor processor, Object... params) throws SQLException + public static void executeStatement(Connection connection, String sql, ResultSetProcessor processor, Object... params) throws SQLException, QException { PreparedStatement statement = null; try @@ -118,7 +119,7 @@ public class QueryManager ** Let the caller provide their own prepared statement (e.g., possibly with some ** customized settings/optimizations). *******************************************************************************/ - public static void executeStatement(PreparedStatement statement, ResultSetProcessor processor, Object... params) throws SQLException + public static void executeStatement(PreparedStatement statement, ResultSetProcessor processor, Object... params) throws SQLException, QException { ResultSet resultSet = null; diff --git a/qqq-language-support-javascript/pom.xml b/qqq-language-support-javascript/pom.xml index ba2af5a4..0210a4a8 100644 --- a/qqq-language-support-javascript/pom.xml +++ b/qqq-language-support-javascript/pom.xml @@ -33,10 +33,8 @@ + - - 0.10 - 0.10 diff --git a/qqq-middleware-api/src/main/java/com/kingsrook/qqq/api/actions/ApiImplementation.java b/qqq-middleware-api/src/main/java/com/kingsrook/qqq/api/actions/ApiImplementation.java new file mode 100644 index 00000000..ea25ac48 --- /dev/null +++ b/qqq-middleware-api/src/main/java/com/kingsrook/qqq/api/actions/ApiImplementation.java @@ -0,0 +1,1116 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2023. 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.api.actions; + + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import com.kingsrook.qqq.api.javalin.QBadRequestException; +import com.kingsrook.qqq.api.model.APIVersion; +import com.kingsrook.qqq.api.model.metadata.ApiInstanceMetaData; +import com.kingsrook.qqq.api.model.metadata.ApiOperation; +import com.kingsrook.qqq.api.model.metadata.tables.ApiTableMetaData; +import com.kingsrook.qqq.api.model.metadata.tables.ApiTableMetaDataContainer; +import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper; +import com.kingsrook.qqq.backend.core.actions.permissions.TablePermissionSubType; +import com.kingsrook.qqq.backend.core.actions.tables.CountAction; +import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction; +import com.kingsrook.qqq.backend.core.actions.tables.GetAction; +import com.kingsrook.qqq.backend.core.actions.tables.InsertAction; +import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; +import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.exceptions.QNotFoundException; +import com.kingsrook.qqq.backend.core.logging.LogPair; +import com.kingsrook.qqq.backend.core.logging.QLogger; +import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountOutput; +import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteOutput; +import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetOutput; +import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertOutput; +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.QFilterOrderBy; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput; +import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateOutput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; +import com.kingsrook.qqq.backend.core.utils.CollectionUtils; +import com.kingsrook.qqq.backend.core.utils.Pair; +import com.kingsrook.qqq.backend.core.utils.StringUtils; +import com.kingsrook.qqq.backend.core.utils.ValueUtils; +import com.kingsrook.qqq.backend.core.utils.collections.ListBuilder; +import org.apache.commons.lang.BooleanUtils; +import org.eclipse.jetty.http.HttpStatus; +import org.json.JSONArray; +import org.json.JSONObject; +import org.json.JSONTokener; +import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair; + + +/******************************************************************************* + ** + *******************************************************************************/ +public class ApiImplementation +{ + private static final QLogger LOG = QLogger.getLogger(ApiImplementation.class); + + ///////////////////////////////////// + // key: Pair // + ///////////////////////////////////// + private static Map, Map> tableApiNameMap = new HashMap<>(); + + + + /******************************************************************************* + ** + *******************************************************************************/ + public static Map query(ApiInstanceMetaData apiInstanceMetaData, String version, String tableApiName, Map> paramMap) throws QException + { + List badRequestMessages = new ArrayList<>(); + + QTableMetaData table = validateTableAndVersion(apiInstanceMetaData, version, tableApiName, ApiOperation.QUERY_BY_QUERY_STRING); + String tableName = table.getName(); + + QueryInput queryInput = new QueryInput(); + queryInput.setTableName(tableName); + queryInput.setIncludeAssociations(true); + + PermissionsHelper.checkTablePermissionThrowing(queryInput, TablePermissionSubType.READ); + + String pageSizeParam = getSingleParam(paramMap, "pageSize"); + String pageNoParam = getSingleParam(paramMap, "pageNo"); + String booleanOperator = getSingleParam(paramMap, "booleanOperator"); + String includeCountParam = getSingleParam(paramMap, "includeCount"); + String orderBy = getSingleParam(paramMap, "orderBy"); + + Integer pageSize = 50; + if(StringUtils.hasContent(pageSizeParam)) + { + try + { + pageSize = ValueUtils.getValueAsInteger(pageSizeParam); + } + catch(Exception e) + { + badRequestMessages.add("Could not parse pageSize as an integer"); + } + } + if(pageSize < 1 || pageSize > 1000) + { + badRequestMessages.add("pageSize must be between 1 and 1000."); + } + + Integer pageNo = 1; + if(StringUtils.hasContent(pageNoParam)) + { + try + { + pageNo = ValueUtils.getValueAsInteger(pageNoParam); + } + catch(Exception e) + { + badRequestMessages.add("Could not parse pageNo as an integer"); + } + } + if(pageNo < 1) + { + badRequestMessages.add("pageNo must be greater than 0."); + } + + queryInput.setLimit(pageSize); + queryInput.setSkip((pageNo - 1) * pageSize); + + // queryInput.setQueryJoins(processQueryJoinsParam(context)); + + QQueryFilter filter = new QQueryFilter(); + if("and".equalsIgnoreCase(booleanOperator)) + { + filter.setBooleanOperator(QQueryFilter.BooleanOperator.AND); + } + else if("or".equalsIgnoreCase(booleanOperator)) + { + filter.setBooleanOperator(QQueryFilter.BooleanOperator.OR); + } + else if(StringUtils.hasContent(booleanOperator)) + { + badRequestMessages.add("booleanOperator must be either AND or OR."); + } + + boolean includeCount = true; + if("true".equalsIgnoreCase(includeCountParam)) + { + includeCount = true; + } + else if("false".equalsIgnoreCase(includeCountParam)) + { + includeCount = false; + } + else if(StringUtils.hasContent(includeCountParam)) + { + badRequestMessages.add("includeCount must be either true or false"); + } + + if(StringUtils.hasContent(orderBy)) + { + for(String orderByPart : orderBy.split(",")) + { + orderByPart = orderByPart.trim(); + String[] orderByNameDirection = orderByPart.split(" +"); + boolean asc = true; + if(orderByNameDirection.length == 2) + { + if("asc".equalsIgnoreCase(orderByNameDirection[1])) + { + asc = true; + } + else if("desc".equalsIgnoreCase(orderByNameDirection[1])) + { + asc = false; + } + else + { + badRequestMessages.add("orderBy direction for field " + orderByNameDirection[0] + " must be either ASC or DESC."); + } + } + else if(orderByNameDirection.length > 2) + { + badRequestMessages.add("Unrecognized format for orderBy clause: " + orderByPart + ". Expected: fieldName [ASC|DESC]."); + } + + try + { + QFieldMetaData field = table.getField(orderByNameDirection[0]); + filter.withOrderBy(new QFilterOrderBy(field.getName(), asc)); + } + catch(Exception e) + { + badRequestMessages.add("Unrecognized orderBy field name: " + orderByNameDirection[0] + "."); + } + } + } + else + { + filter.withOrderBy(new QFilterOrderBy(table.getPrimaryKeyField(), false)); + } + + Set nonFilterParams = Set.of("pageSize", "pageNo", "orderBy", "booleanOperator", "includeCount"); + + //////////////////////////// + // look for filter params // + //////////////////////////// + for(Map.Entry> entry : paramMap.entrySet()) + { + String name = entry.getKey(); + List values = entry.getValue(); + + if(nonFilterParams.contains(name)) + { + continue; + } + + try + { + QFieldMetaData field = table.getField(name); + for(String value : values) + { + if(StringUtils.hasContent(value)) + { + try + { + filter.addCriteria(parseQueryParamToCriteria(name, value)); + } + catch(Exception e) + { + badRequestMessages.add(e.getMessage()); + } + } + } + } + catch(Exception e) + { + badRequestMessages.add("Unrecognized filter criteria field: " + name); + } + } + + ////////////////////////////////////////// + // no more badRequest checks below here // + ////////////////////////////////////////// + if(!badRequestMessages.isEmpty()) + { + if(badRequestMessages.size() == 1) + { + throw (new QBadRequestException(badRequestMessages.get(0))); + } + else + { + throw (new QBadRequestException("Request failed with " + badRequestMessages.size() + " reasons: " + StringUtils.join(" \n", badRequestMessages))); + } + } + + ////////////////// + // do the query // + ////////////////// + QueryAction queryAction = new QueryAction(); + queryInput.setFilter(filter); + QueryOutput queryOutput = queryAction.execute(queryInput); + + Map output = new LinkedHashMap<>(); + output.put("pageNo", pageNo); + output.put("pageSize", pageSize); + + //////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // map record fields for api // + // note - don't put them in the output until after the count, just because that looks a little nicer, i think // + //////////////////////////////////////////////////////////////////////////////////////////////////////////////// + ArrayList> records = new ArrayList<>(); + for(QRecord record : queryOutput.getRecords()) + { + records.add(QRecordApiAdapter.qRecordToApiMap(record, tableName, apiInstanceMetaData.getName(), version)); + } + + ///////////////////////////// + // optionally do the count // + ///////////////////////////// + if(includeCount) + { + CountInput countInput = new CountInput(); + countInput.setTableName(tableName); + countInput.setFilter(filter); + CountOutput countOutput = new CountAction().execute(countInput); + output.put("count", countOutput.getCount()); + } + + output.put("records", records); + + return (output); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + public static Map insert(ApiInstanceMetaData apiInstanceMetaData, String version, String tableApiName, String body) throws QException + { + QTableMetaData table = validateTableAndVersion(apiInstanceMetaData, version, tableApiName, ApiOperation.INSERT); + String tableName = table.getName(); + + InsertInput insertInput = new InsertInput(); + + insertInput.setTableName(tableName); + + PermissionsHelper.checkTablePermissionThrowing(insertInput, TablePermissionSubType.INSERT); + + try + { + if(!StringUtils.hasContent(body)) + { + throw (new QBadRequestException("Missing required POST body")); + } + + JSONTokener jsonTokener = new JSONTokener(body.trim()); + JSONObject jsonObject = new JSONObject(jsonTokener); + + insertInput.setRecords(List.of(QRecordApiAdapter.apiJsonObjectToQRecord(jsonObject, tableName, apiInstanceMetaData.getName(), version, false))); + + if(jsonTokener.more()) + { + throw (new QBadRequestException("Body contained more than a single JSON object.")); + } + } + catch(QBadRequestException qbre) + { + throw (qbre); + } + catch(Exception e) + { + throw (new QBadRequestException("Body could not be parsed as a JSON object: " + e.getMessage(), e)); + } + + InsertAction insertAction = new InsertAction(); + InsertOutput insertOutput = insertAction.execute(insertInput); + + List errors = insertOutput.getRecords().get(0).getErrors(); + if(CollectionUtils.nullSafeHasContents(errors)) + { + boolean isBadRequest = areAnyErrorsBadRequest(errors); + + String message = "Error inserting " + table.getLabel() + ": " + StringUtils.joinWithCommasAndAnd(errors); + if(isBadRequest) + { + throw (new QBadRequestException(message)); + } + else + { + throw (new QException(message)); + } + } + + LinkedHashMap outputRecord = new LinkedHashMap<>(); + outputRecord.put(table.getPrimaryKeyField(), insertOutput.getRecords().get(0).getValue(table.getPrimaryKeyField())); + return (outputRecord); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + public static List> bulkInsert(ApiInstanceMetaData apiInstanceMetaData, String version, String tableApiName, String body) throws QException + { + QTableMetaData table = validateTableAndVersion(apiInstanceMetaData, version, tableApiName, ApiOperation.BULK_INSERT); + String tableName = table.getName(); + + InsertInput insertInput = new InsertInput(); + insertInput.setTableName(tableName); + + PermissionsHelper.checkTablePermissionThrowing(insertInput, TablePermissionSubType.INSERT); + + ///////////////// + // build input // + ///////////////// + try + { + if(!StringUtils.hasContent(body)) + { + throw (new QBadRequestException("Missing required POST body")); + } + + ArrayList recordList = new ArrayList<>(); + insertInput.setRecords(recordList); + + JSONTokener jsonTokener = new JSONTokener(body.trim()); + JSONArray jsonArray = new JSONArray(jsonTokener); + + for(int i = 0; i < jsonArray.length(); i++) + { + JSONObject jsonObject = jsonArray.getJSONObject(i); + recordList.add(QRecordApiAdapter.apiJsonObjectToQRecord(jsonObject, tableName, apiInstanceMetaData.getName(), version, false)); + } + + if(jsonTokener.more()) + { + throw (new QBadRequestException("Body contained more than a single JSON array.")); + } + + if(recordList.isEmpty()) + { + throw (new QBadRequestException("No records were found in the POST body")); + } + } + catch(QBadRequestException qbre) + { + throw (qbre); + } + catch(Exception e) + { + throw (new QBadRequestException("Body could not be parsed as a JSON array: " + e.getMessage(), e)); + } + + ////////////// + // execute! // + ////////////// + InsertAction insertAction = new InsertAction(); + InsertOutput insertOutput = insertAction.execute(insertInput); + + /////////////////////////////////////// + // process records to build response // + /////////////////////////////////////// + List> response = new ArrayList<>(); + for(QRecord record : insertOutput.getRecords()) + { + LinkedHashMap outputRecord = new LinkedHashMap<>(); + response.add(outputRecord); + + List errors = record.getErrors(); + if(CollectionUtils.nullSafeHasContents(errors)) + { + outputRecord.put("statusCode", HttpStatus.Code.BAD_REQUEST.getCode()); + outputRecord.put("statusText", HttpStatus.Code.BAD_REQUEST.getMessage()); + outputRecord.put("error", "Error inserting " + table.getLabel() + ": " + StringUtils.joinWithCommasAndAnd(errors)); + } + else + { + outputRecord.put("statusCode", HttpStatus.Code.CREATED.getCode()); + outputRecord.put("statusText", HttpStatus.Code.CREATED.getMessage()); + outputRecord.put(table.getPrimaryKeyField(), record.getValue(table.getPrimaryKeyField())); + } + } + + return (response); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + public static Map get(ApiInstanceMetaData apiInstanceMetaData, String version, String tableApiName, String primaryKey) throws QException + { + QTableMetaData table = validateTableAndVersion(apiInstanceMetaData, version, tableApiName, ApiOperation.GET); + String tableName = table.getName(); + + GetInput getInput = new GetInput(); + getInput.setTableName(tableName); + + PermissionsHelper.checkTablePermissionThrowing(getInput, TablePermissionSubType.READ); + + getInput.setPrimaryKey(primaryKey); + getInput.setIncludeAssociations(true); + + GetAction getAction = new GetAction(); + GetOutput getOutput = getAction.execute(getInput); + + /////////////////////////////////////////////////////// + // throw a not found error if the record isn't found // + /////////////////////////////////////////////////////// + QRecord record = getOutput.getRecord(); + if(record == null) + { + throw (new QNotFoundException("Could not find " + table.getLabel() + " with " + + table.getFields().get(table.getPrimaryKeyField()).getLabel() + " of " + primaryKey)); + } + + Map outputRecord = QRecordApiAdapter.qRecordToApiMap(record, tableName, apiInstanceMetaData.getName(), version); + return (outputRecord); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + public static void update(ApiInstanceMetaData apiInstanceMetaData, String version, String tableApiName, String primaryKey, String body) throws QException + { + QTableMetaData table = validateTableAndVersion(apiInstanceMetaData, version, tableApiName, ApiOperation.UPDATE); + String tableName = table.getName(); + + UpdateInput updateInput = new UpdateInput(); + updateInput.setTableName(tableName); + + PermissionsHelper.checkTablePermissionThrowing(updateInput, TablePermissionSubType.EDIT); + + try + { + if(!StringUtils.hasContent(body)) + { + throw (new QBadRequestException("Missing required PATCH body")); + } + + JSONTokener jsonTokener = new JSONTokener(body.trim()); + JSONObject jsonObject = new JSONObject(jsonTokener); + + QRecord qRecord = QRecordApiAdapter.apiJsonObjectToQRecord(jsonObject, tableName, apiInstanceMetaData.getName(), version, false); + qRecord.setValue(table.getPrimaryKeyField(), primaryKey); + updateInput.setRecords(List.of(qRecord)); + + if(jsonTokener.more()) + { + throw (new QBadRequestException("Body contained more than a single JSON object.")); + } + } + catch(QBadRequestException qbre) + { + throw (qbre); + } + catch(Exception e) + { + throw (new QBadRequestException("Body could not be parsed as a JSON object: " + e.getMessage(), e)); + } + + UpdateAction updateAction = new UpdateAction(); + UpdateOutput updateOutput = updateAction.execute(updateInput); + + List errors = updateOutput.getRecords().get(0).getErrors(); + if(CollectionUtils.nullSafeHasContents(errors)) + { + if(areAnyErrorsNotFound(errors)) + { + throw (new QNotFoundException("Could not find " + table.getLabel() + " with " + table.getFields().get(table.getPrimaryKeyField()).getLabel() + " of " + primaryKey)); + } + else + { + boolean isBadRequest = areAnyErrorsBadRequest(errors); + + String message = "Error updating " + table.getLabel() + ": " + StringUtils.joinWithCommasAndAnd(errors); + if(isBadRequest) + { + throw (new QBadRequestException(message)); + } + else + { + throw (new QException(message)); + } + } + } + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + public static List> bulkUpdate(ApiInstanceMetaData apiInstanceMetaData, String version, String tableApiName, String body) throws QException + { + QTableMetaData table = validateTableAndVersion(apiInstanceMetaData, version, tableApiName, ApiOperation.BULK_UPDATE); + String tableName = table.getName(); + + UpdateInput updateInput = new UpdateInput(); + updateInput.setTableName(tableName); + + PermissionsHelper.checkTablePermissionThrowing(updateInput, TablePermissionSubType.EDIT); + + ///////////////// + // build input // + ///////////////// + try + { + if(!StringUtils.hasContent(body)) + { + throw (new QBadRequestException("Missing required PATCH body")); + } + + ArrayList recordList = new ArrayList<>(); + updateInput.setRecords(recordList); + + JSONTokener jsonTokener = new JSONTokener(body.trim()); + JSONArray jsonArray = new JSONArray(jsonTokener); + + for(int i = 0; i < jsonArray.length(); i++) + { + JSONObject jsonObject = jsonArray.getJSONObject(i); + recordList.add(QRecordApiAdapter.apiJsonObjectToQRecord(jsonObject, tableName, apiInstanceMetaData.getName(), version, true)); + } + + if(jsonTokener.more()) + { + throw (new QBadRequestException("Body contained more than a single JSON array.")); + } + + if(recordList.isEmpty()) + { + throw (new QBadRequestException("No records were found in the PATCH body")); + } + } + catch(QBadRequestException qbre) + { + throw (qbre); + } + catch(Exception e) + { + throw (new QBadRequestException("Body could not be parsed as a JSON array: " + e.getMessage(), e)); + } + + ////////////// + // execute! // + ////////////// + UpdateAction updateAction = new UpdateAction(); + UpdateOutput updateOutput = updateAction.execute(updateInput); + + /////////////////////////////////////// + // process records to build response // + /////////////////////////////////////// + List> response = new ArrayList<>(); + int i = 0; + for(QRecord record : updateOutput.getRecords()) + { + LinkedHashMap outputRecord = new LinkedHashMap<>(); + response.add(outputRecord); + + try + { + QRecord inputRecord = updateInput.getRecords().get(i); + Serializable primaryKey = inputRecord.getValue(table.getPrimaryKeyField()); + outputRecord.put(table.getPrimaryKeyField(), primaryKey); + } + catch(Exception e) + { + ////////// + // omit // + ////////// + } + + List errors = record.getErrors(); + if(CollectionUtils.nullSafeHasContents(errors)) + { + outputRecord.put("error", "Error updating " + table.getLabel() + ": " + StringUtils.joinWithCommasAndAnd(errors)); + if(areAnyErrorsNotFound(errors)) + { + outputRecord.put("statusCode", HttpStatus.Code.NOT_FOUND.getCode()); + outputRecord.put("statusText", HttpStatus.Code.NOT_FOUND.getMessage()); + } + else + { + outputRecord.put("statusCode", HttpStatus.Code.BAD_REQUEST.getCode()); + outputRecord.put("statusText", HttpStatus.Code.BAD_REQUEST.getMessage()); + } + } + else + { + outputRecord.put("statusCode", HttpStatus.Code.NO_CONTENT.getCode()); + outputRecord.put("statusText", HttpStatus.Code.NO_CONTENT.getMessage()); + } + + i++; + } + + return (response); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + public static void delete(ApiInstanceMetaData apiInstanceMetaData, String version, String tableApiName, String primaryKey) throws QException + { + QTableMetaData table = validateTableAndVersion(apiInstanceMetaData, version, tableApiName, ApiOperation.DELETE); + String tableName = table.getName(); + + DeleteInput deleteInput = new DeleteInput(); + deleteInput.setTableName(tableName); + deleteInput.setPrimaryKeys(List.of(primaryKey)); + + PermissionsHelper.checkTablePermissionThrowing(deleteInput, TablePermissionSubType.DELETE); + + /////////////////// + // do the delete // + /////////////////// + DeleteAction deleteAction = new DeleteAction(); + DeleteOutput deleteOutput = deleteAction.execute(deleteInput); + if(CollectionUtils.nullSafeHasContents(deleteOutput.getRecordsWithErrors())) + { + if(areAnyErrorsNotFound(deleteOutput.getRecordsWithErrors().get(0).getErrors())) + { + throw (new QNotFoundException("Could not find " + table.getLabel() + " with " + table.getFields().get(table.getPrimaryKeyField()).getLabel() + " of " + primaryKey)); + } + else + { + throw (new QException("Error deleting " + table.getLabel() + ": " + StringUtils.joinWithCommasAndAnd(deleteOutput.getRecordsWithErrors().get(0).getErrors()))); + } + } + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + public static List> bulkDelete(ApiInstanceMetaData apiInstanceMetaData, String version, String tableApiName, String body) throws QException + { + QTableMetaData table = validateTableAndVersion(apiInstanceMetaData, version, tableApiName, ApiOperation.BULK_DELETE); + String tableName = table.getName(); + + DeleteInput deleteInput = new DeleteInput(); + deleteInput.setTableName(tableName); + + PermissionsHelper.checkTablePermissionThrowing(deleteInput, TablePermissionSubType.DELETE); + + ///////////////// + // build input // + ///////////////// + try + { + if(!StringUtils.hasContent(body)) + { + throw (new QBadRequestException("Missing required DELETE body")); + } + + ArrayList primaryKeyList = new ArrayList<>(); + deleteInput.setPrimaryKeys(primaryKeyList); + + JSONTokener jsonTokener = new JSONTokener(body.trim()); + JSONArray jsonArray = new JSONArray(jsonTokener); + + for(int i = 0; i < jsonArray.length(); i++) + { + Object object = jsonArray.get(i); + if(object instanceof JSONArray || object instanceof JSONObject) + { + throw (new QBadRequestException("One or more elements inside the DELETE body JSONArray was not a primitive value")); + } + primaryKeyList.add(String.valueOf(object)); + } + + if(jsonTokener.more()) + { + throw (new QBadRequestException("Body contained more than a single JSON array.")); + } + + if(primaryKeyList.isEmpty()) + { + throw (new QBadRequestException("No primary keys were found in the DELETE body")); + } + } + catch(QBadRequestException qbre) + { + throw (qbre); + } + catch(Exception e) + { + throw (new QBadRequestException("Body could not be parsed as a JSON array: " + e.getMessage(), e)); + } + + ////////////// + // execute! // + ////////////// + DeleteAction deleteAction = new DeleteAction(); + DeleteOutput deleteOutput = deleteAction.execute(deleteInput); + + /////////////////////////////////////// + // process records to build response // + /////////////////////////////////////// + List> response = new ArrayList<>(); + + List recordsWithErrors = deleteOutput.getRecordsWithErrors(); + Map> primaryKeyToErrorsMap = new HashMap<>(); + for(QRecord recordWithError : CollectionUtils.nonNullList(recordsWithErrors)) + { + String primaryKey = recordWithError.getValueString(table.getPrimaryKeyField()); + primaryKeyToErrorsMap.put(primaryKey, recordWithError.getErrors()); + } + + for(Serializable primaryKey : deleteInput.getPrimaryKeys()) + { + LinkedHashMap outputRecord = new LinkedHashMap<>(); + response.add(outputRecord); + outputRecord.put(table.getPrimaryKeyField(), primaryKey); + + String primaryKeyString = ValueUtils.getValueAsString(primaryKey); + List errors = primaryKeyToErrorsMap.get(primaryKeyString); + if(CollectionUtils.nullSafeHasContents(errors)) + { + outputRecord.put("error", "Error deleting " + table.getLabel() + ": " + StringUtils.joinWithCommasAndAnd(errors)); + if(areAnyErrorsNotFound(errors)) + { + outputRecord.put("statusCode", HttpStatus.Code.NOT_FOUND.getCode()); + outputRecord.put("statusText", HttpStatus.Code.NOT_FOUND.getMessage()); + } + else + { + outputRecord.put("statusCode", HttpStatus.Code.BAD_REQUEST.getCode()); + outputRecord.put("statusText", HttpStatus.Code.BAD_REQUEST.getMessage()); + } + } + else + { + outputRecord.put("statusCode", HttpStatus.Code.NO_CONTENT.getCode()); + outputRecord.put("statusText", HttpStatus.Code.NO_CONTENT.getMessage()); + } + } + + return (response); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private static String getSingleParam(Map> paramMap, String name) + { + if(CollectionUtils.nullSafeHasContents(paramMap.get(name))) + { + return (paramMap.get(name).get(0)); + } + + return (null); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private enum Operator + { + /////////////////////////////////////////////////////////////////////////////////// + // order of these is important (e.g., because some are a sub-string of others!!) // + /////////////////////////////////////////////////////////////////////////////////// + EQ("=", QCriteriaOperator.EQUALS, QCriteriaOperator.NOT_EQUALS, 1), + LTE("<=", QCriteriaOperator.LESS_THAN_OR_EQUALS, null, 1), + GTE(">=", QCriteriaOperator.GREATER_THAN_OR_EQUALS, null, 1), + LT("<", QCriteriaOperator.LESS_THAN, null, 1), + GT(">", QCriteriaOperator.GREATER_THAN, null, 1), + EMPTY("EMPTY", QCriteriaOperator.IS_BLANK, QCriteriaOperator.IS_NOT_BLANK, 0), + BETWEEN("BETWEEN ", QCriteriaOperator.BETWEEN, QCriteriaOperator.NOT_BETWEEN, 2), + IN("IN ", QCriteriaOperator.IN, QCriteriaOperator.NOT_IN, null), + LIKE("LIKE ", QCriteriaOperator.LIKE, QCriteriaOperator.NOT_LIKE, 1); + + + private final String prefix; + private final QCriteriaOperator positiveOperator; + private final QCriteriaOperator negativeOperator; + private final Integer noOfValues; // null means many (IN) + + + + /******************************************************************************* + ** + *******************************************************************************/ + Operator(String prefix, QCriteriaOperator positiveOperator, QCriteriaOperator negativeOperator, Integer noOfValues) + { + this.prefix = prefix; + this.positiveOperator = positiveOperator; + this.negativeOperator = negativeOperator; + this.noOfValues = noOfValues; + } + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private static QFilterCriteria parseQueryParamToCriteria(String name, String value) throws QException + { + /////////////////////////////////// + // process & discard a leading ! // + /////////////////////////////////// + boolean isNot = false; + if(value.startsWith("!") && value.length() > 1) + { + isNot = true; + value = value.substring(1); + } + + ////////////////////////// + // look for an operator // + ////////////////////////// + Operator selectedOperator = null; + for(Operator op : Operator.values()) + { + if(value.startsWith(op.prefix)) + { + selectedOperator = op; + if(selectedOperator.negativeOperator == null && isNot) + { + throw (new QBadRequestException("Unsupported operator: !" + selectedOperator.prefix)); + } + break; + } + } + + ///////////////////////////////////////////////////////////////////////////////////////////// + // if an operator was found, strip it away from the value for figuring out the values part // + ///////////////////////////////////////////////////////////////////////////////////////////// + if(selectedOperator != null) + { + value = value.substring(selectedOperator.prefix.length()); + } + else + { + //////////////////////////////////////////////////////////////// + // else - assume the default operator, and use the full value // + //////////////////////////////////////////////////////////////// + selectedOperator = Operator.EQ; + } + + //////////////////////////////////// + // figure out the criteria values // + // todo - quotes? // + //////////////////////////////////// + List criteriaValues; + if(selectedOperator.noOfValues == null) + { + criteriaValues = Arrays.asList(value.split(",")); + } + else if(selectedOperator.noOfValues == 1) + { + criteriaValues = ListBuilder.of(value); + } + else if(selectedOperator.noOfValues == 0) + { + if(StringUtils.hasContent(value)) + { + throw (new QBadRequestException("Unexpected value after operator " + selectedOperator.prefix + " for field " + name)); + } + criteriaValues = null; + } + else if(selectedOperator.noOfValues == 2) + { + criteriaValues = Arrays.asList(value.split(",")); + if(criteriaValues.size() != 2) + { + throw (new QBadRequestException("Operator " + selectedOperator.prefix + " for field " + name + " requires 2 values (received " + criteriaValues.size() + ")")); + } + } + else + { + throw (new QException("Unexpected noOfValues [" + selectedOperator.noOfValues + "] in operator [" + selectedOperator + "]")); + } + + return (new QFilterCriteria(name, isNot ? selectedOperator.negativeOperator : selectedOperator.positiveOperator, criteriaValues)); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + public static QTableMetaData validateTableAndVersion(ApiInstanceMetaData apiInstanceMetaData, String version, String tableApiName, ApiOperation operation) throws QNotFoundException + { + QTableMetaData table = getTableByApiName(apiInstanceMetaData.getName(), version, tableApiName); + LogPair[] logPairs = new LogPair[] { logPair("apiName", apiInstanceMetaData.getName()), logPair("version", version), logPair("tableApiName", tableApiName), logPair("operation", operation) }; + + if(table == null) + { + LOG.info("404 because table is null (tableApiName=" + tableApiName + ")", logPairs); + throw (new QNotFoundException("Could not find a table named " + tableApiName + " in this api.")); + } + + if(BooleanUtils.isTrue(table.getIsHidden())) + { + LOG.info("404 because table isHidden", logPairs); + throw (new QNotFoundException("Could not find a table named " + tableApiName + " in this api.")); + } + + ApiTableMetaDataContainer apiTableMetaDataContainer = ApiTableMetaDataContainer.of(table); + if(apiTableMetaDataContainer == null) + { + LOG.info("404 because table apiMetaDataContainer is null", logPairs); + throw (new QNotFoundException("Could not find a table named " + tableApiName + " in this api.")); + } + + ApiTableMetaData apiTableMetaData = apiTableMetaDataContainer.getApiTableMetaData(apiInstanceMetaData.getName()); + if(apiTableMetaData == null) + { + LOG.info("404 because table apiMetaData is null", logPairs); + throw (new QNotFoundException("Could not find a table named " + tableApiName + " in this api.")); + } + + if(BooleanUtils.isTrue(apiTableMetaData.getIsExcluded())) + { + LOG.info("404 because table is excluded", logPairs); + throw (new QNotFoundException("Could not find a table named " + tableApiName + " in this api.")); + } + + if(!operation.isOperationEnabled(List.of(apiInstanceMetaData, apiTableMetaData))) + { + LOG.info("404 because api operation is not enabled", logPairs); + throw (new QNotFoundException("Cannot perform operation [" + operation + "] on table named " + tableApiName + " in this api.")); + } + + if(!table.isCapabilityEnabled(QContext.getQInstance().getBackendForTable(table.getName()), operation.getCapability())) + { + LOG.info("404 because table capability is not enabled", logPairs); + throw (new QNotFoundException("Cannot perform operation [" + operation + "] on table named " + tableApiName + " in this api.")); + } + + APIVersion requestApiVersion = new APIVersion(version); + List supportedVersions = apiInstanceMetaData.getSupportedVersions(); + if(CollectionUtils.nullSafeIsEmpty(supportedVersions) || !supportedVersions.contains(requestApiVersion)) + { + LOG.info("404 because requested version is not supported", logPairs); + throw (new QNotFoundException(version + " is not a supported version in this api.")); + } + + if(!apiTableMetaData.getApiVersionRange().includes(requestApiVersion)) + { + LOG.info("404 because table version range does not include requested version", logPairs); + throw (new QNotFoundException(version + " is not a supported version for table " + tableApiName + " in this api.")); + } + + return (table); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private static QTableMetaData getTableByApiName(String apiName, String version, String tableApiName) + { + ///////////////////////////////////////////////////////////////////////////////////////////// + // tableApiNameMap is a map of (apiName,apiVersion) => Map. // + // that is to say, a 2-level map. The first level is keyed by (apiName,apiVersion) pairs. // + // the second level is keyed by tableApiNames. // + ///////////////////////////////////////////////////////////////////////////////////////////// + Pair key = new Pair<>(apiName, version); + if(tableApiNameMap.get(key) == null) + { + Map map = new HashMap<>(); + + for(QTableMetaData table : QContext.getQInstance().getTables().values()) + { + ApiTableMetaDataContainer apiTableMetaDataContainer = ApiTableMetaDataContainer.of(table); + if(apiTableMetaDataContainer != null) + { + ApiTableMetaData apiTableMetaData = apiTableMetaDataContainer.getApiTableMetaData(apiName); + if(apiTableMetaData != null) + { + String name = table.getName(); + if(StringUtils.hasContent(apiTableMetaData.getApiTableName())) + { + name = apiTableMetaData.getApiTableName(); + } + map.put(name, table); + } + } + } + + tableApiNameMap.put(key, map); + } + + return (tableApiNameMap.get(key).get(tableApiName)); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private static boolean areAnyErrorsBadRequest(List errors) + { + boolean isBadRequest = errors.stream().anyMatch(e -> + e.contains("Missing value in required field") + || e.contains("You do not have permission") + ); + return isBadRequest; + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private static boolean areAnyErrorsNotFound(List errors) + { + return errors.stream().anyMatch(e -> e.startsWith(UpdateAction.NOT_FOUND_ERROR_PREFIX) || e.startsWith(DeleteAction.NOT_FOUND_ERROR_PREFIX)); + } + +} diff --git a/qqq-middleware-api/src/main/java/com/kingsrook/qqq/api/actions/QRecordApiAdapter.java b/qqq-middleware-api/src/main/java/com/kingsrook/qqq/api/actions/QRecordApiAdapter.java index b7de314f..412a0ad5 100644 --- a/qqq-middleware-api/src/main/java/com/kingsrook/qqq/api/actions/QRecordApiAdapter.java +++ b/qqq-middleware-api/src/main/java/com/kingsrook/qqq/api/actions/QRecordApiAdapter.java @@ -64,6 +64,11 @@ public class QRecordApiAdapter *******************************************************************************/ public static Map qRecordToApiMap(QRecord record, String tableName, String apiName, String apiVersion) throws QException { + if(record == null) + { + return (null); + } + List tableApiFields = getTableApiFieldList(new ApiNameVersionAndTableName(apiName, apiVersion, tableName)); LinkedHashMap outputRecord = new LinkedHashMap<>(); diff --git a/qqq-middleware-api/src/main/java/com/kingsrook/qqq/api/javalin/QJavalinApiHandler.java b/qqq-middleware-api/src/main/java/com/kingsrook/qqq/api/javalin/QJavalinApiHandler.java index 0cb92760..a4375bd4 100644 --- a/qqq-middleware-api/src/main/java/com/kingsrook/qqq/api/javalin/QJavalinApiHandler.java +++ b/qqq-middleware-api/src/main/java/com/kingsrook/qqq/api/javalin/QJavalinApiHandler.java @@ -27,35 +27,25 @@ import java.io.Serializable; import java.nio.charset.StandardCharsets; import java.time.Instant; import java.util.ArrayList; -import java.util.Arrays; import java.util.Base64; import java.util.HashMap; -import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Objects; -import java.util.Set; import java.util.stream.Collectors; +import com.kingsrook.qqq.api.actions.ApiImplementation; import com.kingsrook.qqq.api.actions.GenerateOpenApiSpecAction; -import com.kingsrook.qqq.api.actions.QRecordApiAdapter; import com.kingsrook.qqq.api.model.APILog; import com.kingsrook.qqq.api.model.APIVersion; import com.kingsrook.qqq.api.model.actions.GenerateOpenApiSpecInput; import com.kingsrook.qqq.api.model.actions.GenerateOpenApiSpecOutput; -import com.kingsrook.qqq.api.model.metadata.APILogMetaDataProvider; import com.kingsrook.qqq.api.model.metadata.ApiInstanceMetaData; import com.kingsrook.qqq.api.model.metadata.ApiInstanceMetaDataContainer; -import com.kingsrook.qqq.api.model.metadata.ApiOperation; +import com.kingsrook.qqq.api.model.metadata.ApiInstanceMetaDataProvider; import com.kingsrook.qqq.api.model.metadata.tables.ApiTableMetaData; import com.kingsrook.qqq.api.model.metadata.tables.ApiTableMetaDataContainer; -import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper; -import com.kingsrook.qqq.backend.core.actions.permissions.TablePermissionSubType; -import com.kingsrook.qqq.backend.core.actions.tables.CountAction; -import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction; import com.kingsrook.qqq.backend.core.actions.tables.GetAction; import com.kingsrook.qqq.backend.core.actions.tables.InsertAction; -import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; -import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction; import com.kingsrook.qqq.backend.core.context.QContext; import com.kingsrook.qqq.backend.core.exceptions.AccessTokenException; import com.kingsrook.qqq.backend.core.exceptions.QAuthenticationException; @@ -64,30 +54,17 @@ import com.kingsrook.qqq.backend.core.exceptions.QModuleDispatchException; import com.kingsrook.qqq.backend.core.exceptions.QNotFoundException; import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException; import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException; -import com.kingsrook.qqq.backend.core.logging.LogPair; import com.kingsrook.qqq.backend.core.logging.QLogger; import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput; -import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput; -import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountOutput; -import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput; -import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteOutput; import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput; import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetOutput; import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput; import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertOutput; -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.QFilterOrderBy; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; -import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; -import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput; -import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput; -import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateOutput; 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.authentication.Auth0AuthenticationMetaData; import com.kingsrook.qqq.backend.core.model.metadata.branding.QBrandingMetaData; -import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; import com.kingsrook.qqq.backend.core.model.session.QSession; import com.kingsrook.qqq.backend.core.model.session.QUser; @@ -96,10 +73,7 @@ import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModu 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.Pair; import com.kingsrook.qqq.backend.core.utils.StringUtils; -import com.kingsrook.qqq.backend.core.utils.ValueUtils; -import com.kingsrook.qqq.backend.core.utils.collections.ListBuilder; import com.kingsrook.qqq.backend.core.utils.collections.MapBuilder; import com.kingsrook.qqq.backend.javalin.QJavalinAccessLogger; import com.kingsrook.qqq.backend.javalin.QJavalinImplementation; @@ -110,9 +84,6 @@ import io.javalin.http.Context; import org.apache.commons.io.IOUtils; import org.apache.commons.lang.BooleanUtils; import org.eclipse.jetty.http.HttpStatus; -import org.json.JSONArray; -import org.json.JSONObject; -import org.json.JSONTokener; import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair; import static com.kingsrook.qqq.backend.javalin.QJavalinImplementation.SLOW_LOG_THRESHOLD_MS; @@ -126,11 +97,6 @@ public class QJavalinApiHandler private static QInstance qInstance; - ///////////////////////////////////// - // key: Pair // - ///////////////////////////////////// - private static Map, Map> tableApiNameMap = new HashMap<>(); - private static Map apiLogUserIdCache = new HashMap<>(); @@ -432,7 +398,6 @@ public class QJavalinApiHandler context.status(HttpStatus.Code.OK.getCode()); context.result(accessToken); QJavalinAccessLogger.logEndSuccess(); - return; } catch(AccessTokenException aae) { @@ -446,7 +411,6 @@ public class QJavalinApiHandler context.status(aae.getStatusCode()); context.result(aae.getMessage()); QJavalinAccessLogger.logEndSuccess(); - return; } //////////////////////////////////////////////////////// @@ -647,38 +611,10 @@ public class QJavalinApiHandler try { - QTableMetaData table = validateTableAndVersion(context, apiInstanceMetaData, version, tableApiName, ApiOperation.GET); - String tableName = table.getName(); + setupSession(context, null, version, apiInstanceMetaData); + QJavalinAccessLogger.logStart("apiGet", logPair("table", tableApiName), logPair("primaryKey", primaryKey)); - GetInput getInput = new GetInput(); - - setupSession(context, getInput, version, apiInstanceMetaData); - QJavalinAccessLogger.logStart("apiGet", logPair("table", tableName), logPair("primaryKey", primaryKey)); - - getInput.setTableName(tableName); - - PermissionsHelper.checkTablePermissionThrowing(getInput, TablePermissionSubType.READ); - - // 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) - - getInput.setPrimaryKey(primaryKey); - getInput.setIncludeAssociations(true); - - GetAction getAction = new GetAction(); - GetOutput getOutput = getAction.execute(getInput); - - /////////////////////////////////////////////////////// - // throw a not found error if the record isn't found // - /////////////////////////////////////////////////////// - QRecord record = getOutput.getRecord(); - if(record == null) - { - throw (new QNotFoundException("Could not find " + table.getLabel() + " with " - + table.getFields().get(table.getPrimaryKeyField()).getLabel() + " of " + primaryKey)); - } - - Map outputRecord = QRecordApiAdapter.qRecordToApiMap(record, tableName, apiInstanceMetaData.getName(), version); + Map outputRecord = ApiImplementation.get(apiInstanceMetaData, version, tableApiName, primaryKey); QJavalinAccessLogger.logEndSuccess(); String resultString = JsonUtils.toJson(outputRecord); @@ -769,7 +705,7 @@ public class QJavalinApiHandler *******************************************************************************/ private static Integer getApiLogUserId(QSession qSession) throws QException { - String tableName = APILogMetaDataProvider.TABLE_NAME_API_LOG_USER; + String tableName = ApiInstanceMetaDataProvider.TABLE_NAME_API_LOG_USER; if(qSession == null) { @@ -874,7 +810,7 @@ public class QJavalinApiHandler private static Integer fetchApiLogUserIdFromName(String name) throws QException { GetInput getInput = new GetInput(); - getInput.setTableName(APILogMetaDataProvider.TABLE_NAME_API_LOG_USER); + getInput.setTableName(ApiInstanceMetaDataProvider.TABLE_NAME_API_LOG_USER); getInput.setUniqueKey(Map.of("name", name)); GetOutput getOutput = new GetAction().execute(getInput); if(getOutput.getRecord() != null) @@ -892,228 +828,20 @@ public class QJavalinApiHandler *******************************************************************************/ private static void doQuery(Context context, ApiInstanceMetaData apiInstanceMetaData) { - String version = context.pathParam("version"); - String tableApiName = context.pathParam("tableName"); - QQueryFilter filter = null; - APILog apiLog = newAPILog(context); + String version = context.pathParam("version"); + String tableApiName = context.pathParam("tableName"); + + QQueryFilter filter = null; + APILog apiLog = newAPILog(context); try { - List badRequestMessages = new ArrayList<>(); + setupSession(context, null, version, apiInstanceMetaData); + QJavalinAccessLogger.logStart("apiQuery", logPair("table", tableApiName)); - QTableMetaData table = validateTableAndVersion(context, apiInstanceMetaData, version, tableApiName, ApiOperation.QUERY_BY_QUERY_STRING); - String tableName = table.getName(); + Map output = ApiImplementation.query(apiInstanceMetaData, version, tableApiName, context.queryParamMap()); - QueryInput queryInput = new QueryInput(); - setupSession(context, queryInput, version, apiInstanceMetaData); - QJavalinAccessLogger.logStart("apiQuery", logPair("table", tableName)); - - queryInput.setTableName(tableName); - queryInput.setIncludeAssociations(true); - - PermissionsHelper.checkTablePermissionThrowing(queryInput, TablePermissionSubType.READ); - - Integer pageSize = 50; - if(StringUtils.hasContent(context.queryParam("pageSize"))) - { - try - { - pageSize = ValueUtils.getValueAsInteger(context.queryParam("pageSize")); - } - catch(Exception e) - { - badRequestMessages.add("Could not parse pageSize as an integer"); - } - } - if(pageSize < 1 || pageSize > 1000) - { - badRequestMessages.add("pageSize must be between 1 and 1000."); - } - - Integer pageNo = 1; - if(StringUtils.hasContent(context.queryParam("pageNo"))) - { - try - { - pageNo = ValueUtils.getValueAsInteger(context.queryParam("pageNo")); - } - catch(Exception e) - { - badRequestMessages.add("Could not parse pageNo as an integer"); - } - } - if(pageNo < 1) - { - badRequestMessages.add("pageNo must be greater than 0."); - } - - queryInput.setLimit(pageSize); - queryInput.setSkip((pageNo - 1) * pageSize); - - // queryInput.setQueryJoins(processQueryJoinsParam(context)); - - filter = new QQueryFilter(); - if("and".equalsIgnoreCase(context.queryParam("booleanOperator"))) - { - filter.setBooleanOperator(QQueryFilter.BooleanOperator.AND); - } - else if("or".equalsIgnoreCase(context.queryParam("booleanOperator"))) - { - filter.setBooleanOperator(QQueryFilter.BooleanOperator.OR); - } - else if(StringUtils.hasContent(context.queryParam("booleanOperator"))) - { - badRequestMessages.add("booleanOperator must be either AND or OR."); - } - - boolean includeCount = true; - if("true".equalsIgnoreCase(context.queryParam("includeCount"))) - { - includeCount = true; - } - else if("false".equalsIgnoreCase(context.queryParam("includeCount"))) - { - includeCount = false; - } - else if(StringUtils.hasContent(context.queryParam("includeCount"))) - { - badRequestMessages.add("includeCount must be either true or false"); - } - - String orderBy = context.queryParam("orderBy"); - if(StringUtils.hasContent(orderBy)) - { - for(String orderByPart : orderBy.split(",")) - { - orderByPart = orderByPart.trim(); - String[] orderByNameDirection = orderByPart.split(" +"); - boolean asc = true; - if(orderByNameDirection.length == 2) - { - if("asc".equalsIgnoreCase(orderByNameDirection[1])) - { - asc = true; - } - else if("desc".equalsIgnoreCase(orderByNameDirection[1])) - { - asc = false; - } - else - { - badRequestMessages.add("orderBy direction for field " + orderByNameDirection[0] + " must be either ASC or DESC."); - } - } - else if(orderByNameDirection.length > 2) - { - badRequestMessages.add("Unrecognized format for orderBy clause: " + orderByPart + ". Expected: fieldName [ASC|DESC]."); - } - - try - { - QFieldMetaData field = table.getField(orderByNameDirection[0]); - filter.withOrderBy(new QFilterOrderBy(field.getName(), asc)); - } - catch(Exception e) - { - badRequestMessages.add("Unrecognized orderBy field name: " + orderByNameDirection[0] + "."); - } - } - } - else - { - filter.withOrderBy(new QFilterOrderBy(table.getPrimaryKeyField(), false)); - } - - Set nonFilterParams = Set.of("pageSize", "pageNo", "orderBy", "booleanOperator", "includeCount"); - - //////////////////////////// - // look for filter params // - //////////////////////////// - for(Map.Entry> entry : context.queryParamMap().entrySet()) - { - String name = entry.getKey(); - List values = entry.getValue(); - - if(nonFilterParams.contains(name)) - { - continue; - } - - try - { - QFieldMetaData field = table.getField(name); - for(String value : values) - { - if(StringUtils.hasContent(value)) - { - try - { - filter.addCriteria(parseQueryParamToCriteria(name, value)); - } - catch(Exception e) - { - badRequestMessages.add(e.getMessage()); - } - } - } - } - catch(Exception e) - { - badRequestMessages.add("Unrecognized filter criteria field: " + name); - } - } - - ////////////////////////////////////////// - // no more badRequest checks below here // - ////////////////////////////////////////// - if(!badRequestMessages.isEmpty()) - { - if(badRequestMessages.size() == 1) - { - throw (new QBadRequestException(badRequestMessages.get(0))); - } - else - { - throw (new QBadRequestException("Request failed with " + badRequestMessages.size() + " reasons: " + StringUtils.join(" \n", badRequestMessages))); - } - } - - ////////////////// - // do the query // - ////////////////// - QueryAction queryAction = new QueryAction(); - queryInput.setFilter(filter); - QueryOutput queryOutput = queryAction.execute(queryInput); - - Map output = new LinkedHashMap<>(); - output.put("pageNo", pageNo); - output.put("pageSize", pageSize); - - //////////////////////////////////////////////////////////////////////////////////////////////////////////////// - // map record fields for api // - // note - don't put them in the output until after the count, just because that looks a little nicer, i think // - //////////////////////////////////////////////////////////////////////////////////////////////////////////////// - ArrayList> records = new ArrayList<>(); - for(QRecord record : queryOutput.getRecords()) - { - records.add(QRecordApiAdapter.qRecordToApiMap(record, tableName, apiInstanceMetaData.getName(), version)); - } - - ///////////////////////////// - // optionally do the count // - ///////////////////////////// - if(includeCount) - { - CountInput countInput = new CountInput(); - countInput.setTableName(tableName); - countInput.setFilter(filter); - CountOutput countOutput = new CountAction().execute(countInput); - output.put("count", countOutput.getCount()); - } - - output.put("records", records); - - QJavalinAccessLogger.logEndSuccess(logPair("recordCount", queryOutput.getRecords().size()), QJavalinAccessLogger.logPairIfSlow("filter", filter, SLOW_LOG_THRESHOLD_MS)); + QJavalinAccessLogger.logEndSuccess(logPair("recordCount", () -> ((List) output.get("records")).size()), QJavalinAccessLogger.logPairIfSlow("filter", filter, SLOW_LOG_THRESHOLD_MS)); String resultString = JsonUtils.toJson(output); context.result(resultString); storeApiLog(apiLog.withStatusCode(context.statusCode()).withResponseBody(resultString)); @@ -1127,246 +855,6 @@ public class QJavalinApiHandler - /******************************************************************************* - ** - *******************************************************************************/ - private static QTableMetaData validateTableAndVersion(Context context, ApiInstanceMetaData apiInstanceMetaData, String version, String tableApiName, ApiOperation operation) throws QNotFoundException - { - QNotFoundException qNotFoundException = new QNotFoundException("Could not find any resources at path " + context.path()); - - QTableMetaData table = getTableByApiName(apiInstanceMetaData.getName(), version, tableApiName); - LogPair[] logPairs = new LogPair[] { logPair("apiName", apiInstanceMetaData.getName()), logPair("version", version), logPair("tableApiName", tableApiName), logPair("operation", operation) }; - - if(table == null) - { - LOG.info("404 because table is null", logPairs); - throw (qNotFoundException); - } - - if(BooleanUtils.isTrue(table.getIsHidden())) - { - LOG.info("404 because table isHidden", logPairs); - throw (qNotFoundException); - } - - ApiTableMetaDataContainer apiTableMetaDataContainer = ApiTableMetaDataContainer.of(table); - if(apiTableMetaDataContainer == null) - { - LOG.info("404 because table apiMetaDataContainer is null", logPairs); - throw (qNotFoundException); - } - - ApiTableMetaData apiTableMetaData = apiTableMetaDataContainer.getApiTableMetaData(apiInstanceMetaData.getName()); - if(apiTableMetaData == null) - { - LOG.info("404 because table apiMetaData is null", logPairs); - throw (qNotFoundException); - } - - if(BooleanUtils.isTrue(apiTableMetaData.getIsExcluded())) - { - LOG.info("404 because table is excluded", logPairs); - throw (qNotFoundException); - } - - if(!operation.isOperationEnabled(List.of(apiInstanceMetaData, apiTableMetaData))) - { - LOG.info("404 because api operation is not enabled", logPairs); - throw (qNotFoundException); - } - - if(!table.isCapabilityEnabled(qInstance.getBackendForTable(table.getName()), operation.getCapability())) - { - LOG.info("404 because table capability is not enabled", logPairs); - throw (qNotFoundException); - } - - APIVersion requestApiVersion = new APIVersion(version); - List supportedVersions = apiInstanceMetaData.getSupportedVersions(); - if(CollectionUtils.nullSafeIsEmpty(supportedVersions) || !supportedVersions.contains(requestApiVersion)) - { - LOG.info("404 because requested version is not supported", logPairs); - throw (qNotFoundException); - } - - if(!apiTableMetaData.getApiVersionRange().includes(requestApiVersion)) - { - LOG.info("404 because table version range does not include requested version", logPairs); - throw (qNotFoundException); - } - - return (table); - } - - - - /******************************************************************************* - ** - *******************************************************************************/ - private static QTableMetaData getTableByApiName(String apiName, String version, String tableApiName) - { - ///////////////////////////////////////////////////////////////////////////////////////////// - // tableApiNameMap is a map of (apiName,apiVersion) => Map. // - // that is to say, a 2-level map. The first level is keyed by (apiName,apiVersion) pairs. // - // the second level is keyed by tableApiNames. // - ///////////////////////////////////////////////////////////////////////////////////////////// - Pair key = new Pair<>(apiName, version); - if(tableApiNameMap.get(key) == null) - { - Map map = new HashMap<>(); - - for(QTableMetaData table : qInstance.getTables().values()) - { - ApiTableMetaDataContainer apiTableMetaDataContainer = ApiTableMetaDataContainer.of(table); - if(apiTableMetaDataContainer != null) - { - ApiTableMetaData apiTableMetaData = apiTableMetaDataContainer.getApiTableMetaData(apiName); - if(apiTableMetaData != null) - { - String name = table.getName(); - if(StringUtils.hasContent(apiTableMetaData.getApiTableName())) - { - name = apiTableMetaData.getApiTableName(); - } - map.put(name, table); - } - } - } - - tableApiNameMap.put(key, map); - } - - return (tableApiNameMap.get(key).get(tableApiName)); - } - - - - /******************************************************************************* - ** - *******************************************************************************/ - private enum Operator - { - /////////////////////////////////////////////////////////////////////////////////// - // order of these is important (e.g., because some are a sub-string of others!!) // - /////////////////////////////////////////////////////////////////////////////////// - EQ("=", QCriteriaOperator.EQUALS, QCriteriaOperator.NOT_EQUALS, 1), - LTE("<=", QCriteriaOperator.LESS_THAN_OR_EQUALS, null, 1), - GTE(">=", QCriteriaOperator.GREATER_THAN_OR_EQUALS, null, 1), - LT("<", QCriteriaOperator.LESS_THAN, null, 1), - GT(">", QCriteriaOperator.GREATER_THAN, null, 1), - EMPTY("EMPTY", QCriteriaOperator.IS_BLANK, QCriteriaOperator.IS_NOT_BLANK, 0), - BETWEEN("BETWEEN ", QCriteriaOperator.BETWEEN, QCriteriaOperator.NOT_BETWEEN, 2), - IN("IN ", QCriteriaOperator.IN, QCriteriaOperator.NOT_IN, null), - LIKE("LIKE ", QCriteriaOperator.LIKE, QCriteriaOperator.NOT_LIKE, 1); - - - private final String prefix; - private final QCriteriaOperator positiveOperator; - private final QCriteriaOperator negativeOperator; - private final Integer noOfValues; // null means many (IN) - - - - /******************************************************************************* - ** - *******************************************************************************/ - Operator(String prefix, QCriteriaOperator positiveOperator, QCriteriaOperator negativeOperator, Integer noOfValues) - { - this.prefix = prefix; - this.positiveOperator = positiveOperator; - this.negativeOperator = negativeOperator; - this.noOfValues = noOfValues; - } - } - - - - /******************************************************************************* - ** - *******************************************************************************/ - private static QFilterCriteria parseQueryParamToCriteria(String name, String value) throws QException - { - /////////////////////////////////// - // process & discard a leading ! // - /////////////////////////////////// - boolean isNot = false; - if(value.startsWith("!") && value.length() > 1) - { - isNot = true; - value = value.substring(1); - } - - ////////////////////////// - // look for an operator // - ////////////////////////// - Operator selectedOperator = null; - for(Operator op : Operator.values()) - { - if(value.startsWith(op.prefix)) - { - selectedOperator = op; - if(selectedOperator.negativeOperator == null && isNot) - { - throw (new QBadRequestException("Unsupported operator: !" + selectedOperator.prefix)); - } - break; - } - } - - ///////////////////////////////////////////////////////////////////////////////////////////// - // if an operator was found, strip it away from the value for figuring out the values part // - ///////////////////////////////////////////////////////////////////////////////////////////// - if(selectedOperator != null) - { - value = value.substring(selectedOperator.prefix.length()); - } - else - { - //////////////////////////////////////////////////////////////// - // else - assume the default operator, and use the full value // - //////////////////////////////////////////////////////////////// - selectedOperator = Operator.EQ; - } - - //////////////////////////////////// - // figure out the criteria values // - // todo - quotes? // - //////////////////////////////////// - List criteriaValues; - if(selectedOperator.noOfValues == null) - { - criteriaValues = Arrays.asList(value.split(",")); - } - else if(selectedOperator.noOfValues == 1) - { - criteriaValues = ListBuilder.of(value); - } - else if(selectedOperator.noOfValues == 0) - { - if(StringUtils.hasContent(value)) - { - throw (new QBadRequestException("Unexpected value after operator " + selectedOperator.prefix + " for field " + name)); - } - criteriaValues = null; - } - else if(selectedOperator.noOfValues == 2) - { - criteriaValues = Arrays.asList(value.split(",")); - if(criteriaValues.size() != 2) - { - throw (new QBadRequestException("Operator " + selectedOperator.prefix + " for field " + name + " requires 2 values (received " + criteriaValues.size() + ")")); - } - } - else - { - throw (new QException("Unexpected noOfValues [" + selectedOperator.noOfValues + "] in operator [" + selectedOperator + "]")); - } - - return (new QFilterCriteria(name, isNot ? selectedOperator.negativeOperator : selectedOperator.positiveOperator, criteriaValues)); - } - - - /******************************************************************************* ** *******************************************************************************/ @@ -1378,65 +866,10 @@ public class QJavalinApiHandler try { - QTableMetaData table = validateTableAndVersion(context, apiInstanceMetaData, version, tableApiName, ApiOperation.INSERT); - String tableName = table.getName(); + setupSession(context, null, version, apiInstanceMetaData); + QJavalinAccessLogger.logStart("apiInsert", logPair("table", tableApiName)); - InsertInput insertInput = new InsertInput(); - - setupSession(context, insertInput, version, apiInstanceMetaData); - QJavalinAccessLogger.logStart("apiInsert", logPair("table", tableName)); - - insertInput.setTableName(tableName); - - PermissionsHelper.checkTablePermissionThrowing(insertInput, TablePermissionSubType.INSERT); - - try - { - if(!StringUtils.hasContent(context.body())) - { - throw (new QBadRequestException("Missing required POST body")); - } - - JSONTokener jsonTokener = new JSONTokener(context.body().trim()); - JSONObject jsonObject = new JSONObject(jsonTokener); - - insertInput.setRecords(List.of(QRecordApiAdapter.apiJsonObjectToQRecord(jsonObject, tableName, apiInstanceMetaData.getName(), version, false))); - - if(jsonTokener.more()) - { - throw (new QBadRequestException("Body contained more than a single JSON object.")); - } - } - catch(QBadRequestException qbre) - { - throw (qbre); - } - catch(Exception e) - { - throw (new QBadRequestException("Body could not be parsed as a JSON object: " + e.getMessage(), e)); - } - - InsertAction insertAction = new InsertAction(); - InsertOutput insertOutput = insertAction.execute(insertInput); - - List errors = insertOutput.getRecords().get(0).getErrors(); - if(CollectionUtils.nullSafeHasContents(errors)) - { - boolean isBadRequest = areAnyErrorsBadRequest(errors); - - String message = "Error inserting " + table.getLabel() + ": " + StringUtils.joinWithCommasAndAnd(errors); - if(isBadRequest) - { - throw (new QBadRequestException(message)); - } - else - { - throw (new QException(message)); - } - } - - LinkedHashMap outputRecord = new LinkedHashMap<>(); - outputRecord.put(table.getPrimaryKeyField(), insertOutput.getRecords().get(0).getValue(table.getPrimaryKeyField())); + Map outputRecord = ApiImplementation.insert(apiInstanceMetaData, version, tableApiName, context.body()); QJavalinAccessLogger.logEndSuccess(); context.status(HttpStatus.Code.CREATED.getCode()); @@ -1453,20 +886,6 @@ public class QJavalinApiHandler - /******************************************************************************* - ** - *******************************************************************************/ - private static boolean areAnyErrorsBadRequest(List errors) - { - boolean isBadRequest = errors.stream().anyMatch(e -> - e.contains("Missing value in required field") - || e.contains("You do not have permission") - ); - return isBadRequest; - } - - - /******************************************************************************* ** *******************************************************************************/ @@ -1478,90 +897,12 @@ public class QJavalinApiHandler try { - QTableMetaData table = validateTableAndVersion(context, apiInstanceMetaData, version, tableApiName, ApiOperation.BULK_INSERT); - String tableName = table.getName(); + setupSession(context, null, version, apiInstanceMetaData); + QJavalinAccessLogger.logStart("apiBulkInsert", logPair("table", tableApiName)); - InsertInput insertInput = new InsertInput(); + List> response = ApiImplementation.bulkInsert(apiInstanceMetaData, version, tableApiName, context.body()); - setupSession(context, insertInput, version, apiInstanceMetaData); - QJavalinAccessLogger.logStart("apiBulkInsert", logPair("table", tableName)); - - insertInput.setTableName(tableName); - - PermissionsHelper.checkTablePermissionThrowing(insertInput, TablePermissionSubType.INSERT); - - ///////////////// - // build input // - ///////////////// - try - { - if(!StringUtils.hasContent(context.body())) - { - throw (new QBadRequestException("Missing required POST body")); - } - - ArrayList recordList = new ArrayList<>(); - insertInput.setRecords(recordList); - - JSONTokener jsonTokener = new JSONTokener(context.body().trim()); - JSONArray jsonArray = new JSONArray(jsonTokener); - - for(int i = 0; i < jsonArray.length(); i++) - { - JSONObject jsonObject = jsonArray.getJSONObject(i); - recordList.add(QRecordApiAdapter.apiJsonObjectToQRecord(jsonObject, tableName, apiInstanceMetaData.getName(), version, false)); - } - - if(jsonTokener.more()) - { - throw (new QBadRequestException("Body contained more than a single JSON array.")); - } - - if(recordList.isEmpty()) - { - throw (new QBadRequestException("No records were found in the POST body")); - } - } - catch(QBadRequestException qbre) - { - throw (qbre); - } - catch(Exception e) - { - throw (new QBadRequestException("Body could not be parsed as a JSON array: " + e.getMessage(), e)); - } - - ////////////// - // execute! // - ////////////// - InsertAction insertAction = new InsertAction(); - InsertOutput insertOutput = insertAction.execute(insertInput); - - /////////////////////////////////////// - // process records to build response // - /////////////////////////////////////// - List> response = new ArrayList<>(); - for(QRecord record : insertOutput.getRecords()) - { - LinkedHashMap outputRecord = new LinkedHashMap<>(); - response.add(outputRecord); - - List errors = record.getErrors(); - if(CollectionUtils.nullSafeHasContents(errors)) - { - outputRecord.put("statusCode", HttpStatus.Code.BAD_REQUEST.getCode()); - outputRecord.put("statusText", HttpStatus.Code.BAD_REQUEST.getMessage()); - outputRecord.put("error", "Error inserting " + table.getLabel() + ": " + StringUtils.joinWithCommasAndAnd(errors)); - } - else - { - outputRecord.put("statusCode", HttpStatus.Code.CREATED.getCode()); - outputRecord.put("statusText", HttpStatus.Code.CREATED.getMessage()); - outputRecord.put(table.getPrimaryKeyField(), record.getValue(table.getPrimaryKeyField())); - } - } - - QJavalinAccessLogger.logEndSuccess(logPair("recordCount", insertInput.getRecords().size())); + QJavalinAccessLogger.logEndSuccess(logPair("recordCount", response.size())); context.status(HttpStatus.Code.MULTI_STATUS.getCode()); String resultString = JsonUtils.toJson(response); context.result(resultString); @@ -1587,113 +928,12 @@ public class QJavalinApiHandler try { - QTableMetaData table = validateTableAndVersion(context, apiInstanceMetaData, version, tableApiName, ApiOperation.BULK_UPDATE); - String tableName = table.getName(); + setupSession(context, null, version, apiInstanceMetaData); + QJavalinAccessLogger.logStart("apiBulkUpdate", logPair("table", tableApiName)); - UpdateInput updateInput = new UpdateInput(); + List> response = ApiImplementation.bulkUpdate(apiInstanceMetaData, version, tableApiName, context.body()); - setupSession(context, updateInput, version, apiInstanceMetaData); - QJavalinAccessLogger.logStart("apiBulkUpdate", logPair("table", tableName)); - - updateInput.setTableName(tableName); - - PermissionsHelper.checkTablePermissionThrowing(updateInput, TablePermissionSubType.EDIT); - - ///////////////// - // build input // - ///////////////// - try - { - if(!StringUtils.hasContent(context.body())) - { - throw (new QBadRequestException("Missing required PATCH body")); - } - - ArrayList recordList = new ArrayList<>(); - updateInput.setRecords(recordList); - - JSONTokener jsonTokener = new JSONTokener(context.body().trim()); - JSONArray jsonArray = new JSONArray(jsonTokener); - - for(int i = 0; i < jsonArray.length(); i++) - { - JSONObject jsonObject = jsonArray.getJSONObject(i); - recordList.add(QRecordApiAdapter.apiJsonObjectToQRecord(jsonObject, tableName, apiInstanceMetaData.getName(), version, true)); - } - - if(jsonTokener.more()) - { - throw (new QBadRequestException("Body contained more than a single JSON array.")); - } - - if(recordList.isEmpty()) - { - throw (new QBadRequestException("No records were found in the PATCH body")); - } - } - catch(QBadRequestException qbre) - { - throw (qbre); - } - catch(Exception e) - { - throw (new QBadRequestException("Body could not be parsed as a JSON array: " + e.getMessage(), e)); - } - - ////////////// - // execute! // - ////////////// - UpdateAction updateAction = new UpdateAction(); - UpdateOutput updateOutput = updateAction.execute(updateInput); - - /////////////////////////////////////// - // process records to build response // - /////////////////////////////////////// - List> response = new ArrayList<>(); - int i = 0; - for(QRecord record : updateOutput.getRecords()) - { - LinkedHashMap outputRecord = new LinkedHashMap<>(); - response.add(outputRecord); - - try - { - QRecord inputRecord = updateInput.getRecords().get(i); - Serializable primaryKey = inputRecord.getValue(table.getPrimaryKeyField()); - outputRecord.put(table.getPrimaryKeyField(), primaryKey); - } - catch(Exception e) - { - ////////// - // omit // - ////////// - } - - List errors = record.getErrors(); - if(CollectionUtils.nullSafeHasContents(errors)) - { - outputRecord.put("error", "Error updating " + table.getLabel() + ": " + StringUtils.joinWithCommasAndAnd(errors)); - if(areAnyErrorsNotFound(errors)) - { - outputRecord.put("statusCode", HttpStatus.Code.NOT_FOUND.getCode()); - outputRecord.put("statusText", HttpStatus.Code.NOT_FOUND.getMessage()); - } - else - { - outputRecord.put("statusCode", HttpStatus.Code.BAD_REQUEST.getCode()); - outputRecord.put("statusText", HttpStatus.Code.BAD_REQUEST.getMessage()); - } - } - else - { - outputRecord.put("statusCode", HttpStatus.Code.NO_CONTENT.getCode()); - outputRecord.put("statusText", HttpStatus.Code.NO_CONTENT.getMessage()); - } - - i++; - } - - QJavalinAccessLogger.logEndSuccess(logPair("recordCount", updateInput.getRecords().size())); + QJavalinAccessLogger.logEndSuccess(logPair("recordCount", response.size())); context.status(HttpStatus.Code.MULTI_STATUS.getCode()); String resultString = JsonUtils.toJson(response); context.result(resultString); @@ -1708,16 +948,6 @@ public class QJavalinApiHandler - /******************************************************************************* - ** - *******************************************************************************/ - private static boolean areAnyErrorsNotFound(List errors) - { - return errors.stream().anyMatch(e -> e.startsWith(UpdateAction.NOT_FOUND_ERROR_PREFIX) || e.startsWith(DeleteAction.NOT_FOUND_ERROR_PREFIX)); - } - - - /******************************************************************************* ** *******************************************************************************/ @@ -1729,112 +959,12 @@ public class QJavalinApiHandler try { - QTableMetaData table = validateTableAndVersion(context, apiInstanceMetaData, version, tableApiName, ApiOperation.BULK_DELETE); - String tableName = table.getName(); + setupSession(context, null, version, apiInstanceMetaData); + QJavalinAccessLogger.logStart("apiBulkDelete", logPair("table", tableApiName)); - DeleteInput deleteInput = new DeleteInput(); + List> response = ApiImplementation.bulkDelete(apiInstanceMetaData, version, tableApiName, context.body()); - setupSession(context, deleteInput, version, apiInstanceMetaData); - QJavalinAccessLogger.logStart("apiBulkDelete", logPair("table", tableName)); - - deleteInput.setTableName(tableName); - - PermissionsHelper.checkTablePermissionThrowing(deleteInput, TablePermissionSubType.DELETE); - - ///////////////// - // build input // - ///////////////// - try - { - if(!StringUtils.hasContent(context.body())) - { - throw (new QBadRequestException("Missing required DELETE body")); - } - - ArrayList primaryKeyList = new ArrayList<>(); - deleteInput.setPrimaryKeys(primaryKeyList); - - JSONTokener jsonTokener = new JSONTokener(context.body().trim()); - JSONArray jsonArray = new JSONArray(jsonTokener); - - for(int i = 0; i < jsonArray.length(); i++) - { - Object object = jsonArray.get(i); - if(object instanceof JSONArray || object instanceof JSONObject) - { - throw (new QBadRequestException("One or more elements inside the DELETE body JSONArray was not a primitive value")); - } - primaryKeyList.add(String.valueOf(object)); - } - - if(jsonTokener.more()) - { - throw (new QBadRequestException("Body contained more than a single JSON array.")); - } - - if(primaryKeyList.isEmpty()) - { - throw (new QBadRequestException("No primary keys were found in the DELETE body")); - } - } - catch(QBadRequestException qbre) - { - throw (qbre); - } - catch(Exception e) - { - throw (new QBadRequestException("Body could not be parsed as a JSON array: " + e.getMessage(), e)); - } - - ////////////// - // execute! // - ////////////// - DeleteAction deleteAction = new DeleteAction(); - DeleteOutput deleteOutput = deleteAction.execute(deleteInput); - - /////////////////////////////////////// - // process records to build response // - /////////////////////////////////////// - List> response = new ArrayList<>(); - - List recordsWithErrors = deleteOutput.getRecordsWithErrors(); - Map> primaryKeyToErrorsMap = new HashMap<>(); - for(QRecord recordWithError : CollectionUtils.nonNullList(recordsWithErrors)) - { - String primaryKey = recordWithError.getValueString(table.getPrimaryKeyField()); - primaryKeyToErrorsMap.put(primaryKey, recordWithError.getErrors()); - } - - for(Serializable primaryKey : deleteInput.getPrimaryKeys()) - { - LinkedHashMap outputRecord = new LinkedHashMap<>(); - response.add(outputRecord); - outputRecord.put(table.getPrimaryKeyField(), primaryKey); - - String primaryKeyString = ValueUtils.getValueAsString(primaryKey); - List errors = primaryKeyToErrorsMap.get(primaryKeyString); - if(CollectionUtils.nullSafeHasContents(errors)) - { - outputRecord.put("error", "Error deleting " + table.getLabel() + ": " + StringUtils.joinWithCommasAndAnd(errors)); - if(areAnyErrorsNotFound(errors)) - { - outputRecord.put("statusCode", HttpStatus.Code.NOT_FOUND.getCode()); - outputRecord.put("statusText", HttpStatus.Code.NOT_FOUND.getMessage()); - } - else - { - outputRecord.put("statusCode", HttpStatus.Code.BAD_REQUEST.getCode()); - outputRecord.put("statusText", HttpStatus.Code.BAD_REQUEST.getMessage()); - } - } - else - { - outputRecord.put("statusCode", HttpStatus.Code.NO_CONTENT.getCode()); - outputRecord.put("statusText", HttpStatus.Code.NO_CONTENT.getMessage()); - } - } - - QJavalinAccessLogger.logEndSuccess(logPair("recordCount", deleteInput.getPrimaryKeys().size())); + QJavalinAccessLogger.logEndSuccess(logPair("recordCount", response.size())); context.status(HttpStatus.Code.MULTI_STATUS.getCode()); String resultString = JsonUtils.toJson(response); context.result(resultString); @@ -1861,71 +991,10 @@ public class QJavalinApiHandler try { - QTableMetaData table = validateTableAndVersion(context, apiInstanceMetaData, version, tableApiName, ApiOperation.UPDATE); - String tableName = table.getName(); + setupSession(context, null, version, apiInstanceMetaData); + QJavalinAccessLogger.logStart("apiUpdate", logPair("table", tableApiName)); - UpdateInput updateInput = new UpdateInput(); - - setupSession(context, updateInput, version, apiInstanceMetaData); - QJavalinAccessLogger.logStart("apiUpdate", logPair("table", tableName)); - - updateInput.setTableName(tableName); - - PermissionsHelper.checkTablePermissionThrowing(updateInput, TablePermissionSubType.EDIT); - - try - { - if(!StringUtils.hasContent(context.body())) - { - throw (new QBadRequestException("Missing required PATCH body")); - } - - JSONTokener jsonTokener = new JSONTokener(context.body().trim()); - JSONObject jsonObject = new JSONObject(jsonTokener); - - QRecord qRecord = QRecordApiAdapter.apiJsonObjectToQRecord(jsonObject, tableName, apiInstanceMetaData.getName(), version, false); - qRecord.setValue(table.getPrimaryKeyField(), primaryKey); - updateInput.setRecords(List.of(qRecord)); - - if(jsonTokener.more()) - { - throw (new QBadRequestException("Body contained more than a single JSON object.")); - } - } - catch(QBadRequestException qbre) - { - throw (qbre); - } - catch(Exception e) - { - throw (new QBadRequestException("Body could not be parsed as a JSON object: " + e.getMessage(), e)); - } - - UpdateAction updateAction = new UpdateAction(); - UpdateOutput updateOutput = updateAction.execute(updateInput); - - List errors = updateOutput.getRecords().get(0).getErrors(); - if(CollectionUtils.nullSafeHasContents(errors)) - { - if(areAnyErrorsNotFound(errors)) - { - throw (new QNotFoundException("Could not find " + table.getLabel() + " with " + table.getFields().get(table.getPrimaryKeyField()).getLabel() + " of " + primaryKey)); - } - else - { - boolean isBadRequest = areAnyErrorsBadRequest(errors); - - String message = "Error updating " + table.getLabel() + ": " + StringUtils.joinWithCommasAndAnd(errors); - if(isBadRequest) - { - throw (new QBadRequestException(message)); - } - else - { - throw (new QException(message)); - } - } - } + ApiImplementation.update(apiInstanceMetaData, version, tableApiName, primaryKey, context.body()); QJavalinAccessLogger.logEndSuccess(); context.status(HttpStatus.Code.NO_CONTENT.getCode()); @@ -1952,35 +1021,10 @@ public class QJavalinApiHandler try { - QTableMetaData table = validateTableAndVersion(context, apiInstanceMetaData, version, tableApiName, ApiOperation.DELETE); - String tableName = table.getName(); + setupSession(context, null, version, apiInstanceMetaData); + QJavalinAccessLogger.logStart("apiDelete", logPair("table", tableApiName)); - DeleteInput deleteInput = new DeleteInput(); - - setupSession(context, deleteInput, version, apiInstanceMetaData); - QJavalinAccessLogger.logStart("apiDelete", logPair("table", tableName)); - - deleteInput.setTableName(tableName); - deleteInput.setPrimaryKeys(List.of(primaryKey)); - - PermissionsHelper.checkTablePermissionThrowing(deleteInput, TablePermissionSubType.DELETE); - - /////////////////// - // do the delete // - /////////////////// - DeleteAction deleteAction = new DeleteAction(); - DeleteOutput deleteOutput = deleteAction.execute(deleteInput); - if(CollectionUtils.nullSafeHasContents(deleteOutput.getRecordsWithErrors())) - { - if(areAnyErrorsNotFound(deleteOutput.getRecordsWithErrors().get(0).getErrors())) - { - throw (new QNotFoundException("Could not find " + table.getLabel() + " with " + table.getFields().get(table.getPrimaryKeyField()).getLabel() + " of " + primaryKey)); - } - else - { - throw (new QException("Error deleting " + table.getLabel() + ": " + StringUtils.joinWithCommasAndAnd(deleteOutput.getRecordsWithErrors().get(0).getErrors()))); - } - } + ApiImplementation.delete(apiInstanceMetaData, version, tableApiName, primaryKey); QJavalinAccessLogger.logEndSuccess(); context.status(HttpStatus.Code.NO_CONTENT.getCode()); @@ -2018,6 +1062,7 @@ public class QJavalinApiHandler /******************************************************************************* ** *******************************************************************************/ + @SuppressWarnings("UnnecessaryReturnStatement") public static void handleException(HttpStatus.Code statusCode, Context context, Exception e, APILog apiLog) { QBadRequestException badRequestException = ExceptionUtils.findClassInRootChain(e, QBadRequestException.class); diff --git a/qqq-middleware-api/src/main/java/com/kingsrook/qqq/api/model/APILog.java b/qqq-middleware-api/src/main/java/com/kingsrook/qqq/api/model/APILog.java index 3086e0d7..8f6cbcc3 100644 --- a/qqq-middleware-api/src/main/java/com/kingsrook/qqq/api/model/APILog.java +++ b/qqq-middleware-api/src/main/java/com/kingsrook/qqq/api/model/APILog.java @@ -26,7 +26,7 @@ import java.io.Serializable; import java.time.Instant; import java.util.HashMap; import java.util.Map; -import com.kingsrook.qqq.api.model.metadata.APILogMetaDataProvider; +import com.kingsrook.qqq.api.model.metadata.ApiInstanceMetaDataProvider; import com.kingsrook.qqq.backend.core.exceptions.QException; import com.kingsrook.qqq.backend.core.exceptions.QRuntimeException; import com.kingsrook.qqq.backend.core.model.data.QField; @@ -49,7 +49,7 @@ public class APILog extends QRecordEntity @QField(isEditable = false) private Instant timestamp; - @QField(possibleValueSourceName = APILogMetaDataProvider.TABLE_NAME_API_LOG_USER, label = "User") + @QField(possibleValueSourceName = ApiInstanceMetaDataProvider.TABLE_NAME_API_LOG_USER, label = "User") private Integer apiLogUserId; @QField(possibleValueSourceName = "apiMethod") diff --git a/qqq-middleware-api/src/main/java/com/kingsrook/qqq/api/model/metadata/APILogMetaDataProvider.java b/qqq-middleware-api/src/main/java/com/kingsrook/qqq/api/model/metadata/ApiInstanceMetaDataProvider.java similarity index 94% rename from qqq-middleware-api/src/main/java/com/kingsrook/qqq/api/model/metadata/APILogMetaDataProvider.java rename to qqq-middleware-api/src/main/java/com/kingsrook/qqq/api/model/metadata/ApiInstanceMetaDataProvider.java index cf0d4fd7..e8ff2d9d 100644 --- a/qqq-middleware-api/src/main/java/com/kingsrook/qqq/api/model/metadata/APILogMetaDataProvider.java +++ b/qqq-middleware-api/src/main/java/com/kingsrook/qqq/api/model/metadata/ApiInstanceMetaDataProvider.java @@ -50,7 +50,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.tables.UniqueKey; /******************************************************************************* ** *******************************************************************************/ -public class APILogMetaDataProvider +public class ApiInstanceMetaDataProvider { public static final String TABLE_NAME_API_LOG = "apiLog"; public static final String TABLE_NAME_API_LOG_USER = "apiLogUser"; @@ -105,21 +105,32 @@ public class APILogMetaDataProvider new QPossibleValue<>(500, "500 (Internal Server Error)") ))); + //////////////////////////////////////////////////////////////////////////// + // loop over api names and versions, building out possible values sources // + //////////////////////////////////////////////////////////////////////////// + List> apiNamePossibleValues = new ArrayList<>(); List> apiVersionPossibleValues = new ArrayList<>(); - //////////////////////////////////////////////////////////////////////////////////////////////////// - // todo... this, this whole thing, should probably have "which api" as another field too... ugh. // - //////////////////////////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////////////////// + // todo... apiName should maybe be a field on apiLog table, eh? // + ////////////////////////////////////////////////////////////////// TreeSet allVersions = new TreeSet<>(); ApiInstanceMetaDataContainer apiInstanceMetaDataContainer = ApiInstanceMetaDataContainer.of(instance); for(Map.Entry entry : apiInstanceMetaDataContainer.getApis().entrySet()) { + apiNamePossibleValues.add(new QPossibleValue<>(entry.getKey(), entry.getValue().getLabel())); + ApiInstanceMetaData apiInstanceMetaData = entry.getValue(); allVersions.addAll(apiInstanceMetaData.getPastVersions()); allVersions.addAll(apiInstanceMetaData.getSupportedVersions()); allVersions.addAll(apiInstanceMetaData.getFutureVersions()); } + instance.addPossibleValueSource(new QPossibleValueSource() + .withName("apiName") + .withType(QPossibleValueSourceType.ENUM) + .withEnumValues(apiNamePossibleValues)); + for(APIVersion version : allVersions) { apiVersionPossibleValues.add(new QPossibleValue<>(version.toString())); diff --git a/qqq-middleware-api/src/main/java/com/kingsrook/qqq/api/utils/ApiScriptUtils.java b/qqq-middleware-api/src/main/java/com/kingsrook/qqq/api/utils/ApiScriptUtils.java new file mode 100644 index 00000000..f06585d2 --- /dev/null +++ b/qqq-middleware-api/src/main/java/com/kingsrook/qqq/api/utils/ApiScriptUtils.java @@ -0,0 +1,260 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2023. 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.api.utils; + + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import com.kingsrook.qqq.api.actions.ApiImplementation; +import com.kingsrook.qqq.api.actions.QRecordApiAdapter; +import com.kingsrook.qqq.api.model.APIVersion; +import com.kingsrook.qqq.api.model.metadata.ApiInstanceMetaData; +import com.kingsrook.qqq.api.model.metadata.ApiInstanceMetaDataContainer; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.data.QRecord; + + +/******************************************************************************* + ** Object injected into script context, for interfacing with a QQQ API. + *******************************************************************************/ +public class ApiScriptUtils implements Serializable +{ + private String apiName; + private String apiVersion; + + + + /******************************************************************************* + ** Constructor + ** + *******************************************************************************/ + public ApiScriptUtils(String apiName, String apiVersion) + { + setApiName(apiName); + setApiVersion(apiVersion); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + public static ArrayList> qRecordListToApiRecordList(List qRecordList, String tableName, String apiName, String apiVersion) throws QException + { + if(qRecordList == null) + { + return (null); + } + + ArrayList> rs = new ArrayList<>(); + for(QRecord qRecord : qRecordList) + { + rs.add(QRecordApiAdapter.qRecordToApiMap(qRecord, tableName, apiName, apiVersion)); + } + return (rs); + } + + + + /******************************************************************************* + ** Setter for apiName + ** + *******************************************************************************/ + public void setApiName(String apiName) + { + ApiInstanceMetaDataContainer apiInstanceMetaDataContainer = ApiInstanceMetaDataContainer.of(QContext.getQInstance()); + if(apiInstanceMetaDataContainer.getApis().containsKey(apiName)) + { + this.apiName = apiName; + } + else + { + throw (new IllegalArgumentException("[" + apiName + "] is not a valid API name. Valid values are: " + apiInstanceMetaDataContainer.getApis().keySet())); + } + } + + + + /******************************************************************************* + ** Setter for apiVersion + ** + *******************************************************************************/ + public void setApiVersion(String apiVersion) + { + if(apiName == null) + { + throw (new IllegalArgumentException("You must set apiName before setting apiVersion.")); + } + + ApiInstanceMetaDataContainer apiInstanceMetaDataContainer = ApiInstanceMetaDataContainer.of(QContext.getQInstance()); + ApiInstanceMetaData apiInstanceMetaData = apiInstanceMetaDataContainer.getApis().get(apiName); + if(apiInstanceMetaData.getSupportedVersions().contains(new APIVersion(apiVersion))) + { + this.apiVersion = apiVersion; + } + else + { + throw (new IllegalArgumentException("[" + apiVersion + "] is not a supported version for this API. Supported versions are: " + apiInstanceMetaData.getSupportedVersions())); + } + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private void validateApiNameAndVersion(String description) + { + if(apiName == null || apiVersion == null) + { + throw (new IllegalStateException("Both apiName and apiVersion must be set before calling this method (" + description + ").")); + } + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + public Map get(String tableApiName, Object primaryKey) throws QException + { + validateApiNameAndVersion("get(" + tableApiName + "," + primaryKey + ")"); + return (ApiImplementation.get(getApiInstanceMetaData(), apiVersion, tableApiName, String.valueOf(primaryKey))); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + public Map query(String tableName, String queryString) throws QException + { + validateApiNameAndVersion("query(" + tableName + ")"); + Map> paramMap = parseQueryString(queryString); + return (ApiImplementation.query(getApiInstanceMetaData(), apiVersion, tableName, paramMap)); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + public Map insert(String tableApiName, Object body) throws QException + { + validateApiNameAndVersion("insert(" + tableApiName + ")"); + return (ApiImplementation.insert(getApiInstanceMetaData(), apiVersion, tableApiName, String.valueOf(body))); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + public List> bulkInsert(String tableApiName, Object body) throws QException + { + validateApiNameAndVersion("bulkInsert(" + tableApiName + ")"); + return (ApiImplementation.bulkInsert(getApiInstanceMetaData(), apiVersion, tableApiName, String.valueOf(body))); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + public void update(String tableApiName, Object primaryKey, Object body) throws QException + { + validateApiNameAndVersion("update(" + tableApiName + "," + primaryKey + ")"); + ApiImplementation.update(getApiInstanceMetaData(), apiVersion, tableApiName, String.valueOf(primaryKey), String.valueOf(body)); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + public List> bulkUpdate(String tableApiName, Object body) throws QException + { + validateApiNameAndVersion("bulkUpdate(" + tableApiName + ")"); + return (ApiImplementation.bulkUpdate(getApiInstanceMetaData(), apiVersion, tableApiName, String.valueOf(body))); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + public void delete(String tableApiName, Object primaryKey) throws QException + { + validateApiNameAndVersion("delete(" + tableApiName + "," + primaryKey + ")"); + ApiImplementation.delete(getApiInstanceMetaData(), apiVersion, tableApiName, String.valueOf(primaryKey)); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + public List> bulkDelete(String tableApiName, Object body) throws QException + { + validateApiNameAndVersion("bulkDelete(" + tableApiName + ")"); + return (ApiImplementation.bulkDelete(getApiInstanceMetaData(), apiVersion, tableApiName, String.valueOf(body))); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private ApiInstanceMetaData getApiInstanceMetaData() + { + ApiInstanceMetaDataContainer apiInstanceMetaDataContainer = ApiInstanceMetaDataContainer.of(QContext.getQInstance()); + ApiInstanceMetaData apiInstanceMetaData = apiInstanceMetaDataContainer.getApiInstanceMetaData(apiName); + return apiInstanceMetaData; + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private static Map> parseQueryString(String queryString) + { + Map> paramMap = new LinkedHashMap<>(); + if(queryString != null) + { + for(String nameValuePair : queryString.split("&")) + { + String[] nameValue = nameValuePair.split("=", 2); + if(nameValue.length == 2) + { + paramMap.computeIfAbsent(nameValue[0], (k) -> new ArrayList<>()); + paramMap.get(nameValue[0]).add(nameValue[1]); + } + } + } + return paramMap; + } +} diff --git a/qqq-middleware-api/src/test/java/com/kingsrook/qqq/api/TestUtils.java b/qqq-middleware-api/src/test/java/com/kingsrook/qqq/api/TestUtils.java index f775fa94..4ddeb37f 100644 --- a/qqq-middleware-api/src/test/java/com/kingsrook/qqq/api/TestUtils.java +++ b/qqq-middleware-api/src/test/java/com/kingsrook/qqq/api/TestUtils.java @@ -22,6 +22,7 @@ package com.kingsrook.qqq.api; +import java.time.LocalDate; import java.util.List; import com.kingsrook.qqq.api.model.APIVersion; import com.kingsrook.qqq.api.model.metadata.ApiInstanceMetaData; @@ -30,7 +31,11 @@ import com.kingsrook.qqq.api.model.metadata.fields.ApiFieldMetaData; import com.kingsrook.qqq.api.model.metadata.fields.ApiFieldMetaDataContainer; import com.kingsrook.qqq.api.model.metadata.tables.ApiTableMetaData; import com.kingsrook.qqq.api.model.metadata.tables.ApiTableMetaDataContainer; +import com.kingsrook.qqq.backend.core.actions.tables.InsertAction; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy; +import com.kingsrook.qqq.backend.core.model.data.QRecord; import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType; import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData; import com.kingsrook.qqq.backend.core.model.metadata.QInstance; @@ -324,4 +329,40 @@ public class TestUtils .withOrderBy(new QFilterOrderBy("key")); } + + + /******************************************************************************* + ** + *******************************************************************************/ + public static void insertPersonRecord(Integer id, String firstName, String lastName) throws QException + { + insertPersonRecord(id, firstName, lastName, null); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + public static void insertPersonRecord(Integer id, String firstName, String lastName, LocalDate birthDate) throws QException + { + InsertInput insertInput = new InsertInput(); + insertInput.setTableName(TestUtils.TABLE_NAME_PERSON); + insertInput.setRecords(List.of(new QRecord().withValue("id", id).withValue("firstName", firstName).withValue("lastName", lastName).withValue("birthDate", birthDate))); + new InsertAction().execute(insertInput); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + public static void insertSimpsons() throws QException + { + insertPersonRecord(1, "Homer", "Simpson"); + insertPersonRecord(2, "Marge", "Simpson"); + insertPersonRecord(3, "Bart", "Simpson"); + insertPersonRecord(4, "Lisa", "Simpson"); + insertPersonRecord(5, "Maggie", "Simpson"); + } } diff --git a/qqq-middleware-api/src/test/java/com/kingsrook/qqq/api/javalin/QJavalinApiHandlerTest.java b/qqq-middleware-api/src/test/java/com/kingsrook/qqq/api/javalin/QJavalinApiHandlerTest.java index ca5e3d64..a98d1a80 100644 --- a/qqq-middleware-api/src/test/java/com/kingsrook/qqq/api/javalin/QJavalinApiHandlerTest.java +++ b/qqq-middleware-api/src/test/java/com/kingsrook/qqq/api/javalin/QJavalinApiHandlerTest.java @@ -61,6 +61,8 @@ import org.json.JSONObject; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; +import static com.kingsrook.qqq.api.TestUtils.insertPersonRecord; +import static com.kingsrook.qqq.api.TestUtils.insertSimpsons; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -145,25 +147,29 @@ class QJavalinApiHandlerTest extends BaseTest @Test void testRandom404s() { - for(String method : new String[] { "get", "post", "patch", "delete" }) - { - assertErrorResponse(HttpStatus.NOT_FOUND_404, "Could not find any resources at path", Unirest.request(method, BASE_URL + "/api/" + VERSION + "/notATable/").asString()); - assertErrorResponse(HttpStatus.NOT_FOUND_404, "Could not find any resources at path", Unirest.request(method, BASE_URL + "/api/" + VERSION + "/notATable/notAnId").asString()); - assertErrorResponse(HttpStatus.NOT_FOUND_404, "Could not find any resources at path", Unirest.request(method, BASE_URL + "/api/" + VERSION + "/person/1/2").asString()); - assertErrorResponse(HttpStatus.NOT_FOUND_404, "Could not find any resources at path", Unirest.request(method, BASE_URL + "/api/foo").asString()); + assertErrorResponse(HttpStatus.NOT_FOUND_404, "Could not find any resources at path", Unirest.request("get", BASE_URL + "/api/" + VERSION + "/notATable/").asString()); + assertErrorResponse(HttpStatus.NOT_FOUND_404, "Could not find a table named notATable in this api", Unirest.request("get", BASE_URL + "/api/" + VERSION + "/notATable/notAnId").asString()); + assertErrorResponse(HttpStatus.NOT_FOUND_404, "Could not find any resources at path", Unirest.request("get", BASE_URL + "/api/" + VERSION + "/person/1/2").asString()); + assertErrorResponse(HttpStatus.NOT_FOUND_404, "Could not find any resources at path", Unirest.request("get", BASE_URL + "/api/foo").asString()); + assertErrorResponse(HttpStatus.OK_200, null, Unirest.request("get", BASE_URL + "/api/").asString()); // this path returns the doc site for a GET - if(method.equals("get")) - { - ////////////////////////////////////////////// - // this path returns the doc site for a GET // - ////////////////////////////////////////////// - assertErrorResponse(HttpStatus.OK_200, null, Unirest.request(method, BASE_URL + "/api/").asString()); - } - else - { - assertErrorResponse(HttpStatus.NOT_FOUND_404, "Could not find any resources at path", Unirest.request(method, BASE_URL + "/api/").asString()); - } - } + assertErrorResponse(HttpStatus.NOT_FOUND_404, "Could not find a table named notATable in this api", Unirest.request("post", BASE_URL + "/api/" + VERSION + "/notATable/").asString()); + assertErrorResponse(HttpStatus.NOT_FOUND_404, "Could not find any resources at path", Unirest.request("post", BASE_URL + "/api/" + VERSION + "/notATable/notAnId").asString()); + assertErrorResponse(HttpStatus.NOT_FOUND_404, "Could not find any resources at path", Unirest.request("post", BASE_URL + "/api/" + VERSION + "/person/1/2").asString()); + assertErrorResponse(HttpStatus.NOT_FOUND_404, "Could not find any resources at path", Unirest.request("post", BASE_URL + "/api/foo").asString()); + assertErrorResponse(HttpStatus.NOT_FOUND_404, "Could not find any resources at path", Unirest.request("post", BASE_URL + "/api/").asString()); + + assertErrorResponse(HttpStatus.NOT_FOUND_404, "Could not find any resources at path", Unirest.request("patch", BASE_URL + "/api/" + VERSION + "/notATable/").asString()); + assertErrorResponse(HttpStatus.NOT_FOUND_404, "Could not find a table named notATable in this api", Unirest.request("patch", BASE_URL + "/api/" + VERSION + "/notATable/notAnId").asString()); + assertErrorResponse(HttpStatus.NOT_FOUND_404, "Could not find any resources at path", Unirest.request("patch", BASE_URL + "/api/" + VERSION + "/person/1/2").asString()); + assertErrorResponse(HttpStatus.NOT_FOUND_404, "Could not find any resources at path", Unirest.request("patch", BASE_URL + "/api/foo").asString()); + assertErrorResponse(HttpStatus.NOT_FOUND_404, "Could not find any resources at path", Unirest.request("patch", BASE_URL + "/api/").asString()); + + assertErrorResponse(HttpStatus.NOT_FOUND_404, "Could not find any resources at path", Unirest.request("delete", BASE_URL + "/api/" + VERSION + "/notATable/").asString()); + assertErrorResponse(HttpStatus.NOT_FOUND_404, "Could not find a table named notATable in this api", Unirest.request("delete", BASE_URL + "/api/" + VERSION + "/notATable/notAnId").asString()); + assertErrorResponse(HttpStatus.NOT_FOUND_404, "Could not find any resources at path", Unirest.request("delete", BASE_URL + "/api/" + VERSION + "/person/1/2").asString()); + assertErrorResponse(HttpStatus.NOT_FOUND_404, "Could not find any resources at path", Unirest.request("delete", BASE_URL + "/api/foo").asString()); + assertErrorResponse(HttpStatus.NOT_FOUND_404, "Could not find any resources at path", Unirest.request("delete", BASE_URL + "/api/").asString()); } @@ -1240,43 +1246,6 @@ class QJavalinApiHandlerTest extends BaseTest - /******************************************************************************* - ** - *******************************************************************************/ - private static void insertPersonRecord(Integer id, String firstName, String lastName) throws QException - { - insertPersonRecord(id, firstName, lastName, null); - } - - - - /******************************************************************************* - ** - *******************************************************************************/ - private static void insertPersonRecord(Integer id, String firstName, String lastName, LocalDate birthDate) throws QException - { - InsertInput insertInput = new InsertInput(); - insertInput.setTableName(TestUtils.TABLE_NAME_PERSON); - insertInput.setRecords(List.of(new QRecord().withValue("id", id).withValue("firstName", firstName).withValue("lastName", lastName).withValue("birthDate", birthDate))); - new InsertAction().execute(insertInput); - } - - - - /******************************************************************************* - ** - *******************************************************************************/ - private static void insertSimpsons() throws QException - { - insertPersonRecord(1, "Homer", "Simpson"); - insertPersonRecord(2, "Marge", "Simpson"); - insertPersonRecord(3, "Bart", "Simpson"); - insertPersonRecord(4, "Lisa", "Simpson"); - insertPersonRecord(5, "Maggie", "Simpson"); - } - - - /******************************************************************************* ** *******************************************************************************/ diff --git a/qqq-middleware-api/src/test/java/com/kingsrook/qqq/api/utils/ApiScriptUtilsTest.java b/qqq-middleware-api/src/test/java/com/kingsrook/qqq/api/utils/ApiScriptUtilsTest.java new file mode 100644 index 00000000..ec4cfb2b --- /dev/null +++ b/qqq-middleware-api/src/test/java/com/kingsrook/qqq/api/utils/ApiScriptUtilsTest.java @@ -0,0 +1,273 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2023. 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.api.utils; + + +import java.io.Serializable; +import java.util.List; +import java.util.Map; +import com.kingsrook.qqq.api.BaseTest; +import com.kingsrook.qqq.api.TestUtils; +import com.kingsrook.qqq.api.javalin.QBadRequestException; +import com.kingsrook.qqq.backend.core.actions.tables.GetAction; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.exceptions.QNotFoundException; +import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetOutput; +import com.kingsrook.qqq.backend.core.utils.ValueUtils; +import org.junit.jupiter.api.Test; +import static com.kingsrook.qqq.api.TestUtils.insertSimpsons; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + + +/******************************************************************************* + ** Unit test for com.kingsrook.qqq.api.utils.ApiScriptUtils + *******************************************************************************/ +class ApiScriptUtilsTest extends BaseTest +{ + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testSetApiNameAndApiVersion() + { + ApiScriptUtils apiScriptUtils = newDefaultApiScriptUtils(); + + assertThatThrownBy(() -> apiScriptUtils.setApiName("not an api")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("not a valid API name"); + + assertThatThrownBy(() -> apiScriptUtils.setApiVersion("not a version")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("not a supported version"); + + assertThatThrownBy(() -> new ApiScriptUtils("not an api", TestUtils.CURRENT_API_VERSION)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("not a valid API name"); + + assertThatThrownBy(() -> new ApiScriptUtils(TestUtils.ALTERNATIVE_API_NAME, "not a version")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("not a supported version"); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testGet() throws QException + { + ApiScriptUtils apiScriptUtils = newDefaultApiScriptUtils(); + + assertThatThrownBy(() -> apiScriptUtils.get(TestUtils.TABLE_NAME_PERSON, 1)) + .isInstanceOf(QNotFoundException.class); + + insertSimpsons(); + + Map result = apiScriptUtils.get(TestUtils.TABLE_NAME_PERSON, 1); + assertEquals("Homer", result.get("firstName")); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testQuery() throws QException + { + ApiScriptUtils apiScriptUtils = newDefaultApiScriptUtils(); + + assertThatThrownBy(() -> apiScriptUtils.query(TestUtils.TABLE_NAME_PERSON, "foo=bar")) + .isInstanceOf(QBadRequestException.class) + .hasMessageContaining("Unrecognized filter criteria field: foo"); + + insertSimpsons(); + + Map result = apiScriptUtils.query(TestUtils.TABLE_NAME_PERSON, "id=2"); + assertEquals(1, result.get("count")); + assertEquals(1, ((List) result.get("records")).size()); + assertEquals("Marge", ((Map) ((List) result.get("records")).get(0)).get("firstName")); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testInsert() throws QException + { + ApiScriptUtils apiScriptUtils = newDefaultApiScriptUtils(); + Map result = apiScriptUtils.insert(TestUtils.TABLE_NAME_PERSON, """ + { "firstName": "Mr.", "lastName": "Burns" } + """); + assertEquals(1, result.get("id")); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testBulkInsert() throws QException + { + ApiScriptUtils apiScriptUtils = newDefaultApiScriptUtils(); + List> result = apiScriptUtils.bulkInsert(TestUtils.TABLE_NAME_PERSON, """ + [ + { "firstName": "Mr.", "lastName": "Burns" }, + { "firstName": "Waylon", "lastName": "Smithers" } + ] + """); + assertEquals(2, result.size()); + assertEquals(1, result.get(0).get("id")); + assertEquals(2, result.get(1).get("id")); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testUpdate() throws QException + { + ApiScriptUtils apiScriptUtils = newDefaultApiScriptUtils(); + String updateJSON = """ + { "firstName": "Homer J." } + """; + + assertThatThrownBy(() -> apiScriptUtils.update(TestUtils.TABLE_NAME_PERSON, 1, updateJSON)) + .isInstanceOf(QNotFoundException.class); + + insertSimpsons(); + + apiScriptUtils.update(TestUtils.TABLE_NAME_PERSON, 1, updateJSON); + + GetInput getInput = new GetInput(); + getInput.setTableName(TestUtils.TABLE_NAME_PERSON); + getInput.setPrimaryKey(1); + GetOutput getOutput = new GetAction().execute(getInput); + assertEquals("Homer J.", getOutput.getRecord().getValueString("firstName")); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testBulkUpdate() throws QException + { + ApiScriptUtils apiScriptUtils = newDefaultApiScriptUtils(); + + insertSimpsons(); + + String updateJSON = """ + [ + { "id": 1, "firstName": "Homer J." }, + { "id": 6, "firstName": "C.M." } + ] + """; + + List> result = apiScriptUtils.bulkUpdate(TestUtils.TABLE_NAME_PERSON, updateJSON); + + assertEquals(2, result.size()); + assertEquals(1, result.get(0).get("id")); + assertEquals(6, result.get(1).get("id")); + assertEquals(404, result.get(1).get("statusCode")); + assertNotNull(result.get(1).get("error")); + + GetInput getInput = new GetInput(); + getInput.setTableName(TestUtils.TABLE_NAME_PERSON); + getInput.setPrimaryKey(1); + GetOutput getOutput = new GetAction().execute(getInput); + assertEquals("Homer J.", getOutput.getRecord().getValueString("firstName")); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testDelete() throws QException + { + ApiScriptUtils apiScriptUtils = newDefaultApiScriptUtils(); + assertThatThrownBy(() -> apiScriptUtils.delete(TestUtils.TABLE_NAME_PERSON, 1)) + .isInstanceOf(QNotFoundException.class); + + insertSimpsons(); + + apiScriptUtils.delete(TestUtils.TABLE_NAME_PERSON, 1); + + GetInput getInput = new GetInput(); + getInput.setTableName(TestUtils.TABLE_NAME_PERSON); + getInput.setPrimaryKey(1); + GetOutput getOutput = new GetAction().execute(getInput); + assertNull(getOutput.getRecord()); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testBulkDelete() throws QException + { + ApiScriptUtils apiScriptUtils = newDefaultApiScriptUtils(); + + insertSimpsons(); + + List> result = apiScriptUtils.bulkDelete(TestUtils.TABLE_NAME_PERSON, "[1,6]"); + + assertEquals(2, result.size()); + assertEquals(1, ValueUtils.getValueAsInteger(result.get(0).get("id"))); + assertEquals(6, ValueUtils.getValueAsInteger(result.get(1).get("id"))); + assertEquals(404, result.get(1).get("statusCode")); + assertNotNull(result.get(1).get("error")); + + GetInput getInput = new GetInput(); + getInput.setTableName(TestUtils.TABLE_NAME_PERSON); + getInput.setPrimaryKey(1); + GetOutput getOutput = new GetAction().execute(getInput); + assertNull(getOutput.getRecord()); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private static ApiScriptUtils newDefaultApiScriptUtils() + { + return (new ApiScriptUtils(TestUtils.API_NAME, TestUtils.CURRENT_API_VERSION)); + } + +} \ No newline at end of file diff --git a/qqq-middleware-javalin/src/main/java/com/kingsrook/qqq/backend/javalin/QJavalinScriptsHandler.java b/qqq-middleware-javalin/src/main/java/com/kingsrook/qqq/backend/javalin/QJavalinScriptsHandler.java index 61f84bfa..eaaca7ae 100644 --- a/qqq-middleware-javalin/src/main/java/com/kingsrook/qqq/backend/javalin/QJavalinScriptsHandler.java +++ b/qqq-middleware-javalin/src/main/java/com/kingsrook/qqq/backend/javalin/QJavalinScriptsHandler.java @@ -395,18 +395,18 @@ public class QJavalinScriptsHandler String key = entry.getKey(); String value = entry.getValue().get(0); - if(key.equals("code")) + switch(key) { - input.setCodeReference(new QCodeReference().withInlineCode(value).withCodeType(QCodeType.JAVA_SCRIPT)); - } - else - { - inputValues.put(key, value); + case "code" -> input.setCodeReference(new QCodeReference().withInlineCode(value).withCodeType(QCodeType.JAVA_SCRIPT)); + case "apiName" -> input.setApiName(value); + case "apiVersion" -> input.setApiVersion(value); + default -> inputValues.put(key, value); } } TestScriptActionInterface scriptTester = QCodeLoader.getAdHoc(TestScriptActionInterface.class, scriptTesterCodeRef); TestScriptOutput output = new TestScriptOutput(); + scriptTester.execute(input, output); QJavalinAccessLogger.logEndSuccess();