Server WFS3 simple transactions

Preliminary specs:
https://github.com/opengeospatial/ogcapi-features/blob/master/extensions/transactions/TX_Notes.adoc
This commit is contained in:
Alessandro Pasotti 2019-11-06 19:05:55 +01:00
parent 956c468935
commit 814d5be0ff
10 changed files with 307 additions and 55 deletions

View File

@ -115,6 +115,22 @@ This method takes into account the ACL restrictions provided by QGIS Server Acce
%End
static const QVector<QgsMapLayer *> editableWfsLayers( const QgsProject *project, QgsServerRequest::Method &method );
%Docstring
Returns the list of layers accessible to the service in edit mode for a given ``project``.
:param method: represents the operation to be performed (POST, PUT, PATCH, DELETE)
This method takes into account the ACL restrictions provided by QGIS Server Access Control plugins.
.. note::
project must not be NULL
.. versionadded:: 3.12
%End
static QString sanitizedFieldValue( const QString &value );
%Docstring
Sanitizes the input ``value`` by removing URL encoding and checking for malicious content.

View File

@ -16,6 +16,9 @@ class QgsServerRequest
%TypeHeaderCode
#include "qgsserverrequest.h"
%End
public:
static const QMetaObject staticMetaObject;
public:
typedef QMap<QString, QString> Parameters;
@ -27,7 +30,8 @@ class QgsServerRequest
PutMethod,
GetMethod,
PostMethod,
DeleteMethod
DeleteMethod,
PatchMethod
};
@ -56,6 +60,14 @@ Constructor
virtual ~QgsServerRequest();
static QString methodToString( const Method &method );
%Docstring
methodToString returns a string representation of an HTTP request ``method``
.. versionadded:: 3.12
%End
QUrl url() const;
%Docstring

View File

@ -106,6 +106,10 @@ QgsFcgiServerRequest::QgsFcgiServerRequest()
{
method = HeadMethod;
}
else if ( strcmp( me, "PATCH" ) == 0 )
{
method = PatchMethod;
}
}
if ( method == PostMethod || method == PutMethod )

View File

@ -189,70 +189,77 @@ void QgsRequestHandler::setupParameters()
void QgsRequestHandler::parseInput()
{
if ( mRequest.method() == QgsServerRequest::PostMethod )
if ( mRequest.method() == QgsServerRequest::PostMethod ||
mRequest.method() == QgsServerRequest::PutMethod ||
mRequest.method() == QgsServerRequest::PatchMethod )
{
QString inputString( mRequest.data() );
QDomDocument doc;
QString errorMsg;
int line = -1;
int column = -1;
if ( !doc.setContent( inputString, true, &errorMsg, &line, &column ) )
if ( mRequest.header( QStringLiteral( "Content-Type" ) ).contains( QStringLiteral( "json" ) ) )
{
// XXX Output error but continue processing request ?
QgsMessageLog::logMessage( QStringLiteral( "Warning: error parsing post data as XML: at line %1, column %2: %3. Assuming urlencoded query string sent in the post body." )
.arg( line ).arg( column ).arg( errorMsg ) );
// Process input string as a simple query text
typedef QPair<QString, QString> pair_t;
QUrlQuery query( inputString );
QList<pair_t> items = query.queryItems();
for ( pair_t pair : items )
{
// QUrl::fromPercentEncoding doesn't replace '+' with space
const QString key = QUrl::fromPercentEncoding( pair.first.replace( '+', ' ' ).toUtf8() );
const QString value = QUrl::fromPercentEncoding( pair.second.replace( '+', ' ' ).toUtf8() );
mRequest.setParameter( key.toUpper(), value );
}
setupParameters();
}
else
{
// we have an XML document
setupParameters();
QDomElement docElem = doc.documentElement();
// the document element tag name is the request
mRequest.setParameter( QStringLiteral( "REQUEST" ), docElem.tagName() );
// loop through the attributes which are the parameters
// excepting the attributes started by xmlns or xsi
QDomNamedNodeMap map = docElem.attributes();
for ( int i = 0 ; i < map.length() ; ++i )
QString inputString( mRequest.data() );
QDomDocument doc;
QString errorMsg;
int line = -1;
int column = -1;
if ( !doc.setContent( inputString, true, &errorMsg, &line, &column ) )
{
if ( map.item( i ).isNull() )
continue;
// XXX Output error but continue processing request ?
QgsMessageLog::logMessage( QStringLiteral( "Warning: error parsing post data as XML: at line %1, column %2: %3. Assuming urlencoded query string sent in the post body." )
.arg( line ).arg( column ).arg( errorMsg ) );
const QDomNode attrNode = map.item( i );
const QDomAttr attr = attrNode.toAttr();
if ( attr.isNull() )
continue;
// Process input string as a simple query text
const QString attrName = attr.name();
if ( attrName.startsWith( "xmlns" ) || attrName.startsWith( "xsi:" ) )
continue;
mRequest.setParameter( attrName.toUpper(), attr.value() );
typedef QPair<QString, QString> pair_t;
QUrlQuery query( inputString );
QList<pair_t> items = query.queryItems();
for ( pair_t pair : items )
{
// QUrl::fromPercentEncoding doesn't replace '+' with space
const QString key = QUrl::fromPercentEncoding( pair.first.replace( '+', ' ' ).toUtf8() );
const QString value = QUrl::fromPercentEncoding( pair.second.replace( '+', ' ' ).toUtf8() );
mRequest.setParameter( key.toUpper(), value );
}
setupParameters();
}
else
{
// we have an XML document
setupParameters();
QDomElement docElem = doc.documentElement();
// the document element tag name is the request
mRequest.setParameter( QStringLiteral( "REQUEST" ), docElem.tagName() );
// loop through the attributes which are the parameters
// excepting the attributes started by xmlns or xsi
QDomNamedNodeMap map = docElem.attributes();
for ( int i = 0 ; i < map.length() ; ++i )
{
if ( map.item( i ).isNull() )
continue;
const QDomNode attrNode = map.item( i );
const QDomAttr attr = attrNode.toAttr();
if ( attr.isNull() )
continue;
const QString attrName = attr.name();
if ( attrName.startsWith( "xmlns" ) || attrName.startsWith( "xsi:" ) )
continue;
mRequest.setParameter( attrName.toUpper(), attr.value() );
}
mRequest.setParameter( QStringLiteral( "REQUEST_BODY" ), inputString );
}
mRequest.setParameter( QStringLiteral( "REQUEST_BODY" ), inputString );
}
}
else
{
setupParameters();
}
}
void QgsRequestHandler::setParameter( const QString &key, const QString &value )

View File

@ -201,6 +201,60 @@ class SERVER_EXPORT QgsServerApiUtils
return result;
}
#endif
/**
* Returns the list of layers accessible to the service in edit mode for a given \a project.
* \param method represents the operation to be performed (POST, PUT, PATCH, DELETE)
*
* This method takes into account the ACL restrictions provided by QGIS Server Access Control plugins.
*
* \note project must not be NULL
* \since QGIS 3.12
*/
static const QVector<QgsMapLayer *> editableWfsLayers( const QgsProject *project, QgsServerRequest::Method &method );
#ifndef SIP_RUN
/**
* Returns the list of layers of type T accessible to the WFS service for a given \a project.
*
* Example:
*
* QVector<QgsVectorLayer*> vectorLayers = publishedLayers<QgsVectorLayer>();
*
* \note not available in Python bindings
*/
template <typename T>
static const QVector<const T *> editableWfsLayers( const QgsServerApiContext &context, QgsServerRequest::Method &method )
{
#ifdef HAVE_SERVER_PYTHON_PLUGINS
QgsAccessControl *accessControl = context.serverInterface()->accessControls();
#endif
const QgsProject *project = context.project();
QVector<const T *> result;
if ( project )
{
const QStringList wfsLayerIds = QgsServerProjectUtils::wfsLayerIds( *project );
const auto constLayers { project->layers<T *>() };
for ( const auto &layer : constLayers )
{
if ( ! wfsLayerIds.contains( layer->id() ) )
{
continue;
}
#ifdef HAVE_SERVER_PYTHON_PLUGINS
if ( accessControl && !accessControl->layerInsertPermission( layer ) )
{
continue;
}
#endif
result.push_back( layer );
}
}
return result;
}
#endif
/**

View File

@ -251,6 +251,27 @@ class SERVER_EXPORT QgsServerApiBadRequestException: public QgsServerApiExceptio
}
};
/**
* \ingroup server
* \class QgsServerApiPermissionDeniedException
* \brief Forbidden (permission denied) 403
*
* Note that this exception is associated with a default return code 403 which may be
* not appropriate in some situations.
*
* \since QGIS 3.12
*/
class SERVER_EXPORT QgsServerApiPermissionDeniedException: public QgsServerApiException
{
public:
//! Construction
QgsServerApiPermissionDeniedException( const QString &message, const QString &mimeType = QStringLiteral( "application/json" ), int responseCode = 403 )
: QgsServerApiException( QStringLiteral( "Forbidden" ), message, mimeType, responseCode )
{
}
};
/**
* \ingroup server
* \class QgsServerApiImproperlyConfiguredException

View File

@ -34,6 +34,12 @@ QgsServerRequest::QgsServerRequest( const QUrl &url, Method method, const Header
mParams.load( QUrlQuery( url ) );
}
QString QgsServerRequest::methodToString( const QgsServerRequest::Method &method )
{
static QMetaEnum metaEnum = QMetaEnum::fromType<QgsServerRequest::Method>();
return QString( metaEnum.valueToKey( method ) ).remove( QStringLiteral( "Method" ) ).toUpper( );
}
QString QgsServerRequest::header( const QString &name ) const
{
return mHeaders.value( name );

View File

@ -37,6 +37,8 @@
class SERVER_EXPORT QgsServerRequest
{
Q_GADGET
public:
typedef QMap<QString, QString> Parameters;
@ -51,8 +53,10 @@ class SERVER_EXPORT QgsServerRequest
PutMethod,
GetMethod,
PostMethod,
DeleteMethod
DeleteMethod,
PatchMethod
};
Q_ENUM( Method )
/**
@ -81,6 +85,13 @@ class SERVER_EXPORT QgsServerRequest
//! destructor
virtual ~QgsServerRequest() = default;
/**
* methodToString returns a string representation of an HTTP request \a method
* \since QGIS 3.12
*/
static QString methodToString( const Method &method );
/**
* \returns the request url as seen by QGIS server
*

View File

@ -23,6 +23,7 @@
#include "qgsserverapiutils.h"
#include "qgsfeaturerequest.h"
#include "qgsjsonutils.h"
#include "qgsogrutils.h"
#include "qgsvectorlayer.h"
#include "qgsmessagelog.h"
#include "qgsbufferserverrequest.h"
@ -441,10 +442,12 @@ QgsWfs3CollectionsHandler::QgsWfs3CollectionsHandler()
void QgsWfs3CollectionsHandler::handleRequest( const QgsServerApiContext &context ) const
{
json crss = json::array();
for ( const QString &crs : QgsServerApiUtils::publishedCrsList( context.project() ) )
{
crss.push_back( crs.toStdString() );
}
json data
{
{
@ -455,9 +458,9 @@ void QgsWfs3CollectionsHandler::handleRequest( const QgsServerApiContext &contex
"crs", crss
}
};
if ( context.project() )
{
const QgsProject *project = context.project();
const QStringList wfsLayerIds = QgsServerProjectUtils::wfsLayerIds( *project );
for ( const QString &wfsLayerId : wfsLayerIds )
@ -1309,9 +1312,78 @@ void QgsWfs3CollectionsItemsHandler::handleRequest( const QgsServerApiContext &c
};
write( data, context, htmlMetadata );
}
else if ( context.request()->method() == QgsServerRequest::Method::PostMethod )
{
// First: check permissions
const QStringList wfstInsertLayerIds = QgsServerProjectUtils::wfstInsertLayerIds( *context.project() );
if ( ! wfstInsertLayerIds.contains( mapLayer->id() ) || ! mapLayer->dataProvider()->capabilities().testFlag( QgsVectorDataProvider::Capability::AddFeatures ) )
{
throw QgsServerApiPermissionDeniedException( QStringLiteral( "Layer %1 is not editable" ).arg( mapLayer->name() ) );
}
#ifdef HAVE_SERVER_PYTHON_PLUGINS
// get access controls
QgsAccessControl *accessControl = context.serverInterface()->accessControls();
if ( accessControl && !accessControl->layerInsertPermission( mapLayer ) )
{
throw QgsServerApiPermissionDeniedException( QStringLiteral( "No ACL permissions to insert features on layer '%1'" ).arg( mapLayer->name() ) );
}
//scoped pointer to restore all original layer filters (subsetStrings) when pointer goes out of scope
//there's LOTS of potential exit paths here, so we avoid having to restore the filters manually
std::unique_ptr< QgsOWSServerFilterRestorer > filterRestorer( new QgsOWSServerFilterRestorer() );
if ( accessControl )
{
QgsOWSServerFilterRestorer::applyAccessControlLayerFilters( accessControl, mapLayer, filterRestorer->originalFilters() );
}
#endif
try
{
// Parse
json postData = json::parse( context.request()->data() );
// Process data
const QgsFeatureList features = QgsOgrUtils::stringToFeatureList( context.request()->data(), mapLayer->fields(), QTextCodec::codecForName( "UTF-8" ) );
if ( features.isEmpty() )
{
throw QgsServerApiBadRequestException( QStringLiteral( "Posted body contains no feature" ) );
}
QgsFeature feat = features.first();
if ( ! feat.isValid() )
{
throw QgsServerApiInternalServerError( QStringLiteral( "Feature is not valid" ) );
}
// Make sure the first field (id) is null for shapefiles
if ( mapLayer->providerType() == QLatin1String( "ogr" ) && mapLayer->storageType() == QLatin1String( "ESRI Shapefile" ) )
{
feat.setAttribute( 0, QVariant() );
}
feat.setId( FID_NULL );
// TODO: handle CRS
QgsFeatureList featuresToAdd;
featuresToAdd.append( feat );
if ( ! mapLayer->dataProvider()->addFeatures( featuresToAdd ) )
{
throw QgsServerApiInternalServerError( QStringLiteral( "Error adding feature to collection" ) );
}
feat = featuresToAdd.first();
// Send response
context.response()->setStatusCode( 201 );
context.response()->setHeader( QStringLiteral( "Content-Type" ), QStringLiteral( "application/geo+json" ) );
QString url { context.request()->url().toString() };
if ( ! url.endsWith( '/' ) )
{
url.append( '/' );
}
context.response()->setHeader( QStringLiteral( "Location" ), url + QString::number( feat.id() ) );
context.response()->write( "\"string\"" );
}
catch ( json::exception &ex )
{
throw QgsServerApiBadRequestException( QStringLiteral( "JSON parse error: %1" ).arg( ex.what( ) ) );
}
}
else
{
throw QgsServerApiNotImplementedException( QStringLiteral( "Only GET method is implemented." ) );
throw QgsServerApiNotImplementedException( QStringLiteral( "%1 method is not implemented." )
.arg( QgsServerRequest::methodToString( context.request()->method() ) ) );
}
}
@ -1344,11 +1416,11 @@ void QgsWfs3CollectionsFeatureHandler::handleRequest( const QgsServerApiContext
checkLayerIsAccessible( mapLayer, context );
const std::string title { mapLayer->title().isEmpty() ? mapLayer->name().toStdString() : mapLayer->title().toStdString() };
const QString featureId { match.captured( QStringLiteral( "featureId" ) ) };
// GET
if ( context.request()->method() == QgsServerRequest::Method::GetMethod )
{
const QString featureId { match.captured( QStringLiteral( "featureId" ) ) };
#ifdef HAVE_SERVER_PYTHON_PLUGINS
QgsAccessControl *accessControl = context.serverInterface()->accessControls();
//scoped pointer to restore all original layer filters (subsetStrings) when pointer goes out of scope
@ -1393,7 +1465,8 @@ void QgsWfs3CollectionsFeatureHandler::handleRequest( const QgsServerApiContext
}
else
{
throw QgsServerApiNotImplementedException( QStringLiteral( "Only GET method is implemented." ) );
throw QgsServerApiNotImplementedException( QStringLiteral( "%1 method is not implemented." )
.arg( QgsServerRequest::methodToString( context.request()->method() ) ) );
}
}

View File

@ -518,6 +518,54 @@ class QgsServerAPITest(QgsServerAPITestBase):
body = bytes(response.body()).decode('utf8')
self.assertEqual(body, '[{"code":"API not found error","description":"Static file does_not_exists.css was not found"}]')
def test_wfs3_collection_items_post(self):
"""Test WFS3 API items POST"""
project = QgsProject()
project.read(unitTestDataPath('qgis_server') + '/test_project_api.qgs')
# Invalid request
data = b'not json!'
request = QgsBufferServerRequest(
'http://server.qgis.org/wfs3/collections/testlayer%20èé/items',
QgsBufferServerRequest.PostMethod,
{'Content-Type': 'application/geo+json'},
data
)
response = QgsBufferServerResponse()
self.server.handleRequest(request, response, project)
self.assertEqual(response.statusCode(), 400)
self.assertTrue('[{"code":"Bad request error","description":"JSON parse error' in bytes(response.body()).decode('utf8'))
# Valid request
data = """{
"geometry": {
"coordinates": [
8.111,
44.55
],
"type": "Point"
},
"properties": {
"id": 123,
"name": "one + 123",
"utf8nameè": "one èé + 123"
},
"type": "Feature"
}""".encode('utf8')
request = QgsBufferServerRequest(
'http://server.qgis.org/wfs3/collections/testlayer%20èé/items',
QgsBufferServerRequest.PostMethod,
{'Content-Type': 'application/geo+json'},
data
)
response = QgsBufferServerResponse()
self.server.handleRequest(request, response, project)
self.assertEqual(response.statusCode(), 201)
self.assertEqual(response.body(), '"string"')
# Get last fid
fid = max(project.mapLayersByName('testlayer èé')[0].allFeatureIds())
self.assertEqual(response.headers()['Location'], 'http://server.qgis.org/wfs3/collections/testlayer èé/items/%s' % fid)
def test_wfs3_field_filters(self):
"""Test field filters"""
project = QgsProject()