QGIS Server WFS3 ACL + alias + excluded fields

Takes into account aliases, excluded attributes and ACL plugins
This commit is contained in:
Alessandro Pasotti 2019-09-10 18:20:29 +02:00
parent b2c7ba05c2
commit 67c8e56f9c
22 changed files with 240 additions and 123 deletions

View File

@ -39,14 +39,6 @@ Parses a comma separated ``bbox`` into a (possibily empty) :py:class:`QgsRectang
static QgsCoordinateReferenceSystem parseCrs( const QString &bboxCrs );
%Docstring
Parses the CRS URI ``bboxCrs`` (example: "http://www.opengis.net/def/crs/OGC/1.3/CRS84") into a QGIS CRS object
%End
static const QgsFields publishedFields( const QgsVectorLayer *layer );
%Docstring
Returns the list of fields accessible to the service for a given ``layer``.
This method takes into account the ACL restrictions provided by QGIS Server Access Control plugins.
TODO: implement ACL
%End
static const QVector<QgsMapLayer *> publishedWfsLayers( const QgsProject *project );

View File

@ -91,12 +91,6 @@ QgsCoordinateReferenceSystem QgsServerApiUtils::parseCrs( const QString &bboxCrs
}
}
const QgsFields QgsServerApiUtils::publishedFields( const QgsVectorLayer *layer )
{
// TODO: implement plugin's ACL filtering
return layer->fields();
}
const QVector<QgsMapLayer *> QgsServerApiUtils::publishedWfsLayers( const QgsProject *project )
{
const QStringList wfsLayerIds = QgsServerProjectUtils::wfsLayerIds( *project );

View File

@ -71,14 +71,6 @@ class SERVER_EXPORT QgsServerApiUtils
*/
static QgsCoordinateReferenceSystem parseCrs( const QString &bboxCrs );
/**
* Returns the list of fields accessible to the service for a given \a layer.
*
* This method takes into account the ACL restrictions provided by QGIS Server Access Control plugins.
* TODO: implement ACL
*/
static const QgsFields publishedFields( const QgsVectorLayer *layer );
/**
* Returns the list of layers accessible to the service for a given \a project.
*

View File

@ -173,6 +173,7 @@ void QgsServerOgcApiHandler::jsonDump( json &data, const QgsServerApiContext &co
QDateTime time { QDateTime::currentDateTime() };
time.setTimeSpec( Qt::TimeSpec::UTC );
data["timeStamp"] = time.toString( Qt::DateFormat::ISODate ).toStdString() ;
context.response()->setStatusCode( 200 );
context.response()->setHeader( QStringLiteral( "Content-Type" ), contentType );
#ifdef QGISDEBUG
context.response()->write( data.dump( 2 ) );

View File

@ -33,6 +33,7 @@
#ifdef HAVE_SERVER_PYTHON_PLUGINS
#include "qgsfilterrestorer.h"
#include "qgsaccesscontrol.h"
#endif
#include <QMimeDatabase>
@ -202,69 +203,71 @@ void QgsWfs3AbstractItemsHandler::checkLayerIsAccessible( const QgsVectorLayer *
}
}
QgsFeatureRequest QgsWfs3AbstractItemsHandler::filteredRequest( const QgsMapLayer *layer, const QgsServerApiContext &context ) const
QgsFeatureRequest QgsWfs3AbstractItemsHandler::filteredRequest( const QgsVectorLayer *vLayer, const QgsServerApiContext &context ) const
{
QgsFeatureRequest featureRequest;
QgsExpressionContext expressionContext;
expressionContext << QgsExpressionContextUtils::globalScope()
<< QgsExpressionContextUtils::projectScope( context.project() )
<< QgsExpressionContextUtils::layerScope( layer );
<< QgsExpressionContextUtils::layerScope( vLayer );
featureRequest.setExpressionContext( expressionContext );
//is there alias info for this vector layer?
const QgsVectorLayer *vLayer = static_cast<const QgsVectorLayer *>( layer );
QMap< int, QString > layerAliasInfo;
const QgsStringMap aliasMap = vLayer->attributeAliases();
for ( const auto &aliasKey : aliasMap.keys() )
{
int attrIndex = vLayer->fields().lookupField( aliasKey );
if ( attrIndex != -1 )
{
layerAliasInfo.insert( attrIndex, aliasMap.value( aliasKey ) );
}
}
QgsAttributeList attrIndexes = vLayer->attributeList();
// Removed attributes
//excluded attributes for this layer
const QSet<QString> &layerExcludedAttributes = vLayer->excludeAttributesWfs();
if ( !attrIndexes.isEmpty() && !layerExcludedAttributes.isEmpty() )
{
const QgsFields &fields = vLayer->fields();
for ( const QString &excludedAttribute : layerExcludedAttributes )
{
int fieldNameIdx = fields.indexOf( excludedAttribute );
if ( fieldNameIdx > -1 && attrIndexes.contains( fieldNameIdx ) )
{
attrIndexes.removeOne( fieldNameIdx );
}
}
}
featureRequest.setSubsetOfAttributes( attrIndexes );
#ifdef HAVE_SERVER_PYTHON_PLUGINS
// Python plugins can make further modifications to the allowed attributes
QgsAccessControl *accessControl = context.serverInterface()->accessControls();
if ( accessControl )
{
accessControl->filterFeatures( vLayer, featureRequest );
QStringList attributes = QStringList();
for ( int idx : attrIndexes )
{
attributes.append( vLayer->fields().field( idx ).name() );
}
featureRequest.setSubsetOfAttributes(
accessControl->layerAttributes( vLayer, attributes ),
vLayer->fields() );
}
#endif
QSet<QString> publishedAttrs;
const QgsFields constFields { publishedFields( vLayer, context ) };
for ( const QgsField &f : constFields )
{
publishedAttrs.insert( f.name() );
}
featureRequest.setSubsetOfAttributes( publishedAttrs, vLayer->fields() );
return featureRequest;
}
QgsFields QgsWfs3AbstractItemsHandler::publishedFields( const QgsVectorLayer *vLayer, const QgsServerApiContext &context ) const
{
QStringList publishedAttributes = QStringList();
// Removed attributes
// WFS excluded attributes for this layer
const QSet<QString> &layerExcludedAttributes = vLayer->excludeAttributesWfs();
const QgsFields &fields = vLayer->fields();
for ( int i = 0; i < fields.count(); ++i )
{
if ( ! layerExcludedAttributes.contains( fields.at( i ).name() ) )
{
publishedAttributes.push_back( fields.at( i ).name() );
}
}
#ifdef HAVE_SERVER_PYTHON_PLUGINS
// Python plugins can make further modifications to the allowed attributes
QgsAccessControl *accessControl = context.serverInterface()->accessControls();
if ( accessControl )
{
publishedAttributes = accessControl->layerAttributes( vLayer, publishedAttributes );
}
#endif
QgsFields publishedFields;
for ( int i = 0; i < fields.count(); ++i )
{
if ( publishedAttributes.contains( fields.at( i ).name() ) )
{
publishedFields.append( fields.at( i ) );
}
}
return publishedFields;
}
QgsWfs3LandingPageHandler::QgsWfs3LandingPageHandler()
{
}
@ -765,7 +768,8 @@ QList<QgsServerQueryStringParameter> QgsWfs3CollectionsItemsHandler::parameters(
} );
offset.setDescription( QStringLiteral( "Offset for features to retrieve [0-%1]" ).arg( mapLayer->featureCount( ) ) );
offsetValidatorSet = true;
for ( const auto &p : fieldParameters( mapLayer ) )
const QList<QgsServerQueryStringParameter> constFieldParameters { fieldParameters( mapLayer, context ) };
for ( const auto &p : constFieldParameters )
{
params.push_back( p );
}
@ -845,7 +849,8 @@ json QgsWfs3CollectionsItemsHandler::schema( const QgsServerApiContext &context
}
};
for ( const auto &p : fieldParameters( mapLayer ) )
const QList<QgsServerQueryStringParameter> constFieldParameters { fieldParameters( mapLayer, context ) };
for ( const auto &p : constFieldParameters )
{
const std::string name { p.name().toStdString() };
parameters.push_back( p.data() );
@ -899,14 +904,15 @@ json QgsWfs3CollectionsItemsHandler::schema( const QgsServerApiContext &context
return data;
}
const QList<QgsServerQueryStringParameter> QgsWfs3CollectionsItemsHandler::fieldParameters( const QgsVectorLayer *mapLayer ) const
const QList<QgsServerQueryStringParameter> QgsWfs3CollectionsItemsHandler::fieldParameters( const QgsVectorLayer *mapLayer, const QgsServerApiContext &context ) const
{
QList<QgsServerQueryStringParameter> params;
if ( mapLayer )
{
const QgsFields constFields { QgsServerApiUtils::publishedFields( mapLayer ) };
const QgsFields constFields { publishedFields( mapLayer, context ) };
for ( const auto &f : constFields )
{
const QString fName { f.alias().isEmpty() ? f.name() : f.alias() };
QgsServerQueryStringParameter::Type t;
switch ( f.type() )
{
@ -922,8 +928,8 @@ const QList<QgsServerQueryStringParameter> QgsWfs3CollectionsItemsHandler::field
t = QgsServerQueryStringParameter::Type::String;
break;
}
QgsServerQueryStringParameter fieldParam { f.name(), false,
t, QStringLiteral( "Retrieve features filtered by: %1 (%2)" ).arg( f.name() )
QgsServerQueryStringParameter fieldParam { fName, false,
t, QStringLiteral( "Retrieve features filtered by: %1 (%2)" ).arg( fName )
.arg( QgsServerQueryStringParameter::typeName( t ) ) };
params.push_back( fieldParam );
}
@ -987,10 +993,11 @@ void QgsWfs3CollectionsItemsHandler::handleRequest( const QgsServerApiContext &c
// Attribute filters
QgsStringMap attrFilters;
const QgsFields constField { QgsServerApiUtils::publishedFields( mapLayer ) };
for ( const QgsField &f : constField )
const QgsFields constFields { publishedFields( mapLayer, context ) };
for ( const QgsField &f : constFields )
{
const QString val = params.value( f.name() ).toString() ;
const QString fName { f.alias().isEmpty() ? f.name() : f.alias() };
const QString val = params.value( fName ).toString() ;
if ( ! val.isEmpty() )
{
QString sanitized { QgsServerApiUtils::sanitizedFieldValue( val ) };
@ -998,7 +1005,7 @@ void QgsWfs3CollectionsItemsHandler::handleRequest( const QgsServerApiContext &c
{
throw QgsServerApiBadRequestException( QStringLiteral( "Invalid filter field value [%1=%2]" ).arg( f.name() ).arg( val ) );
}
attrFilters[f.name()] = sanitized;
attrFilters[fName] = sanitized;
}
}
@ -1018,18 +1025,22 @@ void QgsWfs3CollectionsItemsHandler::handleRequest( const QgsServerApiContext &c
}
// Inputs are valid, process request
QgsFeatureRequest req;
QgsFeatureRequest featureRequest = filteredRequest( mapLayer, context );
if ( ! filterRect.isNull() )
{
QgsCoordinateTransform ct( bboxCrs, mapLayer->crs(), context.project()->transformContext() );
ct.transform( filterRect );
req.setFilterRect( ct.transform( filterRect ) );
featureRequest.setFilterRect( ct.transform( filterRect ) );
}
QString filterExpression;
if ( ! attrFilters.isEmpty() )
{
QStringList expressions;
if ( featureRequest.filterExpression() && ! featureRequest.filterExpression()->expression().isEmpty() )
{
expressions.push_back( featureRequest.filterExpression()->expression() );
}
for ( auto it = attrFilters.constBegin(); it != attrFilters.constEnd(); it++ )
{
// Handle star
@ -1045,17 +1056,19 @@ void QgsWfs3CollectionsItemsHandler::handleRequest( const QgsServerApiContext &c
}
}
filterExpression = expressions.join( QStringLiteral( " AND " ) );
req.setFilterExpression( filterExpression );
featureRequest.setFilterExpression( filterExpression );
}
// WFS3 core specs only serves 4326
req.setDestinationCrs( crs, context.project()->transformContext() );
// Add offset to limit because paging is not supported from QgsFeatureRequest
req.setLimit( limit + offset );
featureRequest.setDestinationCrs( crs, context.project()->transformContext() );
// Add offset to limit because paging is not supported by QgsFeatureRequest
featureRequest.setLimit( limit + offset );
QgsJsonExporter exporter { mapLayer };
exporter.setAttributes( featureRequest.subsetOfAttributes() );
exporter.setAttributeDisplayName( true );
exporter.setSourceCrs( mapLayer->crs() );
QgsFeatureList featureList;
QgsFeatureIterator features { mapLayer->getFeatures( req ) };
QgsFeatureIterator features { mapLayer->getFeatures( featureRequest ) };
QgsFeature feat;
long i { 0 };
while ( features.nextFeature( feat ) )
@ -1076,11 +1089,11 @@ void QgsWfs3CollectionsItemsHandler::handleRequest( const QgsServerApiContext &c
{
if ( filterExpression.isEmpty() )
{
req.setNoAttributes();
featureRequest.setNoAttributes();
}
req.setFlags( QgsFeatureRequest::Flag::NoGeometry );
req.setLimit( -1 );
features = mapLayer->getFeatures( req );
featureRequest.setFlags( QgsFeatureRequest::Flag::NoGeometry );
featureRequest.setLimit( -1 );
features = mapLayer->getFeatures( featureRequest );
while ( features.nextFeature( feat ) )
{
matchedFeaturesCount++;
@ -1191,7 +1204,6 @@ void QgsWfs3CollectionsFeatureHandler::handleRequest( const QgsServerApiContext
if ( context.request()->method() == QgsServerRequest::Method::GetMethod )
{
const QString featureId { match.captured( QStringLiteral( "featureId" ) ) };
QgsJsonExporter exporter { mapLayer };
#ifdef HAVE_SERVER_PYTHON_PLUGINS
QgsAccessControl *accessControl = context.serverInterface()->accessControls();
@ -1213,6 +1225,9 @@ void QgsWfs3CollectionsFeatureHandler::handleRequest( const QgsServerApiContext
QgsServerApiInternalServerError( QStringLiteral( "Invalid feature [%1]" ).arg( featureId ) );
}
QgsJsonExporter exporter { mapLayer };
exporter.setAttributes( featureRequest.subsetOfAttributes() );
exporter.setAttributeDisplayName( true );
json data = exporter.exportFeatureToJsonObject( feature );
data["links"] = links( context );
json navigation = json::array();

View File

@ -19,6 +19,7 @@
#define QGS_WFS3_HANDLERS_H
#include "qgsserverogcapihandler.h"
#include "qgsfields.h"
class QgsFeatureRequest;
class QgsServerOgcApi;
@ -42,7 +43,22 @@ class QgsWfs3AbstractItemsHandler: public QgsServerOgcApiHandler
*/
void checkLayerIsAccessible( const QgsVectorLayer *layer, const QgsServerApiContext &context ) const;
QgsFeatureRequest filteredRequest( const QgsMapLayer *layer, const QgsServerApiContext &context ) const;
/**
* Creates a filtered QgsFeatureRequest containing only fields published for WMS and plugin filters applied.
* \param layer the vector layer
* \param context the server api context
* \return QgsFeatureRequest with filters applied
*/
QgsFeatureRequest filteredRequest( const QgsVectorLayer *layer, const QgsServerApiContext &context ) const;
/**
* Returns a filtered list of fields containing only fields published for WMS and plugin filters applied.
* @param layer the vector layer
* @param context the server api context
* @return QgsFields list with filters applied
*/
QgsFields publishedFields( const QgsVectorLayer *layer, const QgsServerApiContext &context ) const;
};
/**
@ -224,7 +240,7 @@ class QgsWfs3CollectionsItemsHandler: public QgsWfs3AbstractItemsHandler
private:
// Retrieve the fields filter parameters
const QList<QgsServerQueryStringParameter> fieldParameters( const QgsVectorLayer *mapLayer ) const;
const QList<QgsServerQueryStringParameter> fieldParameters( const QgsVectorLayer *mapLayer, const QgsServerApiContext &context ) const;
};

View File

@ -118,7 +118,7 @@ class QgsServerAPITestBase(QgsServerTestBase):
""" QGIS API server tests"""
# Set to True in child classes to re-generate reference files for this class
regeregenerate_api_reference = True
regeregenerate_api_reference = False
def dump(self, response):
"""Returns the response body as str"""
@ -417,17 +417,26 @@ class QgsServerAPITest(QgsServerAPITestBase):
request = QgsBufferServerRequest('http://server.qgis.org/wfs3/collections/testlayer3/items?name=two')
self.server.handleRequest(request, response, project)
self.assertEqual(response.statusCode(), 404) # Not found
request = QgsBufferServerRequest('http://server.qgis.org/wfs3/collections/layer_with_short_name/items?name=two')
request = QgsBufferServerRequest('http://server.qgis.org/wfs3/collections/layer1_with_short_name/items?name=two')
self.server.handleRequest(request, response, project)
self.assertEqual(response.statusCode(), 200)
self.compareApi(request, project, 'test_wfs3_collections_items_testlayer_with_short_name_eq_two.json')
self.compareApi(request, project, 'test_wfs3_collections_items_layer1_with_short_name_eq_two.json')
def test_wfs3_field_filters_star(self):
"""Test field filters"""
project = QgsProject()
project.read(unitTestDataPath('qgis_server') + '/test_project_api.qgs')
request = QgsBufferServerRequest('http://server.qgis.org/wfs3/collections/testlayer_with_short_name/items?name=tw*')
self.compareApi(request, project, 'test_wfs3_collections_items_testlayer_with_short_name_eq_two_star.json')
request = QgsBufferServerRequest('http://server.qgis.org/wfs3/collections/layer1_with_short_name/items?name=tw*')
response = self.compareApi(request, project, 'test_wfs3_collections_items_layer1_with_short_name_eq_tw_star.json')
self.assertEqual(response.statusCode(), 200)
def test_wfs3_excluded_attributes(self):
"""Test excluded attributes"""
project = QgsProject()
project.read(unitTestDataPath('qgis_server') + '/test_project_api.qgs')
request = QgsBufferServerRequest('http://server.qgis.org/wfs3/collections/exclude_attribute/items/0.geojson')
response = self.compareApi(request, project, 'test_wfs3_collections_items_exclude_attribute_0.json')
self.assertEqual(response.statusCode(), 200)
class Handler1(QgsServerOgcApiHandler):

View File

@ -617,17 +617,6 @@ Content-Type: application/openapi+json;version=3.0
},
"style": "form"
},
{
"description": "Filter the collection by 'name'",
"explode": false,
"in": "query",
"name": "name",
"required": false,
"schema": {
"type": "string"
},
"style": "form"
},
{
"description": "Filter the collection by 'utf8nameè'",
"explode": false,
@ -704,10 +693,10 @@ Content-Type: application/openapi+json;version=3.0
}
],
{
"description": "Filter the collection by 'id'",
"description": "Filter the collection by 'alias_id'",
"explode": false,
"in": "query",
"name": "id",
"name": "alias_id",
"required": false,
"schema": {
"type": "integer"
@ -715,10 +704,10 @@ Content-Type: application/openapi+json;version=3.0
"style": "form"
},
{
"description": "Filter the collection by 'name'",
"description": "Filter the collection by 'alias_name'",
"explode": false,
"in": "query",
"name": "name",
"name": "alias_name",
"required": false,
"schema": {
"type": "string"
@ -1438,5 +1427,5 @@ Content-Type: application/openapi+json;version=3.0
"name": "Features"
}
],
"timeStamp": "2019-09-08T18:57:09Z"
"timeStamp": "2019-09-10T18:18:14Z"
}

View File

@ -19,5 +19,5 @@ Content-Type: application/json
"type": "text/html"
}
],
"timeStamp": "2019-09-08T18:57:10Z"
"timeStamp": "2019-09-10T18:18:15Z"
}

View File

@ -0,0 +1,32 @@
Content-Type: application/geo+json
{
"geometry": {
"coordinates": [
8.203496,
44.901483
],
"type": "Point"
},
"id": 0,
"links": [
{
"href": "http://server.qgis.org/wfs3/collections/exclude_attribute/items/0.geojson",
"rel": "self",
"title": "Retrieve a feature as GEOJSON",
"type": "application/geo+json"
},
{
"href": "http://server.qgis.org/wfs3/collections/exclude_attribute/items/0.html",
"rel": "alternate",
"title": "Retrieve a feature as HTML",
"type": "text/html"
}
],
"properties": {
"id": 1,
"utf8nameè": "one èé"
},
"timeStamp": "2019-09-10T18:18:16Z",
"type": "Feature"
}

View File

@ -0,0 +1,40 @@
Content-Type: application/geo+json
{
"features": [
{
"geometry": {
"coordinates": [
8.203547,
44.901436
],
"type": "Point"
},
"id": 1,
"properties": {
"id": 2,
"name": "two",
"utf8nameè": "two àò"
},
"type": "Feature"
}
],
"links": [
{
"href": "http://server.qgis.org/wfs3/collections/layer1_with_short_name/items.geojson?name=tw*",
"rel": "self",
"title": "Retrieve the features of the collection as GEOJSON",
"type": "application/geo+json"
},
{
"href": "http://server.qgis.org/wfs3/collections/layer1_with_short_name/items.html?name=tw*",
"rel": "alternate",
"title": "Retrieve the features of the collection as HTML",
"type": "text/html"
}
],
"numberMatched": 1,
"numberReturned": 1,
"timeStamp": "2019-09-10T18:18:16Z",
"type": "FeatureCollection"
}

View File

@ -0,0 +1,40 @@
Content-Type: application/geo+json
{
"features": [
{
"geometry": {
"coordinates": [
8.203547,
44.901436
],
"type": "Point"
},
"id": 1,
"properties": {
"id": 2,
"name": "two",
"utf8nameè": "two àò"
},
"type": "Feature"
}
],
"links": [
{
"href": "http://server.qgis.org/wfs3/collections/layer1_with_short_name/items.geojson?name=two",
"rel": "self",
"title": "Retrieve the features of the collection as GEOJSON",
"type": "application/geo+json"
},
{
"href": "http://server.qgis.org/wfs3/collections/layer1_with_short_name/items.html?name=two",
"rel": "alternate",
"title": "Retrieve the features of the collection as HTML",
"type": "text/html"
}
],
"numberMatched": 1,
"numberReturned": 1,
"timeStamp": "2019-09-10T18:18:16Z",
"type": "FeatureCollection"
}

View File

@ -1,3 +0,0 @@
Content-Type: application/json
[{"code":"API not found error","description":"Collection with given id (testlayer_with_short_name) was not found or multiple matches were found"}]

View File

@ -67,6 +67,6 @@ Content-Type: application/geo+json
],
"numberMatched": 3,
"numberReturned": 3,
"timeStamp": "2019-09-08T18:57:09Z",
"timeStamp": "2019-09-10T18:18:14Z",
"type": "FeatureCollection"
}

View File

@ -35,6 +35,6 @@ Content-Type: application/geo+json
],
"numberMatched": 1,
"numberReturned": 1,
"timeStamp": "2019-09-08T18:57:09Z",
"timeStamp": "2019-09-10T18:18:15Z",
"type": "FeatureCollection"
}

View File

@ -51,6 +51,6 @@ Content-Type: application/geo+json
],
"numberMatched": 2,
"numberReturned": 2,
"timeStamp": "2019-09-08T18:57:09Z",
"timeStamp": "2019-09-10T18:18:15Z",
"type": "FeatureCollection"
}

View File

@ -67,6 +67,6 @@ Content-Type: application/geo+json
],
"numberMatched": 3,
"numberReturned": 3,
"timeStamp": "2019-09-08T18:57:09Z",
"timeStamp": "2019-09-10T18:18:15Z",
"type": "FeatureCollection"
}

View File

@ -42,6 +42,6 @@ Content-Type: application/geo+json
],
"numberMatched": 3,
"numberReturned": 1,
"timeStamp": "2019-09-08T18:57:10Z",
"timeStamp": "2019-09-10T18:18:15Z",
"type": "FeatureCollection"
}

View File

@ -49,6 +49,6 @@ Content-Type: application/geo+json
],
"numberMatched": 3,
"numberReturned": 1,
"timeStamp": "2019-09-08T18:57:10Z",
"timeStamp": "2019-09-10T18:18:15Z",
"type": "FeatureCollection"
}

View File

@ -162,5 +162,5 @@ Content-Type: application/json
"type": "text/html"
}
],
"timeStamp": "2019-09-08T18:57:10Z"
"timeStamp": "2019-09-10T18:18:16Z"
}

View File

@ -22,5 +22,5 @@ Content-Type: application/json
"type": "text/html"
}
],
"timeStamp": "2019-09-08T18:57:10Z"
"timeStamp": "2019-09-10T18:18:16Z"
}

View File

@ -33,5 +33,5 @@ Content-Type: application/json
"type": "application/openapi+json;version=3.0"
}
],
"timeStamp": "2019-09-08T18:57:11Z"
"timeStamp": "2019-09-10T18:18:17Z"
}