diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/utils/JsonUtils.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/utils/JsonUtils.java index 7c000e3e..325e842f 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/utils/JsonUtils.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/utils/JsonUtils.java @@ -30,11 +30,14 @@ import java.util.List; import java.util.Map; import java.util.function.Consumer; import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.core.JsonGenerator; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectWriter; import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.ser.std.StdSerializer; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import com.kingsrook.qqq.backend.core.logging.QLogger; import com.kingsrook.qqq.backend.core.model.data.QRecord; @@ -54,6 +57,11 @@ public class JsonUtils { private static final QLogger LOG = QLogger.getLogger(JsonUtils.class); + ////////////////////////////////////////////////////////////////////// + // see https://www.baeldung.com/jackson-map-null-values-or-null-key // + ////////////////////////////////////////////////////////////////////// + public static NullKeyToEmptyStringSerializer nullKeyToEmptyStringSerializer = new NullKeyToEmptyStringSerializer(); + /******************************************************************************* @@ -396,4 +404,41 @@ public class JsonUtils return (record); } + + + /*************************************************************************** + ** + ***************************************************************************/ + public static class NullKeyToEmptyStringSerializer extends StdSerializer + { + /*************************************************************************** + ** + ***************************************************************************/ + public NullKeyToEmptyStringSerializer() + { + this(null); + } + + + + /*************************************************************************** + ** + ***************************************************************************/ + public NullKeyToEmptyStringSerializer(Class t) + { + super(t); + } + + + + /*************************************************************************** + ** + ***************************************************************************/ + @Override + public void serialize(Object nullKey, JsonGenerator jsonGenerator, SerializerProvider unused) throws IOException + { + jsonGenerator.writeFieldName(""); + } + } + } diff --git a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/utils/JsonUtilsTest.java b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/utils/JsonUtilsTest.java index 8e88739a..88db07e3 100644 --- a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/utils/JsonUtilsTest.java +++ b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/utils/JsonUtilsTest.java @@ -35,11 +35,13 @@ 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.data.QRecord; +import com.kingsrook.qqq.backend.core.utils.collections.MapBuilder; import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; @@ -81,7 +83,7 @@ class JsonUtilsTest extends BaseTest { objectMapper.setSerializationInclusion(JsonInclude.Include.ALWAYS); }); - + assertThat(json).contains(""" "values":{"foo":"Foo","bar":3.14159,"baz":null}"""); } @@ -318,4 +320,27 @@ class JsonUtilsTest extends BaseTest assertEquals("age", qQueryFilter.getOrderBys().get(0).getFieldName()); assertTrue(qQueryFilter.getOrderBys().get(0).getIsAscending()); } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testNullKeyInMap() + { + Map mapWithNullKey = MapBuilder.of(null, "foo"); + + ////////////////////////////////////////////////////// + // assert default behavior throws with null map key // + ////////////////////////////////////////////////////// + assertThatThrownBy(() -> JsonUtils.toJson(mapWithNullKey)).rootCause().hasMessageContaining("Null key for a Map not allowed in JSON"); + + //////////////////////////////////////////////////////////////////////// + // assert that the nullKeyToEmptyStringSerializer does what we expect // + //////////////////////////////////////////////////////////////////////// + assertEquals(""" + {"":"foo"}""", JsonUtils.toJson(mapWithNullKey, mapper -> mapper.getSerializerProvider().setNullKeySerializer(JsonUtils.nullKeyToEmptyStringSerializer))); + } + } 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..e339d957 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 @@ -28,6 +28,7 @@ import java.time.LocalDate; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.function.Function; import com.fasterxml.jackson.annotation.JsonInclude; import com.kingsrook.qqq.backend.core.actions.tables.InsertAction; import com.kingsrook.qqq.backend.core.actions.values.QValueFormatter; @@ -141,12 +142,14 @@ public class ProcessSpecUtilsV1 errorResponse.setError("Illegal Argument Exception: NaN"); errorResponse.setUserFacingError("The process could not be completed due to invalid input."); + Function responseToExample = response -> new Example().withValue(convertResponseToJSONObject(response).toMap()); + return MapBuilder.of(() -> new LinkedHashMap()) - .with("COMPLETE", new Example().withValue(completeResponse)) - .with("COMPLETE with metaDataAdjustment", new Example().withValue(completeResponseWithMetaDataAdjustment)) - .with("JOB_STARTED", new Example().withValue(jobStartedResponse)) - .with("RUNNING", new Example().withValue(runningResponse)) - .with("ERROR", new Example().withValue(errorResponse)) + .with("COMPLETE", responseToExample.apply(completeResponse)) + .with("COMPLETE with metaDataAdjustment", responseToExample.apply(completeResponseWithMetaDataAdjustment)) + .with("JOB_STARTED", responseToExample.apply(jobStartedResponse)) + .with("RUNNING", responseToExample.apply(runningResponse)) + .with("ERROR", responseToExample.apply(errorResponse)) .build(); } @@ -155,7 +158,21 @@ public class ProcessSpecUtilsV1 /*************************************************************************** ** ***************************************************************************/ - public static void handleOutput(Context context, ProcessInitOrStepOrStatusResponseV1 output) + public static void handleOutput(Context context, ProcessInitOrStepOrStatusResponseV1 response) + { + JSONObject outputJsonObject = convertResponseToJSONObject(response); + + String json = outputJsonObject.toString(3); + System.out.println(json); + context.result(json); + } + + + + /*************************************************************************** + ** + ***************************************************************************/ + private static JSONObject convertResponseToJSONObject(ProcessInitOrStepOrStatusResponseV1 response) { //////////////////////////////////////////////////////////////////////////////// // normally, we like the JsonUtils behavior of excluding null/empty elements. // @@ -163,7 +180,7 @@ public class ProcessSpecUtilsV1 // so, go through a loop of object → JSON String → JSONObject → String... // // also - work with the TypedResponse sub-object within this response class // //////////////////////////////////////////////////////////////////////////////// - ProcessInitOrStepOrStatusResponseV1.TypedResponse typedOutput = output.getTypedResponse(); + ProcessInitOrStepOrStatusResponseV1.TypedResponse typedOutput = response.getTypedResponse(); String outputJson = JsonUtils.toJson(typedOutput); JSONObject outputJsonObject = new JSONObject(outputJson); @@ -183,6 +200,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 +238,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)); @@ -252,10 +297,7 @@ public class ProcessSpecUtilsV1 outputJsonObject.put("values", valuesAsJsonObject); } } - - String json = outputJsonObject.toString(3); - System.out.println(json); - context.result(json); + return outputJsonObject; } diff --git a/qqq-middleware-javalin/src/main/resources/openapi/v1/openapi.yaml b/qqq-middleware-javalin/src/main/resources/openapi/v1/openapi.yaml index f89ba784..e6772436 100644 --- a/qqq-middleware-javalin/src/main/resources/openapi/v1/openapi.yaml +++ b/qqq-middleware-javalin/src/main/resources/openapi/v1/openapi.yaml @@ -1652,66 +1652,61 @@ paths: examples: COMPLETE: value: - typedResponse: - nextStep: "reviewScreen" - processUUID: "01234567-89AB-CDEF-0123-456789ABCDEF" - type: "COMPLETE" - values: - totalAge: 32768 - firstLastName: "Aabramson" + values: + firstLastName: "Aabramson" + totalAge: 32768 + processUUID: "01234567-89AB-CDEF-0123-456789ABCDEF" + nextStep: "reviewScreen" + type: "COMPLETE" COMPLETE with metaDataAdjustment: value: - typedResponse: - nextStep: "inputScreen" - processMetaDataAdjustment: - updatedFields: - someField: - displayFormat: "%s" - isEditable: true - isHeavy: false - isHidden: false - isRequired: true - name: "someField" - type: "STRING" - updatedFrontendStepList: - - components: - - type: "EDIT_FORM" - formFields: - - displayFormat: "%s" - isEditable: true - isHeavy: false - isHidden: false - isRequired: false - name: "someField" - type: "STRING" - name: "inputScreen" - - components: - - type: "PROCESS_SUMMARY_RESULTS" - name: "resultScreen" - processUUID: "01234567-89AB-CDEF-0123-456789ABCDEF" - type: "COMPLETE" - values: - totalAge: 32768 - firstLastName: "Aabramson" + values: + firstLastName: "Aabramson" + totalAge: 32768 + processUUID: "01234567-89AB-CDEF-0123-456789ABCDEF" + nextStep: "inputScreen" + processMetaDataAdjustment: + updatedFields: + someField: + isRequired: true + isEditable: true + name: "someField" + displayFormat: "%s" + type: "STRING" + isHeavy: false + isHidden: false + updatedFrontendStepList: + - components: + - type: "EDIT_FORM" + name: "inputScreen" + formFields: + - isRequired: false + isEditable: true + name: "someField" + displayFormat: "%s" + type: "STRING" + isHeavy: false + isHidden: false + - components: + - type: "PROCESS_SUMMARY_RESULTS" + name: "resultScreen" + type: "COMPLETE" JOB_STARTED: value: - typedResponse: - jobUUID: "98765432-10FE-DCBA-9876-543210FEDCBA" - processUUID: "01234567-89AB-CDEF-0123-456789ABCDEF" - type: "JOB_STARTED" + jobUUID: "98765432-10FE-DCBA-9876-543210FEDCBA" + processUUID: "01234567-89AB-CDEF-0123-456789ABCDEF" + type: "JOB_STARTED" RUNNING: value: - typedResponse: - current: 47 - message: "Processing person records" - processUUID: "01234567-89AB-CDEF-0123-456789ABCDEF" - total: 1701 - type: "RUNNING" + current: 47 + total: 1701 + processUUID: "01234567-89AB-CDEF-0123-456789ABCDEF" + type: "RUNNING" + message: "Processing person records" ERROR: value: - typedResponse: - processUUID: "01234567-89AB-CDEF-0123-456789ABCDEF" - type: "RUNNING" + processUUID: "01234567-89AB-CDEF-0123-456789ABCDEF" + type: "RUNNING" schema: $ref: "#/components/schemas/ProcessStepResponseV1" description: "State of the initialization of the job, with different fields\ @@ -1788,66 +1783,61 @@ paths: examples: COMPLETE: value: - typedResponse: - nextStep: "reviewScreen" - processUUID: "01234567-89AB-CDEF-0123-456789ABCDEF" - type: "COMPLETE" - values: - totalAge: 32768 - firstLastName: "Aabramson" + values: + firstLastName: "Aabramson" + totalAge: 32768 + processUUID: "01234567-89AB-CDEF-0123-456789ABCDEF" + nextStep: "reviewScreen" + type: "COMPLETE" COMPLETE with metaDataAdjustment: value: - typedResponse: - nextStep: "inputScreen" - processMetaDataAdjustment: - updatedFields: - someField: - displayFormat: "%s" - isEditable: true - isHeavy: false - isHidden: false - isRequired: true - name: "someField" - type: "STRING" - updatedFrontendStepList: - - components: - - type: "EDIT_FORM" - formFields: - - displayFormat: "%s" - isEditable: true - isHeavy: false - isHidden: false - isRequired: false - name: "someField" - type: "STRING" - name: "inputScreen" - - components: - - type: "PROCESS_SUMMARY_RESULTS" - name: "resultScreen" - processUUID: "01234567-89AB-CDEF-0123-456789ABCDEF" - type: "COMPLETE" - values: - totalAge: 32768 - firstLastName: "Aabramson" + values: + firstLastName: "Aabramson" + totalAge: 32768 + processUUID: "01234567-89AB-CDEF-0123-456789ABCDEF" + nextStep: "inputScreen" + processMetaDataAdjustment: + updatedFields: + someField: + isRequired: true + isEditable: true + name: "someField" + displayFormat: "%s" + type: "STRING" + isHeavy: false + isHidden: false + updatedFrontendStepList: + - components: + - type: "EDIT_FORM" + name: "inputScreen" + formFields: + - isRequired: false + isEditable: true + name: "someField" + displayFormat: "%s" + type: "STRING" + isHeavy: false + isHidden: false + - components: + - type: "PROCESS_SUMMARY_RESULTS" + name: "resultScreen" + type: "COMPLETE" JOB_STARTED: value: - typedResponse: - jobUUID: "98765432-10FE-DCBA-9876-543210FEDCBA" - processUUID: "01234567-89AB-CDEF-0123-456789ABCDEF" - type: "JOB_STARTED" + jobUUID: "98765432-10FE-DCBA-9876-543210FEDCBA" + processUUID: "01234567-89AB-CDEF-0123-456789ABCDEF" + type: "JOB_STARTED" RUNNING: value: - typedResponse: - current: 47 - message: "Processing person records" - processUUID: "01234567-89AB-CDEF-0123-456789ABCDEF" - total: 1701 - type: "RUNNING" + current: 47 + total: 1701 + processUUID: "01234567-89AB-CDEF-0123-456789ABCDEF" + type: "RUNNING" + message: "Processing person records" ERROR: value: - typedResponse: - processUUID: "01234567-89AB-CDEF-0123-456789ABCDEF" - type: "RUNNING" + processUUID: "01234567-89AB-CDEF-0123-456789ABCDEF" + type: "RUNNING" schema: $ref: "#/components/schemas/ProcessStepResponseV1" description: "State of the backend's running of the next step(s) of the\ @@ -1895,66 +1885,61 @@ paths: examples: COMPLETE: value: - typedResponse: - nextStep: "reviewScreen" - processUUID: "01234567-89AB-CDEF-0123-456789ABCDEF" - type: "COMPLETE" - values: - totalAge: 32768 - firstLastName: "Aabramson" + values: + firstLastName: "Aabramson" + totalAge: 32768 + processUUID: "01234567-89AB-CDEF-0123-456789ABCDEF" + nextStep: "reviewScreen" + type: "COMPLETE" COMPLETE with metaDataAdjustment: value: - typedResponse: - nextStep: "inputScreen" - processMetaDataAdjustment: - updatedFields: - someField: - displayFormat: "%s" - isEditable: true - isHeavy: false - isHidden: false - isRequired: true - name: "someField" - type: "STRING" - updatedFrontendStepList: - - components: - - type: "EDIT_FORM" - formFields: - - displayFormat: "%s" - isEditable: true - isHeavy: false - isHidden: false - isRequired: false - name: "someField" - type: "STRING" - name: "inputScreen" - - components: - - type: "PROCESS_SUMMARY_RESULTS" - name: "resultScreen" - processUUID: "01234567-89AB-CDEF-0123-456789ABCDEF" - type: "COMPLETE" - values: - totalAge: 32768 - firstLastName: "Aabramson" + values: + firstLastName: "Aabramson" + totalAge: 32768 + processUUID: "01234567-89AB-CDEF-0123-456789ABCDEF" + nextStep: "inputScreen" + processMetaDataAdjustment: + updatedFields: + someField: + isRequired: true + isEditable: true + name: "someField" + displayFormat: "%s" + type: "STRING" + isHeavy: false + isHidden: false + updatedFrontendStepList: + - components: + - type: "EDIT_FORM" + name: "inputScreen" + formFields: + - isRequired: false + isEditable: true + name: "someField" + displayFormat: "%s" + type: "STRING" + isHeavy: false + isHidden: false + - components: + - type: "PROCESS_SUMMARY_RESULTS" + name: "resultScreen" + type: "COMPLETE" JOB_STARTED: value: - typedResponse: - jobUUID: "98765432-10FE-DCBA-9876-543210FEDCBA" - processUUID: "01234567-89AB-CDEF-0123-456789ABCDEF" - type: "JOB_STARTED" + jobUUID: "98765432-10FE-DCBA-9876-543210FEDCBA" + processUUID: "01234567-89AB-CDEF-0123-456789ABCDEF" + type: "JOB_STARTED" RUNNING: value: - typedResponse: - current: 47 - message: "Processing person records" - processUUID: "01234567-89AB-CDEF-0123-456789ABCDEF" - total: 1701 - type: "RUNNING" + current: 47 + total: 1701 + processUUID: "01234567-89AB-CDEF-0123-456789ABCDEF" + type: "RUNNING" + message: "Processing person records" ERROR: value: - typedResponse: - processUUID: "01234567-89AB-CDEF-0123-456789ABCDEF" - type: "RUNNING" + processUUID: "01234567-89AB-CDEF-0123-456789ABCDEF" + type: "RUNNING" schema: $ref: "#/components/schemas/ProcessStepResponseV1" description: "State of the backend's running of the specified job, with\ 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