mirror of
https://github.com/qgis/QGIS.git
synced 2025-12-15 00:07:25 -05:00
Server WFS3 simple transactions
Preliminary specs: https://github.com/opengeospatial/ogcapi-features/blob/master/extensions/transactions/TX_Notes.adoc
This commit is contained in:
parent
956c468935
commit
814d5be0ff
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -106,6 +106,10 @@ QgsFcgiServerRequest::QgsFcgiServerRequest()
|
||||
{
|
||||
method = HeadMethod;
|
||||
}
|
||||
else if ( strcmp( me, "PATCH" ) == 0 )
|
||||
{
|
||||
method = PatchMethod;
|
||||
}
|
||||
}
|
||||
|
||||
if ( method == PostMethod || method == PutMethod )
|
||||
|
||||
@ -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 )
|
||||
|
||||
@ -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
|
||||
|
||||
/**
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 );
|
||||
|
||||
@ -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
|
||||
*
|
||||
|
||||
@ -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() ) ) );
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user