diff --git a/qqq-middleware-javalin/src/main/java/com/kingsrook/qqq/backend/javalin/QJavalinProcessHandler.java b/qqq-middleware-javalin/src/main/java/com/kingsrook/qqq/backend/javalin/QJavalinProcessHandler.java index bb00bfab..1ed7e826 100644 --- a/qqq-middleware-javalin/src/main/java/com/kingsrook/qqq/backend/javalin/QJavalinProcessHandler.java +++ b/qqq-middleware-javalin/src/main/java/com/kingsrook/qqq/backend/javalin/QJavalinProcessHandler.java @@ -352,6 +352,8 @@ public class QJavalinProcessHandler Map resultForCaller = new HashMap<>(); Exception returningException = null; + String processName = context.pathParam("processName"); + try { if(processUUID == null) @@ -360,7 +362,6 @@ public class QJavalinProcessHandler } resultForCaller.put("processUUID", processUUID); - String processName = context.pathParam("processName"); LOG.info(startAfterStep == null ? "Initiating process [" + processName + "] [" + processUUID + "]" : "Resuming process [" + processName + "] [" + processUUID + "] after step [" + startAfterStep + "]"); @@ -441,10 +442,30 @@ public class QJavalinProcessHandler // negative side-effects - but be aware. // // One could imagine that we'd need this to be configurable in the future? // /////////////////////////////////////////////////////////////////////////////////// - context.result(JsonUtils.toJson(resultForCaller, mapper -> + try { - mapper.setSerializationInclusion(JsonInclude.Include.ALWAYS); - })); + String json = JsonUtils.toJson(resultForCaller, mapper -> + { + mapper.setSerializationInclusion(JsonInclude.Include.ALWAYS); + + ///////////////////////////////////////////////////////////////////////////////////////////////////////////// + // use this custom serializer to convert null map-keys to empty-strings (rather than having an exception!) // + ///////////////////////////////////////////////////////////////////////////////////////////////////////////// + mapper.getSerializerProvider().setNullKeySerializer(JsonUtils.nullKeyToEmptyStringSerializer); + }); + context.result(json); + } + catch(Exception e) + { + ///////////////////////////////////////////////////////////////////////////////////////////////////// + // related to the change above - we've seen at least one new error that can come from the // + // Include.ALWAYS change (a null key in a map -> jackson exception). So, try-catch around // + // the above serialization, and if it does throw, log, but continue trying a default serialization // + // as that is probably preferable to an exception for the caller... // + ///////////////////////////////////////////////////////////////////////////////////////////////////// + LOG.warn("Error deserializing process results with serializationInclusion:ALWAYS - will retry with default settings", e, logPair("processName", processName), logPair("processUUID", processUUID)); + context.result(JsonUtils.toJson(resultForCaller)); + } } diff --git a/qqq-middleware-javalin/src/main/java/com/kingsrook/qqq/middleware/javalin/specs/v1/responses/ProcessInitOrStepOrStatusResponseV1.java b/qqq-middleware-javalin/src/main/java/com/kingsrook/qqq/middleware/javalin/specs/v1/responses/ProcessInitOrStepOrStatusResponseV1.java index 5723c3c2..cc81bacb 100644 --- a/qqq-middleware-javalin/src/main/java/com/kingsrook/qqq/middleware/javalin/specs/v1/responses/ProcessInitOrStepOrStatusResponseV1.java +++ b/qqq-middleware-javalin/src/main/java/com/kingsrook/qqq/middleware/javalin/specs/v1/responses/ProcessInitOrStepOrStatusResponseV1.java @@ -26,6 +26,7 @@ import java.io.Serializable; import java.util.List; import java.util.Map; import java.util.stream.Collectors; +import com.fasterxml.jackson.annotation.JsonIgnore; import com.kingsrook.qqq.middleware.javalin.executors.io.ProcessInitOrStepOrStatusOutputInterface; import com.kingsrook.qqq.middleware.javalin.schemabuilder.SchemaBuilder; import com.kingsrook.qqq.middleware.javalin.schemabuilder.ToSchema; @@ -117,6 +118,7 @@ public class ProcessInitOrStepOrStatusResponseV1 implements ProcessInitOrStepOrS ** Getter for values ** *******************************************************************************/ + @JsonIgnore // we are doing custom serialization of the values map, so mark as ignore. public Map getValues() { return values; diff --git a/qqq-middleware-javalin/src/main/java/com/kingsrook/qqq/middleware/javalin/specs/v1/utils/ProcessSpecUtilsV1.java b/qqq-middleware-javalin/src/main/java/com/kingsrook/qqq/middleware/javalin/specs/v1/utils/ProcessSpecUtilsV1.java index 9b1ffca7..761baa5d 100644 --- a/qqq-middleware-javalin/src/main/java/com/kingsrook/qqq/middleware/javalin/specs/v1/utils/ProcessSpecUtilsV1.java +++ b/qqq-middleware-javalin/src/main/java/com/kingsrook/qqq/middleware/javalin/specs/v1/utils/ProcessSpecUtilsV1.java @@ -183,6 +183,14 @@ public class ProcessSpecUtilsV1 String name = valueEntry.getKey(); Serializable value = valueEntry.getValue(); + /////////////////////////////////////////////////////////////////////////////////////////////////////// + // follow the strategy that we use for JsonUtils.nullKeyToEmptyStringSerializer in this rare case... // + /////////////////////////////////////////////////////////////////////////////////////////////////////// + if(name == null) + { + name = ""; + } + Serializable valueToMakeIntoJson = value; if(value instanceof String s) { @@ -213,11 +221,31 @@ public class ProcessSpecUtilsV1 valueToMakeIntoJson = new WidgetBlock(abstractBlockWidgetData); } - String valueAsJsonString = JsonUtils.toJson(valueToMakeIntoJson, mapper -> + /////////////////////////////////////////////// + // ok now, make the value into a JSON string // + /////////////////////////////////////////////// + String valueAsJsonString; + try { - mapper.setSerializationInclusion(JsonInclude.Include.ALWAYS); - }); + valueAsJsonString = JsonUtils.toJson(valueToMakeIntoJson, mapper -> + { + mapper.setSerializationInclusion(JsonInclude.Include.ALWAYS); + ///////////////////////////////////////////////////////////////////////////////////////////////////////////// + // use this custom serializer to convert null map-keys to empty-strings (rather than having an exception!) // + ///////////////////////////////////////////////////////////////////////////////////////////////////////////// + mapper.getSerializerProvider().setNullKeySerializer(JsonUtils.nullKeyToEmptyStringSerializer); + }); + } + catch(Exception e) + { + LOG.warn("Error deserializing process results with serializationInclusion:ALWAYS - will retry with default settings", e); + valueAsJsonString = JsonUtils.toJson(valueToMakeIntoJson); + } + + ///////////////////////////////////////////////////////////////////////////////////////////////////////// + // THEN - make it back into a JSONObject or JSONArray, and add it to the valuesAsJsonObject JSONObject // + ///////////////////////////////////////////////////////////////////////////////////////////////////////// if(valueAsJsonString.startsWith("[")) { valuesAsJsonObject.put(name, new JSONArray(valueAsJsonString)); diff --git a/qqq-middleware-javalin/src/test/java/com/kingsrook/qqq/backend/javalin/QJavalinProcessHandlerTest.java b/qqq-middleware-javalin/src/test/java/com/kingsrook/qqq/backend/javalin/QJavalinProcessHandlerTest.java index 563ad04a..6c5298ed 100644 --- a/qqq-middleware-javalin/src/test/java/com/kingsrook/qqq/backend/javalin/QJavalinProcessHandlerTest.java +++ b/qqq-middleware-javalin/src/test/java/com/kingsrook/qqq/backend/javalin/QJavalinProcessHandlerTest.java @@ -655,4 +655,28 @@ class QJavalinProcessHandlerTest extends QJavalinTestBase assertEquals(200, response.getStatus()); } + + + /******************************************************************************* + ** test running a process who has a value with a null key. + * + ** This was a regression - that threw an exception from jackson at one point in time. + ** + ** Note: ported to v1 + *******************************************************************************/ + @Test + public void test_processPutsNullKeyInMap() + { + HttpResponse response = Unirest.get(BASE_URL + "/processes/" + TestUtils.PROCESS_NAME_PUTS_NULL_KEY_IN_MAP + "/init").asString(); + assertEquals(200, response.getStatus()); + JSONObject jsonObject = JsonUtils.toJSONObject(response.getBody()); + assertNotNull(jsonObject); + JSONObject values = jsonObject.getJSONObject("values"); + JSONObject mapWithNullKey = values.getJSONObject("mapWithNullKey"); + assertTrue(mapWithNullKey.has("")); // null key currently set to become empty-string key... + assertEquals("hadNullKey", mapWithNullKey.getString("")); + assertTrue(mapWithNullKey.has("one")); + assertEquals("1", mapWithNullKey.getString("one")); + } + } diff --git a/qqq-middleware-javalin/src/test/java/com/kingsrook/qqq/backend/javalin/TestUtils.java b/qqq-middleware-javalin/src/test/java/com/kingsrook/qqq/backend/javalin/TestUtils.java index b1e41f11..32b6ec2d 100644 --- a/qqq-middleware-javalin/src/test/java/com/kingsrook/qqq/backend/javalin/TestUtils.java +++ b/qqq-middleware-javalin/src/test/java/com/kingsrook/qqq/backend/javalin/TestUtils.java @@ -25,6 +25,7 @@ package com.kingsrook.qqq.backend.javalin; import java.io.InputStream; import java.nio.charset.StandardCharsets; import java.sql.Connection; +import java.util.HashMap; import java.util.List; import java.util.Objects; import java.util.Optional; @@ -53,6 +54,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData; import com.kingsrook.qqq.backend.core.model.metadata.QInstance; import com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData; import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; +import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReferenceLambda; import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeType; import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData; import com.kingsrook.qqq.backend.core.model.metadata.fields.AdornmentType; @@ -112,6 +114,8 @@ public class TestUtils public static final String PROCESS_NAME_SIMPLE_THROW = "simpleThrow"; public static final String PROCESS_NAME_SLEEP_INTERACTIVE = "sleepInteractive"; + public static final String PROCESS_NAME_PUTS_NULL_KEY_IN_MAP = "putsNullKeyInMap"; + public static final String STEP_NAME_SLEEPER = "sleeper"; public static final String STEP_NAME_THROWER = "thrower"; @@ -177,6 +181,7 @@ public class TestUtils qInstance.addProcess(defineProcessGreetPeopleInteractive()); qInstance.addProcess(defineProcessSimpleSleep()); qInstance.addProcess(defineProcessScreenThenSleep()); + qInstance.addProcess(defineProcessPutsNullKeyInMap()); qInstance.addProcess(defineProcessSimpleThrow()); qInstance.addReport(definePersonsReport()); qInstance.addPossibleValueSource(definePossibleValueSourcePerson()); @@ -554,6 +559,26 @@ public class TestUtils + + /******************************************************************************* + ** Define an interactive version of the 'greet people' process + *******************************************************************************/ + private static QProcessMetaData defineProcessPutsNullKeyInMap() + { + return new QProcessMetaData() + .withName(PROCESS_NAME_PUTS_NULL_KEY_IN_MAP) + .withTableName(TABLE_NAME_PERSON) + .withStep(new QBackendStepMetaData().withName("step") + .withCode(new QCodeReferenceLambda((runBackendStepInput, runBackendStepOutput) -> + { + HashMap mapWithNullKey = new HashMap<>(); + mapWithNullKey.put(null, "hadNullKey"); + mapWithNullKey.put("one", "1"); + runBackendStepOutput.addValue("mapWithNullKey", mapWithNullKey); + }))); + } + + /******************************************************************************* ** Define a process with just one step that sleeps and then throws *******************************************************************************/ diff --git a/qqq-middleware-javalin/src/test/java/com/kingsrook/qqq/middleware/javalin/specs/v1/ProcessInitSpecV1Test.java b/qqq-middleware-javalin/src/test/java/com/kingsrook/qqq/middleware/javalin/specs/v1/ProcessInitSpecV1Test.java index 36d2fa8c..ee9fd1af 100644 --- a/qqq-middleware-javalin/src/test/java/com/kingsrook/qqq/middleware/javalin/specs/v1/ProcessInitSpecV1Test.java +++ b/qqq-middleware-javalin/src/test/java/com/kingsrook/qqq/middleware/javalin/specs/v1/ProcessInitSpecV1Test.java @@ -216,4 +216,26 @@ class ProcessInitSpecV1Test extends SpecTestBase // todo - in a higher-level test, resume test_processInitGoingAsync at the // request job status before sleep is done // line } + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testProcessPutsNullKeyInMap() + { + HttpResponse response = Unirest.post(getBaseUrlAndPath() + "/processes/" + TestUtils.PROCESS_NAME_PUTS_NULL_KEY_IN_MAP + "/init") + .multiPartContent() + .asString(); + + assertEquals(200, response.getStatus()); + JSONObject jsonObject = JsonUtils.toJSONObject(response.getBody()); + + JSONObject values = jsonObject.getJSONObject("values"); + JSONObject mapWithNullKey = values.getJSONObject("mapWithNullKey"); + assertTrue(mapWithNullKey.has("")); // null key currently set to become empty-string key... + assertEquals("hadNullKey", mapWithNullKey.getString("")); + assertTrue(mapWithNullKey.has("one")); + assertEquals("1", mapWithNullKey.getString("one")); + } + } \ No newline at end of file