Compare commits

..

22 Commits

Author SHA1 Message Date
abc6331131 Fixed process responses in openapi.yaml -- they were a layer too low, w/ a wrapped "typedResponse" above them (and since they were being serialized directly by jackson, were missing the 'values' now that they were marked to be ignored by it... so going through our conversion method in here - this suggests some refactoring that should apply a change like this to all specs, in case they have overrides of handleOutput as well... 2024-12-11 15:27:33 -06:00
e84fe7eb18 Checkstyle! 2024-12-11 15:05:47 -06:00
63a48eeafa Avoid exceptions from jackson serialization of processValues that contain a map with a null key 2024-12-11 14:59:08 -06:00
5434721c8e Add NullKeyToEmptyStringSerializer - to allow jackson serialization of a map with a null key 2024-12-11 14:40:06 -06:00
f3546da8cc Updating to 0.24.0 2024-11-22 15:51:25 -06:00
cfd3100535 Merge tag 'version-0.23.0' into dev
Tag release
2024-11-22 15:51:21 -06:00
0dbac39ef5 Merge branch 'rel/0.23.0' 2024-11-22 15:48:22 -06:00
00b4708d80 Update for next development version 2024-11-22 15:27:52 -06:00
b5959b4b89 Update versions for release 2024-11-22 15:27:48 -06:00
243ffe81a5 Change base port - to make mvn verify more stable 2024-11-22 15:14:35 -06:00
76118bfca1 CE-1946: added boolean to let frontend know if it is running in a process 2024-11-22 11:40:44 -06:00
6e91149b0a Feedback from code review 2024-11-22 10:21:22 -06:00
cfeb71aa2f Merged dev into feature/pom-version-fixing 2024-11-21 19:21:04 -06:00
edaabc3523 Try to manage 'snapshot' versions ourselves, to avoid bom-pom causing floating versions to be included... 2024-11-21 15:55:15 -06:00
e53e00c520 Merge pull request #138 from Kingsrook/feature/CE-1887-mobile-android-app
Feature/ce 1887 mobile android app
2024-11-21 11:53:39 -06:00
e970d613a7 Merged dev into feature/CE-1887-mobile-android-app 2024-11-21 10:55:16 -06:00
f5c1573102 Merge pull request #139 from Kingsrook/feature/CE-1946-process-to-allow-post-wms-carton-contents-adjustments
Feature/ce 1946 process to allow post wms carton contents adjustments
2024-11-21 10:33:48 -06:00
2103d578b3 Merge pull request #140 from Kingsrook/feature/CE-1772-generate-labels-poc
Feature/ce 1772 generate labels poc
2024-11-21 10:31:32 -06:00
daad8a720a CE-1946: added more props to child record list data 2024-11-19 20:41:16 -06:00
0ef01efcaa CE-1772: updates to alert widgets 2024-11-19 15:03:02 -06:00
6ef0a89533 CE-1772: fix aws expecting content type if object metadata is given 2024-11-03 21:53:50 -06:00
ce50120234 CE-1772: s3 updates to allow content type specifications among other things 2024-11-03 21:34:50 -06:00
25 changed files with 777 additions and 228 deletions

View File

@ -1,23 +1,51 @@
#!/bin/bash
if [ -z "$CIRCLE_BRANCH" ] && [ -z "$CIRCLE_TAG" ]; then
echo "Error: env vars CIRCLE_BRANCH and CIRCLE_TAG were not set."
exit 1;
fi
############################################################################
## adjust-pom.version.sh
## During CircleCI builds - edit the qqq parent pom.xml, to set the
## <revision> value such that:
## - feature-branch builds, tagged as snapshot-*, deploy with a version
## number that includes that tag's name (minus the snapshot- part)
## - integration-branch builds deploy with a version number that includes
## the branch name slugified
## - we never deploy -SNAPSHOT versions any more - because we don't believe
## it is ever valid to not know exactly what versions you are getting
## (perhaps because we are too loose with our versioning?)
############################################################################
if [ "$CIRCLE_BRANCH" == "dev" ] || [ "$CIRCLE_BRANCH" == "staging" ] || [ "$CIRCLE_BRANCH" == "main" ] || [ \! -z $(echo "$CIRCLE_TAG" | grep "^version-") ]; then
echo "On a primary branch or tag [${CIRCLE_BRANCH}${CIRCLE_TAG}] - will not edit the pom version.";
POM=$(dirname $0)/../pom.xml
echo "On branch: $CIRCLE_BRANCH, tag: $CIRCLE_TAG..."
######################################################################
## ## only do anything if the committed pom has a -SNAPSHOT version ##
######################################################################
REVISION=$(grep '<revision>' $POM | sed 's/.*<revision>//;s/<.*//');
echo "<revision> in pom.xml is: $REVISION"
if [ \! $(echo "$REVISION" | grep SNAPSHOT) ]; then
echo "Not on a SNAPSHOT revision, so nothing to do here."
exit 0;
fi
if [ -n "$CIRCLE_BRANCH" ]; then
SLUG=$(echo $CIRCLE_BRANCH | sed 's/[^a-zA-Z0-9]/-/g')
else
SLUG=$(echo $CIRCLE_TAG | sed 's/^snapshot-//g')
##################################################################################
## ## figure out if we need a SLUG: a snapshot- tag, or an integration/ branch ##
##################################################################################
SLUG=""
if [ $(echo "$CIRCLE_TAG" | grep ^snapshot-) ]; then
SLUG=$(echo "$CIRCLE_TAG" | sed "s/^snapshot-//")-
echo "Using slug [$SLUG] from tag [$CIRCLE_TAG]"
elif [ $(echo "$CIRCLE_BRANCH" | grep ^integration/) ]; then
SLUG=$(echo "$CIRCLE_BRANCH" | sed "s,/,-,g")-
echo "Using slug [$SLUG] from branch [$CIRCLE_BRANCH]"
fi
POM=$(dirname $0)/../pom.xml
################################################################
## ## build the replcaement for -SNAPSHOT, and update the pom ##
################################################################
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
REPLACEMENT=${SLUG}${TIMESTAMP}
echo "Updating $POM <revision> to: $SLUG-SNAPSHOT"
sed -i "s/<revision>.*/<revision>$SLUG-SNAPSHOT<\/revision>/" $POM
echo "Updating $POM -SNAPSHOT to: -$REPLACEMENT"
sed -i "s/-SNAPSHOT<\/revision>/-$REPLACEMENT<\/revision>/" $POM
git diff $POM

View File

@ -47,7 +47,7 @@
</modules>
<properties>
<revision>0.23.0-SNAPSHOT</revision>
<revision>0.24.0-SNAPSHOT</revision>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>

View File

@ -40,9 +40,9 @@ import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
** - alertType - name of entry in AlertType enum (ERROR, WARNING, SUCCESS)
** - alertHtml - html to display inside the alert (other than its icon)
*******************************************************************************/
public class ProcessAlertWidget extends AbstractWidgetRenderer implements MetaDataProducerInterface<QWidgetMetaData>
public class AlertWidgetRenderer extends AbstractWidgetRenderer implements MetaDataProducerInterface<QWidgetMetaData>
{
public static final String NAME = "ProcessAlertWidget";
public static final String NAME = "AlertWidgetRenderer";

View File

@ -301,6 +301,9 @@ public class ChildRecordListRenderer extends AbstractWidgetRenderer
}
}
widgetData.setAllowRecordEdit(BooleanUtils.isTrue(ValueUtils.getValueAsBoolean(input.getQueryParams().get("allowRecordEdit"))));
widgetData.setAllowRecordDelete(BooleanUtils.isTrue(ValueUtils.getValueAsBoolean(input.getQueryParams().get("allowRecordDelete"))));
return (new RenderWidgetOutput(widgetData));
}
catch(Exception e)

View File

@ -28,7 +28,6 @@ import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@ -468,7 +467,8 @@ public class QValueFormatter
{
for(QFieldMetaData field : table.getFields().values())
{
if(field.getType().equals(QFieldType.BLOB))
Optional<FieldAdornment> fileDownloadAdornment = field.getAdornment(AdornmentType.FILE_DOWNLOAD);
if(fileDownloadAdornment.isPresent())
{
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// file name comes from: //
@ -478,20 +478,7 @@ public class QValueFormatter
// - tableLabel primaryKey fieldLabel //
// - and - if the FILE_DOWNLOAD adornment had a DEFAULT_EXTENSION, then it gets added (preceded by a dot) //
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
Optional<FieldAdornment> fileDownloadAdornment = field.getAdornment(AdornmentType.FILE_DOWNLOAD);
Map<String, Serializable> adornmentValues = Collections.emptyMap();
if(fileDownloadAdornment.isPresent())
{
adornmentValues = fileDownloadAdornment.get().getValues();
}
else
{
///////////////////////////////////////////////////////
// don't change blobs unless they are file-downloads //
///////////////////////////////////////////////////////
continue;
}
Map<String, Serializable> adornmentValues = fileDownloadAdornment.get().getValues();
String fileNameField = ValueUtils.getValueAsString(adornmentValues.get(AdornmentType.FileDownloadValues.FILE_NAME_FIELD));
String fileNameFormat = ValueUtils.getValueAsString(adornmentValues.get(AdornmentType.FileDownloadValues.FILE_NAME_FORMAT));
@ -542,7 +529,13 @@ public class QValueFormatter
}
}
record.setValue(field.getName(), "/data/" + table.getName() + "/" + primaryKey + "/" + field.getName() + "/" + fileName);
/////////////////////////////////////////////
// if field type is blob, update its value //
/////////////////////////////////////////////
if(QFieldType.BLOB.equals(field.getType()))
{
record.setValue(field.getName(), "/data/" + table.getName() + "/" + primaryKey + "/" + field.getName() + "/" + fileName);
}
record.setDisplayValue(field.getName(), fileName);
}
}

View File

@ -31,6 +31,7 @@ import com.kingsrook.qqq.backend.core.model.actions.AbstractTableActionInput;
public class StorageInput extends AbstractTableActionInput
{
private String reference;
private String contentType;
@ -74,4 +75,35 @@ public class StorageInput extends AbstractTableActionInput
return (this);
}
/*******************************************************************************
** Getter for contentType
*******************************************************************************/
public String getContentType()
{
return (this.contentType);
}
/*******************************************************************************
** Setter for contentType
*******************************************************************************/
public void setContentType(String contentType)
{
this.contentType = contentType;
}
/*******************************************************************************
** Fluent setter for contentType
*******************************************************************************/
public StorageInput withContentType(String contentType)
{
this.contentType = contentType;
return (this);
}
}

View File

@ -22,6 +22,9 @@
package com.kingsrook.qqq.backend.core.model.dashboard.widgets;
import java.util.List;
/*******************************************************************************
** Model containing datastructure expected by frontend alert widget
**
@ -40,8 +43,10 @@ public class AlertData extends QWidgetData
private String html;
private AlertType alertType;
private String html;
private AlertType alertType;
private Boolean hideWidget = false;
private List<String> bulletList;
@ -139,4 +144,66 @@ public class AlertData extends QWidgetData
return (this);
}
/*******************************************************************************
** Getter for hideWidget
*******************************************************************************/
public boolean getHideWidget()
{
return (this.hideWidget);
}
/*******************************************************************************
** Setter for hideWidget
*******************************************************************************/
public void setHideWidget(boolean hideWidget)
{
this.hideWidget = hideWidget;
}
/*******************************************************************************
** Fluent setter for hideWidget
*******************************************************************************/
public AlertData withHideWidget(boolean hideWidget)
{
this.hideWidget = hideWidget;
return (this);
}
/*******************************************************************************
** Getter for bulletList
*******************************************************************************/
public List<String> getBulletList()
{
return (this.bulletList);
}
/*******************************************************************************
** Setter for bulletList
*******************************************************************************/
public void setBulletList(List<String> bulletList)
{
this.bulletList = bulletList;
}
/*******************************************************************************
** Fluent setter for bulletList
*******************************************************************************/
public AlertData withBulletList(List<String> bulletList)
{
this.bulletList = bulletList;
return (this);
}
}

View File

@ -39,9 +39,14 @@ public class ChildRecordListData extends QWidgetData
private QueryOutput queryOutput;
private QTableMetaData childTableMetaData;
private String tableName;
private String tablePath;
private String viewAllLink;
private Integer totalRows;
private Boolean disableRowClick = false;
private Boolean allowRecordEdit = false;
private Boolean allowRecordDelete = false;
private Boolean isInProcess = false;
private boolean canAddChildRecord = false;
private Map<String, Serializable> defaultValuesForNewChildRecords;
@ -352,4 +357,173 @@ public class ChildRecordListData extends QWidgetData
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 ChildRecordListData withTableName(String tableName)
{
this.tableName = tableName;
return (this);
}
/*******************************************************************************
** Fluent setter for tablePath
*******************************************************************************/
public ChildRecordListData withTablePath(String tablePath)
{
this.tablePath = tablePath;
return (this);
}
/*******************************************************************************
** Getter for disableRowClick
*******************************************************************************/
public Boolean getDisableRowClick()
{
return (this.disableRowClick);
}
/*******************************************************************************
** Setter for disableRowClick
*******************************************************************************/
public void setDisableRowClick(Boolean disableRowClick)
{
this.disableRowClick = disableRowClick;
}
/*******************************************************************************
** Fluent setter for disableRowClick
*******************************************************************************/
public ChildRecordListData withDisableRowClick(Boolean disableRowClick)
{
this.disableRowClick = disableRowClick;
return (this);
}
/*******************************************************************************
** Getter for allowRecordEdit
*******************************************************************************/
public Boolean getAllowRecordEdit()
{
return (this.allowRecordEdit);
}
/*******************************************************************************
** Setter for allowRecordEdit
*******************************************************************************/
public void setAllowRecordEdit(Boolean allowRecordEdit)
{
this.allowRecordEdit = allowRecordEdit;
}
/*******************************************************************************
** Fluent setter for allowRecordEdit
*******************************************************************************/
public ChildRecordListData withAllowRecordEdit(Boolean allowRecordEdit)
{
this.allowRecordEdit = allowRecordEdit;
return (this);
}
/*******************************************************************************
** Getter for allowRecordDelete
*******************************************************************************/
public Boolean getAllowRecordDelete()
{
return (this.allowRecordDelete);
}
/*******************************************************************************
** Setter for allowRecordDelete
*******************************************************************************/
public void setAllowRecordDelete(Boolean allowRecordDelete)
{
this.allowRecordDelete = allowRecordDelete;
}
/*******************************************************************************
** Fluent setter for allowRecordDelete
*******************************************************************************/
public ChildRecordListData withAllowRecordDelete(Boolean allowRecordDelete)
{
this.allowRecordDelete = allowRecordDelete;
return (this);
}
/*******************************************************************************
** Getter for isInProcess
*******************************************************************************/
public Boolean getIsInProcess()
{
return (this.isInProcess);
}
/*******************************************************************************
** Setter for isInProcess
*******************************************************************************/
public void setIsInProcess(Boolean isInProcess)
{
this.isInProcess = isInProcess;
}
/*******************************************************************************
** Fluent setter for isInProcess
*******************************************************************************/
public ChildRecordListData withIsInProcess(Boolean isInProcess)
{
this.isInProcess = isInProcess;
return (this);
}
}

View File

@ -94,6 +94,29 @@ public class CountingHash<K extends Serializable> extends AbstractMap<K, Integer
/*******************************************************************************
** increment the value for the specified key
**
*******************************************************************************/
public Integer put(K key)
{
return (add(key));
}
/*******************************************************************************
** Set the value for the specified key by the supplied value
**
*******************************************************************************/
public Integer put(K key, Integer value)
{
this.map.put(key, value);
return (value);
}
/*******************************************************************************
**
*******************************************************************************/

View File

@ -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<Object>
{
/***************************************************************************
**
***************************************************************************/
public NullKeyToEmptyStringSerializer()
{
this(null);
}
/***************************************************************************
**
***************************************************************************/
public NullKeyToEmptyStringSerializer(Class<Object> t)
{
super(t);
}
/***************************************************************************
**
***************************************************************************/
@Override
public void serialize(Object nullKey, JsonGenerator jsonGenerator, SerializerProvider unused) throws IOException
{
jsonGenerator.writeFieldName("");
}
}
}

View File

@ -35,9 +35,9 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
/*******************************************************************************
** Unit test for ProcessAlertWidget
** Unit test for AlertWidgetRenderer
*******************************************************************************/
class ProcessAlertWidgetTest extends BaseTest
class AlertWidgetRendererTest extends BaseTest
{
/*******************************************************************************
@ -46,10 +46,10 @@ class ProcessAlertWidgetTest extends BaseTest
@Test
void test() throws QException
{
MetaDataProducerHelper.processAllMetaDataProducersInPackage(QContext.getQInstance(), ProcessAlertWidget.class.getPackageName());
MetaDataProducerHelper.processAllMetaDataProducersInPackage(QContext.getQInstance(), AlertWidgetRenderer.class.getPackageName());
RenderWidgetInput input = new RenderWidgetInput();
input.setWidgetMetaData(QContext.getQInstance().getWidget(ProcessAlertWidget.NAME));
input.setWidgetMetaData(QContext.getQInstance().getWidget(AlertWidgetRenderer.NAME));
///////////////////////////////////////////////////////////////////////////////////////////
// make sure we run w/o exceptions (and w/ default outputs) if there are no query params //

View File

@ -73,4 +73,19 @@ class CountingHashTest extends BaseTest
assertEquals(1, alwaysMutable.get("B"));
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void testPut()
{
CountingHash<String> alwaysMutable = new CountingHash<>(Map.of("A", 5));
alwaysMutable.put("A", 25);
assertEquals(25, alwaysMutable.get("A"));
alwaysMutable.put("A");
assertEquals(26, alwaysMutable.get("A"));
}
}

View File

@ -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;
@ -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<Object, String> 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)));
}
}

View File

@ -58,7 +58,7 @@ public class S3StorageAction extends AbstractS3Action implements QStorageInterfa
AmazonS3 amazonS3 = getS3Utils().getAmazonS3();
String fullPath = getFullPath(storageInput);
S3UploadOutputStream s3UploadOutputStream = new S3UploadOutputStream(amazonS3, backend.getBucketName(), fullPath);
S3UploadOutputStream s3UploadOutputStream = new S3UploadOutputStream(amazonS3, backend.getBucketName(), fullPath, storageInput.getContentType());
return (s3UploadOutputStream);
}
catch(Exception e)

View File

@ -53,6 +53,7 @@ public class S3UploadOutputStream extends OutputStream
private final AmazonS3 amazonS3;
private final String bucketName;
private final String key;
private final String contentType;
private byte[] buffer = new byte[5 * 1024 * 1024];
private int offset = 0;
@ -68,11 +69,12 @@ public class S3UploadOutputStream extends OutputStream
** Constructor
**
*******************************************************************************/
public S3UploadOutputStream(AmazonS3 amazonS3, String bucketName, String key)
public S3UploadOutputStream(AmazonS3 amazonS3, String bucketName, String key, String contentType)
{
this.amazonS3 = amazonS3;
this.bucketName = bucketName;
this.key = key;
this.contentType = contentType;
}
@ -96,6 +98,13 @@ public class S3UploadOutputStream extends OutputStream
*******************************************************************************/
private void uploadIfNeeded()
{
ObjectMetadata objectMetadata = null;
if(this.contentType != null)
{
objectMetadata = new ObjectMetadata();
objectMetadata.setContentType(this.contentType);
}
if(offset == buffer.length)
{
//////////////////////////////////////////
@ -104,7 +113,8 @@ public class S3UploadOutputStream extends OutputStream
if(initiateMultipartUploadResult == null)
{
LOG.info("Initiating a multipart upload", logPair("key", key));
initiateMultipartUploadResult = amazonS3.initiateMultipartUpload(new InitiateMultipartUploadRequest(bucketName, key));
initiateMultipartUploadResult = amazonS3.initiateMultipartUpload(new InitiateMultipartUploadRequest(bucketName, key, objectMetadata));
uploadPartResultList = new ArrayList<>();
}
@ -115,7 +125,8 @@ public class S3UploadOutputStream extends OutputStream
.withInputStream(new ByteArrayInputStream(buffer))
.withBucketName(bucketName)
.withKey(key)
.withPartSize(buffer.length);
.withPartSize(buffer.length)
.withObjectMetadata(objectMetadata);
uploadPartResultList.add(amazonS3.uploadPart(uploadPartRequest));
@ -166,6 +177,13 @@ public class S3UploadOutputStream extends OutputStream
return;
}
ObjectMetadata objectMetadata = null;
if(this.contentType != null)
{
objectMetadata = new ObjectMetadata();
objectMetadata.setContentType(this.contentType);
}
if(initiateMultipartUploadResult != null)
{
if(offset > 0)
@ -180,7 +198,8 @@ public class S3UploadOutputStream extends OutputStream
.withInputStream(new ByteArrayInputStream(buffer, 0, offset))
.withBucketName(bucketName)
.withKey(key)
.withPartSize(offset);
.withPartSize(offset)
.withObjectMetadata(objectMetadata);
uploadPartResultList.add(amazonS3.uploadPart(uploadPartRequest));
}
@ -193,8 +212,12 @@ public class S3UploadOutputStream extends OutputStream
}
else
{
if(objectMetadata == null)
{
objectMetadata = new ObjectMetadata();
}
LOG.info("Putting object (non-multipart)", logPair("key", key), logPair("length", offset));
ObjectMetadata objectMetadata = new ObjectMetadata();
objectMetadata.setContentLength(offset);
PutObjectResult putObjectResult = amazonS3.putObject(bucketName, key, new ByteArrayInputStream(buffer, 0, offset), objectMetadata);
}

View File

@ -57,7 +57,7 @@ class S3UploadOutputStreamTest extends BaseS3Test
outputStream.write("\n]\n".getBytes(StandardCharsets.UTF_8));
outputStream.close();
S3UploadOutputStream s3UploadOutputStream = new S3UploadOutputStream(getS3Utils().getAmazonS3(), bucketName, key);
S3UploadOutputStream s3UploadOutputStream = new S3UploadOutputStream(getS3Utils().getAmazonS3(), bucketName, key, null);
s3UploadOutputStream.write(outputStream.toByteArray(), 0, 5 * 1024 * 1024);
s3UploadOutputStream.write(outputStream.toByteArray(), 0, 3 * 1024 * 1024);
s3UploadOutputStream.write(outputStream.toByteArray(), 0, 3 * 1024 * 1024);

View File

@ -1 +1 @@
0.23.0
0.24.0

View File

@ -352,6 +352,8 @@ public class QJavalinProcessHandler
Map<String, Object> 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));
}
}

View File

@ -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<String, Serializable> getValues()
{
return values;

View File

@ -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<ProcessInitOrStepOrStatusResponseV1, Example> responseToExample = response -> new Example().withValue(convertResponseToJSONObject(response).toMap());
return MapBuilder.of(() -> new LinkedHashMap<String, Example>())
.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;
}

View File

@ -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\

View File

@ -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<String> 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"));
}
}

View File

@ -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<BackendStep>((runBackendStepInput, runBackendStepOutput) ->
{
HashMap<String, String> 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
*******************************************************************************/

View File

@ -38,7 +38,7 @@ import org.junit.jupiter.api.BeforeEach;
*******************************************************************************/
public abstract class SpecTestBase
{
private static int PORT = 6263;
private static int PORT = 6273;
protected static Javalin service;

View File

@ -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<String> 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"));
}
}