Skip to content

Commit 2924f3e

Browse files
rouaultnyalldawson
authored andcommittedApr 18, 2023
[OAPIF provider] Add support for edition capabilities (OGC API Features Part 4)
- Issues OPTIONS on /collections/{coll_id} and collections/{coll_id}/{feature_id} endpoints to check server capabilities - Add support for addFeature() through POST /collections/{coll_id} endpoint - Add support for changeGeometryValues()/changeAttributeValues() through PUT /collections/{coll_id}/{feature_id} endpoint. Or through PATCH /collections/{coll_id}/{feature_id} with just the updated subset, if available - Add support for deleteFeatures() through DELETE /collections/{coll_id}/{feature_id} endpoint. - For POST/PUT/PATCH, takes into account Part 2 (CRS support) when implemented by the server.
1 parent efc0e5f commit 2924f3e

18 files changed

+1381
-46
lines changed
 

‎src/providers/wfs/CMakeLists.txt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,12 @@ set(WFS_SRCS
2727
oapif/qgsoapifapirequest.cpp
2828
oapif/qgsoapifcollection.cpp
2929
oapif/qgsoapifconformancerequest.cpp
30+
oapif/qgsoapifcreatefeaturerequest.cpp
31+
oapif/qgsoapifdeletefeaturerequest.cpp
32+
oapif/qgsoapifpatchfeaturerequest.cpp
33+
oapif/qgsoapifputfeaturerequest.cpp
3034
oapif/qgsoapifitemsrequest.cpp
35+
oapif/qgsoapifoptionsrequest.cpp
3136
oapif/qgsoapifprovider.cpp
3237
oapif/qgsoapifutils.cpp
3338
)
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
/***************************************************************************
2+
qgsoapifcreatefeaturerequest.cpp
3+
--------------------------------
4+
begin : March 2023
5+
copyright : (C) 2023 by Even Rouault
6+
email : even.rouault at spatialys.com
7+
***************************************************************************
8+
* *
9+
* This program is free software; you can redistribute it and/or modify *
10+
* it under the terms of the GNU General Public License as published by *
11+
* the Free Software Foundation; either version 2 of the License, or *
12+
* (at your option) any later version. *
13+
* *
14+
***************************************************************************/
15+
16+
#include <nlohmann/json.hpp>
17+
using namespace nlohmann;
18+
19+
#include "qgslogger.h"
20+
#include "qgsjsonutils.h"
21+
#include "qgsoapifcreatefeaturerequest.h"
22+
#include "qgsoapifprovider.h"
23+
24+
QgsOapifCreateFeatureRequest::QgsOapifCreateFeatureRequest( const QgsDataSourceUri &uri ):
25+
QgsBaseNetworkRequest( QgsAuthorizationSettings( uri.username(), uri.password(), uri.authConfigId() ), "OAPIF" )
26+
{
27+
}
28+
29+
QString QgsOapifCreateFeatureRequest::createFeature( const QgsOapifSharedData *sharedData, const QgsFeature &f, const QString &contentCrs, bool hasAxisInverted )
30+
{
31+
QgsJsonExporter exporter;
32+
33+
QgsFeature fModified( f );
34+
if ( hasAxisInverted && f.hasGeometry() )
35+
{
36+
QgsGeometry g = f.geometry();
37+
g.get()->swapXy();
38+
fModified.setGeometry( g );
39+
}
40+
41+
json j = exporter.exportFeatureToJsonObject( fModified );
42+
auto iterId = j.find( "id" );
43+
if ( iterId != j.end() )
44+
j.erase( iterId );
45+
auto iterBbox = j.find( "bbox" );
46+
if ( iterBbox != j.end() )
47+
j.erase( iterBbox );
48+
if ( !sharedData->mFoundIdInProperties && j["properties"].contains( "id" ) )
Code has comments. Press enter to view.
49+
j["properties"].erase( "id" );
50+
const QString jsonFeature = QString::fromStdString( j.dump() );
51+
QgsDebugMsgLevel( jsonFeature, 5 );
52+
mEmptyResponseIsValid = true;
53+
mFakeResponseHasHeaders = true;
54+
QList<QNetworkReply::RawHeaderPair> extraHeaders;
55+
if ( !contentCrs.isEmpty() )
56+
extraHeaders.append( QNetworkReply::RawHeaderPair( QByteArray( "Content-Crs" ), contentCrs.toUtf8() ) );
57+
if ( !sendPOST( sharedData->mItemsUrl, "application/geo+json", jsonFeature.toUtf8(), extraHeaders ) )
58+
return QString();
59+
60+
QString location;
61+
for ( const auto &headerKeyValue : mResponseHeaders )
62+
{
63+
if ( headerKeyValue.first == QByteArray( "Location" ) )
64+
{
65+
location = QString::fromUtf8( headerKeyValue.second );
66+
break;
67+
}
68+
}
69+
70+
const int posItems = location.lastIndexOf( QLatin1String( "/items/" ) );
71+
if ( posItems < 0 )
72+
return QString();
73+
74+
const QString createdId = location.mid( posItems + static_cast<int>( strlen( "/items/" ) ) );
75+
QgsDebugMsgLevel( "createdId = " + createdId, 5 );
76+
return createdId;
77+
}
78+
79+
QString QgsOapifCreateFeatureRequest::errorMessageWithReason( const QString &reason )
80+
{
81+
return tr( "Create Feature request failed: %1" ).arg( reason );
82+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
/***************************************************************************
2+
qgsoapifcreatefeaturerequest.h
3+
------------------------------
4+
begin : March 2023
5+
copyright : (C) 2023 by Even Rouault
6+
email : even.rouault at spatialys.com
7+
***************************************************************************
8+
* *
9+
* This program is free software; you can redistribute it and/or modify *
10+
* it under the terms of the GNU General Public License as published by *
11+
* the Free Software Foundation; either version 2 of the License, or *
12+
* (at your option) any later version. *
13+
* *
14+
***************************************************************************/
15+
16+
#ifndef QGSOAPIFCREATEFEATUREREQUEST_H
17+
#define QGSOAPIFCREATEFEATUREREQUEST_H
18+
19+
#include <QObject>
20+
21+
#include "qgsdatasourceuri.h"
22+
#include "qgsfeature.h"
23+
#include "qgsbasenetworkrequest.h"
24+
25+
class QgsOapifSharedData;
26+
27+
//! Manages the Create Feature request
28+
class QgsOapifCreateFeatureRequest : public QgsBaseNetworkRequest
29+
{
30+
Q_OBJECT
31+
public:
32+
explicit QgsOapifCreateFeatureRequest( const QgsDataSourceUri &uri );
33+
34+
//! Issue a POST request to create the feature and return its id
35+
QString createFeature( const QgsOapifSharedData *sharedData, const QgsFeature &f, const QString &contentCrs, bool hasAxisInverted );
36+
37+
protected:
38+
QString errorMessageWithReason( const QString &reason ) override;
39+
};
40+
41+
#endif // QGSOAPIFCREATEFEATUREREQUEST_H
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
/***************************************************************************
2+
qgsoapifdeletefeaturerequest.cpp
3+
--------------------------------
4+
begin : March 2023
5+
copyright : (C) 2023 by Even Rouault
6+
email : even.rouault at spatialys.com
7+
***************************************************************************
8+
* *
9+
* This program is free software; you can redistribute it and/or modify *
10+
* it under the terms of the GNU General Public License as published by *
11+
* the Free Software Foundation; either version 2 of the License, or *
12+
* (at your option) any later version. *
13+
* *
14+
***************************************************************************/
15+
16+
#include "qgslogger.h"
17+
#include "qgsoapifdeletefeaturerequest.h"
18+
19+
QgsOapifDeleteFeatureRequest::QgsOapifDeleteFeatureRequest( const QgsDataSourceUri &uri ):
20+
QgsBaseNetworkRequest( QgsAuthorizationSettings( uri.username(), uri.password(), uri.authConfigId() ), "OAPIF" )
21+
{
22+
}
23+
24+
QString QgsOapifDeleteFeatureRequest::errorMessageWithReason( const QString &reason )
25+
{
26+
return tr( "Delete Feature request failed: %1" ).arg( reason );
27+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/***************************************************************************
2+
qgsoapifdeletefeaturerequest.h
3+
------------------------------
4+
begin : March 2023
5+
copyright : (C) 2023 by Even Rouault
6+
email : even.rouault at spatialys.com
7+
***************************************************************************
8+
* *
9+
* This program is free software; you can redistribute it and/or modify *
10+
* it under the terms of the GNU General Public License as published by *
11+
* the Free Software Foundation; either version 2 of the License, or *
12+
* (at your option) any later version. *
13+
* *
14+
***************************************************************************/
15+
16+
#ifndef QGSOAPIFDELETEFEATUREREQUEST_H
17+
#define QGSOAPIFDELETEFEATUREREQUEST_H
18+
19+
#include <QObject>
20+
21+
#include "qgsdatasourceuri.h"
22+
#include "qgsbasenetworkrequest.h"
23+
24+
//! Manages the Delete Feature request
25+
class QgsOapifDeleteFeatureRequest : public QgsBaseNetworkRequest
26+
{
27+
Q_OBJECT
28+
public:
29+
explicit QgsOapifDeleteFeatureRequest( const QgsDataSourceUri &uri );
30+
31+
protected:
32+
QString errorMessageWithReason( const QString &reason ) override;
33+
};
34+
35+
#endif // QGSOAPIFDELETEFEATUREREQUEST_H

‎src/providers/wfs/oapif/qgsoapifitemsrequest.cpp

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@ void QgsOapifItemsRequest::processReply()
133133
if ( jFeature.is_object() && jFeature.contains( "id" ) )
134134
{
135135
const json id = jFeature["id"];
136+
mFoundIdTopLevel = true;
136137
if ( id.is_string() )
137138
{
138139
mFeatures[i].second = QString::fromStdString( id.get<std::string>() );
@@ -142,6 +143,14 @@ void QgsOapifItemsRequest::processReply()
142143
mFeatures[i].second = QString::number( id.get<qint64>() );
143144
}
144145
}
146+
if ( jFeature.is_object() && jFeature.contains( "properties" ) )
147+
{
148+
const json properties = jFeature["properties"];
149+
if ( properties.is_object() && properties.contains( "id" ) )
150+
{
151+
mFoundIdInProperties = true;
152+
}
153+
}
145154
}
146155
}
147156
}

‎src/providers/wfs/oapif/qgsoapifitemsrequest.h

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,12 @@ class QgsOapifItemsRequest : public QgsBaseNetworkRequest
6868
//! Return the url of the next page
6969
const QString &nextUrl() const { return mNextUrl; }
7070

71+
//! Return if an "id" is present at top level of features
72+
bool foundIdTopLevel() const { return mFoundIdTopLevel; }
73+
74+
//! Return if an "id" is present in the "properties" object of features
75+
bool foundIdInProperties() const { return mFoundIdInProperties; }
76+
7177
signals:
7278
//! emitted when the capabilities have been fully parsed, or an error occurred
7379
void gotResponse();
@@ -97,6 +103,9 @@ class QgsOapifItemsRequest : public QgsBaseNetworkRequest
97103

98104
ApplicationLevelError mAppLevelError = ApplicationLevelError::NoError;
99105

106+
bool mFoundIdTopLevel = false;
107+
108+
bool mFoundIdInProperties = false;
100109
};
101110

102111
#endif // QGSOAPIFITEMSREQUEST_H
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
/***************************************************************************
2+
qgsoapifoptionsrequest.cpp
3+
--------------------------
4+
begin : March 2023
5+
copyright : (C) 2023 by Even Rouault
6+
email : even.rouault at spatialys.com
7+
***************************************************************************
8+
* *
9+
* This program is free software; you can redistribute it and/or modify *
10+
* it under the terms of the GNU General Public License as published by *
11+
* the Free Software Foundation; either version 2 of the License, or *
12+
* (at your option) any later version. *
13+
* *
14+
***************************************************************************/
15+
16+
#include "qgsoapifoptionsrequest.h"
17+
18+
QgsOapifOptionsRequest::QgsOapifOptionsRequest( const QgsDataSourceUri &uri ):
19+
QgsBaseNetworkRequest( QgsAuthorizationSettings( uri.username(), uri.password(), uri.authConfigId() ), "OAPIF" )
20+
{
21+
}
22+
23+
QString QgsOapifOptionsRequest::errorMessageWithReason( const QString &reason )
24+
{
25+
return tr( "Download of OPTIONS failed: %1" ).arg( reason );
26+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/***************************************************************************
2+
qgsoapifoptionsrequest.h
3+
------------------------
4+
begin : March 2023
5+
copyright : (C) 2023 by Even Rouault
6+
email : even.rouault at spatialys.com
7+
***************************************************************************
8+
* *
9+
* This program is free software; you can redistribute it and/or modify *
10+
* it under the terms of the GNU General Public License as published by *
11+
* the Free Software Foundation; either version 2 of the License, or *
12+
* (at your option) any later version. *
13+
* *
14+
***************************************************************************/
15+
16+
#ifndef QGSOAPIFOPTIONSREQUEST_H
17+
#define QGSOAPIFOPTIONSREQUEST_H
18+
19+
#include <QObject>
20+
21+
#include "qgsdatasourceuri.h"
22+
#include "qgsbasenetworkrequest.h"
23+
24+
//! Manages the Options request
25+
class QgsOapifOptionsRequest : public QgsBaseNetworkRequest
26+
{
27+
Q_OBJECT
28+
public:
29+
explicit QgsOapifOptionsRequest( const QgsDataSourceUri &uri );
30+
31+
protected:
32+
QString errorMessageWithReason( const QString &reason ) override;
33+
};
34+
35+
#endif // QGSOAPIFOPTIONSREQUEST_H
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
/***************************************************************************
2+
qgsoapifpatchfeaturerequest.cpp
3+
-------------------------------
4+
begin : March 2023
5+
copyright : (C) 2023 by Even Rouault
6+
email : even.rouault at spatialys.com
7+
***************************************************************************
8+
* *
9+
* This program is free software; you can redistribute it and/or modify *
10+
* it under the terms of the GNU General Public License as published by *
11+
* the Free Software Foundation; either version 2 of the License, or *
12+
* (at your option) any later version. *
13+
* *
14+
***************************************************************************/
15+
16+
#include <nlohmann/json.hpp>
17+
using namespace nlohmann;
18+
19+
#include "qgsjsonutils.h"
20+
21+
#include "qgsoapifpatchfeaturerequest.h"
22+
#include "qgsoapifprovider.h"
23+
24+
QgsOapifPatchFeatureRequest::QgsOapifPatchFeatureRequest( const QgsDataSourceUri &uri ):
25+
QgsBaseNetworkRequest( QgsAuthorizationSettings( uri.username(), uri.password(), uri.authConfigId() ), "OAPIF" )
26+
{
27+
}
28+
29+
bool QgsOapifPatchFeatureRequest::patchFeature( const QgsOapifSharedData *sharedData, const QString &jsonId, const QgsGeometry &geom, const QString &contentCrs, bool hasAxisInverted )
30+
{
31+
QgsGeometry geomModified( geom );
32+
if ( hasAxisInverted )
33+
{
34+
geomModified.get()->swapXy();
35+
}
36+
37+
json j;
38+
j["geometry"] = geomModified.asJsonObject();
39+
QList<QNetworkReply::RawHeaderPair> extraHeaders;
40+
if ( !contentCrs.isEmpty() )
41+
extraHeaders.append( QNetworkReply::RawHeaderPair( QByteArray( "Content-Crs" ), contentCrs.toUtf8() ) );
42+
mEmptyResponseIsValid = true;
43+
mFakeURLIncludesContentType = true;
44+
QUrl url( sharedData->mItemsUrl + QString( QStringLiteral( "/" ) + jsonId ) );
45+
return sendPATCH( url, "application/merge-patch+json", QString::fromStdString( j.dump() ).toUtf8(), extraHeaders );
46+
}
47+
48+
bool QgsOapifPatchFeatureRequest::patchFeature( const QgsOapifSharedData *sharedData, const QString &jsonId, const QgsAttributeMap &attrMap )
49+
{
50+
json properties;
51+
QgsAttributeMap::const_iterator attMapIt = attrMap.constBegin();
52+
for ( ; attMapIt != attrMap.constEnd(); ++attMapIt )
53+
{
54+
QString fieldName = sharedData->mFields.at( attMapIt.key() ).name();
55+
properties[ fieldName.toStdString() ] = QgsJsonUtils::jsonFromVariant( attMapIt.value() );
56+
}
57+
json j;
58+
j[ "properties" ] = properties;
59+
mEmptyResponseIsValid = true;
60+
mFakeURLIncludesContentType = true;
61+
QUrl url( sharedData->mItemsUrl + QString( QStringLiteral( "/" ) + jsonId ) );
62+
return sendPATCH( url, "application/merge-patch+json", QString::fromStdString( j.dump() ).toUtf8() );
63+
}
64+
65+
QString QgsOapifPatchFeatureRequest::errorMessageWithReason( const QString &reason )
66+
{
67+
return tr( "Patch Feature request failed: %1" ).arg( reason );
68+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
/***************************************************************************
2+
qgsoapifpatchfeaturerequest.h
3+
-----------------------------
4+
begin : March 2023
5+
copyright : (C) 2023 by Even Rouault
6+
email : even.rouault at spatialys.com
7+
***************************************************************************
8+
* *
9+
* This program is free software; you can redistribute it and/or modify *
10+
* it under the terms of the GNU General Public License as published by *
11+
* the Free Software Foundation; either version 2 of the License, or *
12+
* (at your option) any later version. *
13+
* *
14+
***************************************************************************/
15+
16+
#ifndef QGSOAPIFPATCHFEATUREREQUEST_H
17+
#define QGSOAPIFPATCHFEATUREREQUEST_H
18+
19+
#include <QObject>
20+
21+
#include "qgsdatasourceuri.h"
22+
#include "qgsfeature.h"
23+
#include "qgsbasenetworkrequest.h"
24+
25+
class QgsOapifSharedData;
26+
27+
//! Manages the Patch Feature request
28+
class QgsOapifPatchFeatureRequest : public QgsBaseNetworkRequest
29+
{
30+
Q_OBJECT
31+
public:
32+
explicit QgsOapifPatchFeatureRequest( const QgsDataSourceUri &uri );
33+
34+
//! Issue a PATCH request to overwrite the feature by changing its geometry
35+
bool patchFeature( const QgsOapifSharedData *sharedData, const QString &jsonId, const QgsGeometry &geom, const QString &contentCrs, bool hasAxisInverted );
36+
37+
//! Issue a PATCH request to overwrite the feature by changing some attributes
38+
bool patchFeature( const QgsOapifSharedData *sharedData, const QString &jsonId, const QgsAttributeMap &attrMap );
39+
40+
protected:
41+
QString errorMessageWithReason( const QString &reason ) override;
42+
};
43+
44+
#endif // QGSOAPIFPATCHFEATUREREQUEST_H

‎src/providers/wfs/oapif/qgsoapifprovider.cpp

Lines changed: 274 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,12 @@
2121
#include "qgsoapifapirequest.h"
2222
#include "qgsoapifcollection.h"
2323
#include "qgsoapifconformancerequest.h"
24+
#include "qgsoapifcreatefeaturerequest.h"
25+
#include "qgsoapifdeletefeaturerequest.h"
26+
#include "qgsoapifpatchfeaturerequest.h"
27+
#include "qgsoapifputfeaturerequest.h"
2428
#include "qgsoapifitemsrequest.h"
29+
#include "qgsoapifoptionsrequest.h"
2530
#include "qgswfsconstants.h"
2631
#include "qgswfsutils.h" // for isCompatibleType()
2732

@@ -228,6 +233,10 @@ bool QgsOapifProvider::init()
228233

229234
mShared->mFields = itemsRequest.fields();
230235
mShared->mWKBType = itemsRequest.wkbType();
236+
mShared->mFoundIdTopLevel = itemsRequest.foundIdTopLevel();
237+
mShared->mFoundIdInProperties = itemsRequest.foundIdInProperties();
238+
239+
computeCapabilities( itemsRequest );
231240

232241
return true;
233242
}
@@ -334,9 +343,59 @@ bool QgsOapifProvider::isValid() const
334343
return mValid;
335344
}
336345

346+
void QgsOapifProvider::computeCapabilities( const QgsOapifItemsRequest &itemsRequest )
347+
{
348+
mCapabilities = QgsVectorDataProvider::SelectAtId |
349+
QgsVectorDataProvider::ReadLayerMetadata |
350+
QgsVectorDataProvider::Capability::ReloadData;
351+
352+
// Determine edition capabilities: create (POST on /items),
353+
// update (PUT on /items/some_id) and delete (DELETE on /items/some_id)
354+
// by issuing a OPTIONS HTTP request.
355+
QgsDataSourceUri uri( mShared->mURI.uri() );
356+
QgsOapifOptionsRequest optionsItemsRequest( uri );
357+
QStringList supportedOptions = optionsItemsRequest.sendOPTIONS( mShared->mItemsUrl );
358+
if ( supportedOptions.contains( QLatin1String( "POST" ) ) )
359+
{
360+
mCapabilities |= QgsVectorDataProvider::AddFeatures;
361+
362+
const auto &features = itemsRequest.features();
363+
QString testId;
364+
if ( !features.empty() )
365+
{
366+
testId = features[0].second;
367+
}
368+
else
369+
{
370+
// If there is no existing feature, it is not obvious to know if the
371+
// server supports PUT and DELETE on items. Attempt to request OPTIONS
372+
// on a fake object...
373+
testId = QStringLiteral( "unknown_id" );
374+
}
375+
QgsOapifOptionsRequest optionsOneItemRequest( uri );
376+
QString url( mShared->mItemsUrl );
377+
url += QStringLiteral( "/" );
378+
url += testId;
379+
supportedOptions = optionsOneItemRequest.sendOPTIONS( url );
380+
if ( supportedOptions.contains( QLatin1String( "PUT" ) ) )
381+
{
382+
mCapabilities |= QgsVectorDataProvider::ChangeAttributeValues;
383+
mCapabilities |= QgsVectorDataProvider::ChangeGeometries;
384+
}
385+
if ( supportedOptions.contains( QLatin1String( "DELETE" ) ) )
386+
{
387+
mCapabilities |= QgsVectorDataProvider::DeleteFeatures;
388+
}
389+
if ( supportedOptions.contains( QLatin1String( "PATCH" ) ) )
390+
{
391+
mSupportsPatch = true;
392+
}
393+
}
394+
}
395+
337396
QgsVectorDataProvider::Capabilities QgsOapifProvider::capabilities() const
338397
{
339-
return QgsVectorDataProvider::SelectAtId | QgsVectorDataProvider::ReadLayerMetadata | QgsVectorDataProvider::Capability::ReloadData;
398+
return mCapabilities;
340399
}
341400

342401
bool QgsOapifProvider::empty() const
@@ -427,6 +486,217 @@ void QgsOapifProvider::handlePostCloneOperations( QgsVectorDataProvider *source
427486
mShared = qobject_cast<QgsOapifProvider *>( source )->mShared;
428487
}
429488

489+
bool QgsOapifProvider::addFeatures( QgsFeatureList &flist, Flags flags )
490+
{
491+
QgsDataSourceUri uri( mShared->mURI.uri() );
492+
QStringList jsonIds;
493+
QString contentCrs;
494+
if ( mShared->mSourceCrs
495+
!= QgsCoordinateReferenceSystem::fromOgcWmsCrs( QgsOapifProvider::OAPIF_PROVIDER_DEFAULT_CRS ) )
496+
{
497+
contentCrs = mShared->mSourceCrs.toOgcUri();
498+
}
499+
const bool hasAxisInverted = mShared->mSourceCrs.hasAxisInverted();
500+
for ( QgsFeature &f : flist )
501+
{
502+
QgsOapifCreateFeatureRequest req( uri );
503+
const QString id = req.createFeature( mShared.get(), f, contentCrs, hasAxisInverted );
504+
if ( id.isEmpty() )
505+
{
506+
pushError( tr( "Feature creation failed: %1" ).arg( req.errorMessage() ) );
507+
return false;
508+
}
509+
jsonIds.append( id );
510+
}
511+
512+
QStringList::const_iterator idIt = jsonIds.constBegin();
513+
QgsFeatureList::iterator featureIt = flist.begin();
514+
515+
QVector<QgsFeatureUniqueIdPair> serializedFeatureList;
516+
for ( ; idIt != jsonIds.constEnd() && featureIt != flist.end(); ++idIt, ++featureIt )
517+
{
518+
serializedFeatureList.push_back( QgsFeatureUniqueIdPair( *featureIt, *idIt ) );
519+
}
520+
mShared->serializeFeatures( serializedFeatureList );
521+
522+
if ( !( flags & QgsFeatureSink::FastInsert ) )
523+
{
524+
// And now set the feature id from the one got from the database
525+
QMap< QString, QgsFeatureId > map;
526+
for ( int idx = 0; idx < serializedFeatureList.size(); idx++ )
527+
map[ serializedFeatureList[idx].second ] = serializedFeatureList[idx].first.id();
528+
529+
idIt = jsonIds.constBegin();
530+
featureIt = flist.begin();
531+
for ( ; idIt != jsonIds.constEnd() && featureIt != flist.end(); ++idIt, ++featureIt )
532+
{
533+
if ( map.find( *idIt ) != map.end() )
534+
featureIt->setId( map[*idIt] );
535+
}
536+
}
537+
538+
return true;
539+
}
540+
541+
bool QgsOapifProvider::changeGeometryValues( const QgsGeometryMap &geometry_map )
542+
{
543+
QgsDataSourceUri uri( mShared->mURI.uri() );
544+
QString contentCrs;
545+
if ( mShared->mSourceCrs
546+
!= QgsCoordinateReferenceSystem::fromOgcWmsCrs( QgsOapifProvider::OAPIF_PROVIDER_DEFAULT_CRS ) )
547+
{
548+
contentCrs = mShared->mSourceCrs.toOgcUri();
549+
}
550+
const bool hasAxisInverted = mShared->mSourceCrs.hasAxisInverted();
551+
QgsGeometryMap::const_iterator geomIt = geometry_map.constBegin();
552+
for ( ; geomIt != geometry_map.constEnd(); ++geomIt )
553+
{
554+
const QgsFeatureId qgisFid = geomIt.key();
555+
//find out feature id
556+
QString jsonId = mShared->findUniqueId( qgisFid );
557+
if ( jsonId.isEmpty() )
558+
{
559+
pushError( QStringLiteral( "Cannot identify feature of id %1" ).arg( qgisFid ) );
560+
return false;
561+
}
562+
563+
if ( mSupportsPatch )
564+
{
565+
// Push to server
566+
QgsOapifPatchFeatureRequest req( uri );
567+
if ( !req.patchFeature( mShared.get(), jsonId, geomIt.value(), contentCrs, hasAxisInverted ) )
568+
{
569+
pushError( QStringLiteral( "Cannot modify feature of id %1" ).arg( qgisFid ) );
570+
return false;
571+
}
572+
}
573+
else
574+
{
575+
// Fetch existing feature
576+
QgsFeatureRequest request;
577+
request.setFilterFid( qgisFid );
578+
QgsFeatureIterator featureIterator = getFeatures( request );
579+
QgsFeature f;
580+
if ( !featureIterator.nextFeature( f ) )
581+
{
582+
pushError( QStringLiteral( "Cannot retrieve feature of id %1" ).arg( qgisFid ) );
583+
return false;
584+
}
585+
586+
// Patch it with new geometry
587+
f.setGeometry( geomIt.value() );
588+
589+
// Push to server
590+
QgsOapifPutFeatureRequest req( uri );
591+
if ( !req.putFeature( mShared.get(), jsonId, f, contentCrs, hasAxisInverted ) )
592+
{
593+
pushError( QStringLiteral( "Cannot modify feature of id %1" ).arg( qgisFid ) );
594+
return false;
595+
}
596+
}
597+
}
598+
599+
mShared->changeGeometryValues( geometry_map );
600+
return true;
601+
}
602+
603+
bool QgsOapifProvider::changeAttributeValues( const QgsChangedAttributesMap &attr_map )
604+
{
605+
QgsDataSourceUri uri( mShared->mURI.uri() );
606+
QString contentCrs;
607+
if ( mShared->mSourceCrs
608+
!= QgsCoordinateReferenceSystem::fromOgcWmsCrs( QgsOapifProvider::OAPIF_PROVIDER_DEFAULT_CRS ) )
609+
{
610+
contentCrs = mShared->mSourceCrs.toOgcUri();
611+
}
612+
const bool hasAxisInverted = mShared->mSourceCrs.hasAxisInverted();
613+
QgsChangedAttributesMap::const_iterator attIt = attr_map.constBegin();
614+
for ( ; attIt != attr_map.constEnd(); ++attIt )
615+
{
616+
const QgsFeatureId qgisFid = attIt.key();
617+
//find out feature id
618+
QString jsonId = mShared->findUniqueId( qgisFid );
619+
if ( jsonId.isEmpty() )
620+
{
621+
pushError( QStringLiteral( "Cannot identify feature of id %1" ).arg( qgisFid ) );
622+
return false;
623+
}
624+
625+
if ( mSupportsPatch )
626+
{
627+
// Push to server
628+
QgsOapifPatchFeatureRequest req( uri );
629+
if ( !req.patchFeature( mShared.get(), jsonId, attIt.value() ) )
630+
{
631+
pushError( QStringLiteral( "Cannot modify feature of id %1" ).arg( qgisFid ) );
632+
return false;
633+
}
634+
}
635+
else
636+
{
637+
// Fetch existing feature
638+
QgsFeatureRequest request;
639+
request.setFilterFid( qgisFid );
640+
QgsFeatureIterator featureIterator = getFeatures( request );
641+
QgsFeature f;
642+
if ( !featureIterator.nextFeature( f ) )
643+
{
644+
pushError( QStringLiteral( "Cannot retrieve feature of id %1" ).arg( qgisFid ) );
645+
return false;
646+
}
647+
648+
// Patch it with new attribute values
649+
QgsAttributeMap::const_iterator attMapIt = attIt.value().constBegin();
650+
for ( ; attMapIt != attIt.value().constEnd(); ++attMapIt )
651+
{
652+
f.setAttribute( attMapIt.key(), attMapIt.value() );
653+
}
654+
655+
// Push to server
656+
QgsOapifPutFeatureRequest req( uri );
657+
if ( !req.putFeature( mShared.get(), jsonId, f, contentCrs, hasAxisInverted ) )
658+
{
659+
pushError( QStringLiteral( "Cannot modify feature of id %1" ).arg( qgisFid ) );
660+
return false;
661+
}
662+
}
663+
}
664+
665+
mShared->changeAttributeValues( attr_map );
666+
return true;
667+
}
668+
669+
bool QgsOapifProvider::deleteFeatures( const QgsFeatureIds &ids )
670+
{
671+
if ( ids.isEmpty() )
672+
{
673+
return true;
674+
}
675+
676+
QgsDataSourceUri uri( mShared->mURI.uri() );
677+
for ( const QgsFeatureId &id : ids )
678+
{
679+
//find out feature id
680+
QString jsonId = mShared->findUniqueId( id );
681+
if ( jsonId.isEmpty() )
682+
{
683+
pushError( QStringLiteral( "Cannot identify feature of id %1" ).arg( id ) );
684+
return false;
685+
}
686+
687+
QgsOapifDeleteFeatureRequest req( uri );
688+
QUrl url( mShared->mItemsUrl + QString( QStringLiteral( "/" ) + jsonId ) );
689+
if ( ! req.sendDELETE( url ) )
690+
{
691+
pushError( tr( "Feature deletion failed: %1" ).arg( req.errorMessage() ) );
692+
return false;
693+
}
694+
}
695+
696+
mShared->deleteFeatures( ids );
697+
return true;
698+
}
699+
430700
QString QgsOapifProvider::name() const
431701
{
432702
return OAPIF_PROVIDER_KEY;
@@ -493,6 +763,8 @@ QgsOapifSharedData *QgsOapifSharedData::clone() const
493763
copy->mCollectionUrl = mCollectionUrl;
494764
copy->mItemsUrl = mItemsUrl;
495765
copy->mServerFilter = mServerFilter;
766+
copy->mFoundIdTopLevel = mFoundIdTopLevel;
767+
copy->mFoundIdInProperties = mFoundIdInProperties;
496768
QgsBackgroundCachedSharedData::copyStateToClone( copy );
497769

498770
return copy;
@@ -882,7 +1154,7 @@ void QgsOapifFeatureDownloaderImpl::run( bool serializeFeatures, long long maxFe
8821154
{
8831155
QgsGeometry g = f.geometry();
8841156
if ( mShared->mSourceCrs.hasAxisInverted() )
885-
g.transform( QTransform( 0, 1, 1, 0, 0, 0 ) );
1157+
g.get()->swapXy();
8861158
dstFeat.setGeometry( g );
8871159
}
8881160
const auto srcAttrs = f.attributes();

‎src/providers/wfs/oapif/qgsoapifprovider.h

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
#include "qgsvectordataprovider.h"
2424
#include "qgsbackgroundcachedshareddata.h"
2525
#include "qgswfsdatasourceuri.h"
26+
#include "qgsoapifitemsrequest.h"
2627

2728
#include "qgsprovidermetadata.h"
2829

@@ -93,6 +94,13 @@ class QgsOapifProvider final: public QgsVectorDataProvider
9394

9495
void handlePostCloneOperations( QgsVectorDataProvider *source ) override;
9596

97+
//Editing operations
98+
99+
bool addFeatures( QgsFeatureList &flist, QgsFeatureSink::Flags flags = QgsFeatureSink::Flags() ) override;
100+
bool deleteFeatures( const QgsFeatureIds &ids ) override;
101+
bool changeGeometryValues( const QgsGeometryMap &geometry_map ) override;
102+
bool changeAttributeValues( const QgsChangedAttributesMap &attr_map ) override;
103+
96104
private slots:
97105

98106
void pushErrorSlot( const QString &errorMsg );
@@ -103,6 +111,12 @@ class QgsOapifProvider final: public QgsVectorDataProvider
103111
//! Flag if provider is valid
104112
bool mValid = true;
105113

114+
//! Server capabilities for this layer (generated from capabilities document)
115+
QgsVectorDataProvider::Capabilities mCapabilities = QgsVectorDataProvider::Capabilities();
116+
117+
//! Whether server supports PATCH operation
118+
bool mSupportsPatch = false;
119+
106120
//! String used to define a subset of the layer
107121
QString mSubsetString;
108122

@@ -119,6 +133,9 @@ class QgsOapifProvider final: public QgsVectorDataProvider
119133
* Invalidates cache of shared object
120134
*/
121135
void reloadProviderData() override;
136+
137+
//! Compute capabilities
138+
void computeCapabilities( const QgsOapifItemsRequest &itemsRequest );
122139
};
123140

124141
class QgsOapifProviderMetadata final: public QgsProviderMetadata
@@ -164,6 +181,9 @@ class QgsOapifSharedData final: public QObject, public QgsBackgroundCachedShared
164181
protected:
165182
friend class QgsOapifProvider;
166183
friend class QgsOapifFeatureDownloaderImpl;
184+
friend class QgsOapifCreateFeatureRequest;
185+
friend class QgsOapifPutFeatureRequest;
186+
friend class QgsOapifPatchFeatureRequest;
167187

168188
//! Datasource URI
169189
QgsWFSDataSourceURI mURI;
@@ -189,6 +209,12 @@ class QgsOapifSharedData final: public QObject, public QgsBackgroundCachedShared
189209
//! Translation state of filter to server-side filter.
190210
QgsOapifProvider::FilterTranslationState mFilterTranslationState = QgsOapifProvider::FilterTranslationState::FULLY_CLIENT;
191211

212+
//! Set if an "id" is present at top level of features
213+
bool mFoundIdTopLevel = false;
214+
215+
//! Set if an "id" is present in the "properties" object of features
216+
bool mFoundIdInProperties = false;
217+
192218
//! Append extra query parameters if needed
193219
QString appendExtraQueryParameters( const QString &url ) const;
194220

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
/***************************************************************************
2+
qgsoapifputfeaturerequest.cpp
3+
-----------------------------
4+
begin : March 2023
5+
copyright : (C) 2023 by Even Rouault
6+
email : even.rouault at spatialys.com
7+
***************************************************************************
8+
* *
9+
* This program is free software; you can redistribute it and/or modify *
10+
* it under the terms of the GNU General Public License as published by *
11+
* the Free Software Foundation; either version 2 of the License, or *
12+
* (at your option) any later version. *
13+
* *
14+
***************************************************************************/
15+
16+
#include <nlohmann/json.hpp>
17+
using namespace nlohmann;
18+
19+
#include "qgslogger.h"
20+
#include "qgsjsonutils.h"
21+
#include "qgsoapifputfeaturerequest.h"
22+
#include "qgsoapifprovider.h"
23+
24+
QgsOapifPutFeatureRequest::QgsOapifPutFeatureRequest( const QgsDataSourceUri &uri ):
25+
QgsBaseNetworkRequest( QgsAuthorizationSettings( uri.username(), uri.password(), uri.authConfigId() ), "OAPIF" )
26+
{
27+
}
28+
29+
bool QgsOapifPutFeatureRequest::putFeature( const QgsOapifSharedData *sharedData, const QString &jsonId, const QgsFeature &f, const QString &contentCrs, bool hasAxisInverted )
30+
{
31+
QgsJsonExporter exporter;
32+
33+
QgsFeature fModified( f );
34+
if ( hasAxisInverted && f.hasGeometry() )
35+
{
36+
QgsGeometry g = f.geometry();
37+
g.get()->swapXy();
38+
fModified.setGeometry( g );
39+
}
40+
41+
json j = exporter.exportFeatureToJsonObject( fModified, QVariantMap(), jsonId );
42+
auto iterBbox = j.find( "bbox" );
43+
if ( iterBbox != j.end() )
44+
j.erase( iterBbox );
45+
if ( !sharedData->mFoundIdInProperties )
46+
{
47+
auto jPropertiesIter = j.find( "properties" );
48+
if ( jPropertiesIter != j.end() )
49+
{
50+
auto &jProperties = *jPropertiesIter;
51+
auto iterId = jProperties.find( "id" );
52+
if ( iterId != jProperties.end() )
53+
jProperties.erase( iterId );
54+
}
55+
}
56+
const QString jsonFeature = QString::fromStdString( j.dump() );
57+
QgsDebugMsgLevel( jsonFeature, 5 );
58+
mEmptyResponseIsValid = true;
59+
mFakeResponseHasHeaders = true;
60+
QList<QNetworkReply::RawHeaderPair> extraHeaders;
61+
if ( !contentCrs.isEmpty() )
62+
extraHeaders.append( QNetworkReply::RawHeaderPair( QByteArray( "Content-Crs" ), contentCrs.toUtf8() ) );
63+
QUrl url( sharedData->mItemsUrl + QString( QStringLiteral( "/" ) + jsonId ) );
64+
return sendPUT( url, "application/geo+json", jsonFeature.toUtf8(), extraHeaders );
65+
}
66+
67+
QString QgsOapifPutFeatureRequest::errorMessageWithReason( const QString &reason )
68+
{
69+
return tr( "Put Feature request failed: %1" ).arg( reason );
70+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
/***************************************************************************
2+
qgsoapifputfeaturerequest.h
3+
---------------------------
4+
begin : March 2023
5+
copyright : (C) 2023 by Even Rouault
6+
email : even.rouault at spatialys.com
7+
***************************************************************************
8+
* *
9+
* This program is free software; you can redistribute it and/or modify *
10+
* it under the terms of the GNU General Public License as published by *
11+
* the Free Software Foundation; either version 2 of the License, or *
12+
* (at your option) any later version. *
13+
* *
14+
***************************************************************************/
15+
16+
#ifndef QGSOAPIFPUTFEATUREREQUEST_H
17+
#define QGSOAPIFPUTFEATUREREQUEST_H
18+
19+
#include <QObject>
20+
21+
#include "qgsdatasourceuri.h"
22+
#include "qgsfeature.h"
23+
#include "qgsbasenetworkrequest.h"
24+
25+
class QgsOapifSharedData;
26+
27+
//! Manages the Put Feature request
28+
class QgsOapifPutFeatureRequest : public QgsBaseNetworkRequest
29+
{
30+
Q_OBJECT
31+
public:
32+
explicit QgsOapifPutFeatureRequest( const QgsDataSourceUri &uri );
33+
34+
//! Issue a PUT request to overwrite the feature
35+
bool putFeature( const QgsOapifSharedData *sharedData, const QString &jsonId, const QgsFeature &f, const QString &contentCrs, bool hasAxisInverted );
36+
37+
protected:
38+
QString errorMessageWithReason( const QString &reason ) override;
39+
};
40+
41+
#endif // QGSOAPIFPUTFEATUREREQUEST_H

‎src/providers/wfs/qgsbasenetworkrequest.cpp

Lines changed: 220 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ void QgsBaseNetworkRequest::requestTimedOut( QNetworkReply *reply )
7979
mTimedout = true;
8080
}
8181

82-
bool QgsBaseNetworkRequest::sendGET( const QUrl &url, const QString &acceptHeader, bool synchronous, bool forceRefresh, bool cache )
82+
bool QgsBaseNetworkRequest::sendGET( const QUrl &url, const QString &acceptHeader, bool synchronous, bool forceRefresh, bool cache, const QList<QNetworkReply::RawHeaderPair> &extraHeaders )
8383
{
8484
abort(); // cancel previous
8585
mIsAborted = false;
@@ -111,16 +111,6 @@ bool QgsBaseNetworkRequest::sendGET( const QUrl &url, const QString &acceptHeade
111111
QString modifiedUrlString = modifiedUrl.toString();
112112
// Qt5 does URL encoding from some reason (of the FILTER parameter for example)
113113
modifiedUrlString = QUrl::fromPercentEncoding( modifiedUrlString.toUtf8() );
114-
QgsDebugMsgLevel( QStringLiteral( "Get %1" ).arg( modifiedUrlString ), 4 );
115-
modifiedUrlString = modifiedUrlString.mid( QStringLiteral( "http://" ).size() );
116-
#ifdef Q_OS_WIN
117-
// Passing "urls" like "http://c:/path" to QUrl 'eats' the : after c,
118-
// so we must restore it
119-
if ( modifiedUrlString[1] == '/' )
120-
{
121-
modifiedUrlString = modifiedUrlString[0] + ":/" + modifiedUrlString.mid( 2 );
122-
}
123-
#endif
124114

125115
if ( !acceptHeader.isEmpty() )
126116
{
@@ -133,6 +123,29 @@ bool QgsBaseNetworkRequest::sendGET( const QUrl &url, const QString &acceptHeade
133123
modifiedUrlString += QStringLiteral( "?Accept=" ) + acceptHeader;
134124
}
135125
}
126+
for ( const QNetworkReply::RawHeaderPair &headerPair : extraHeaders )
127+
{
128+
if ( modifiedUrlString.indexOf( '?' ) > 0 )
129+
{
130+
modifiedUrlString += QStringLiteral( "&" );
131+
}
132+
else
133+
{
134+
modifiedUrlString += QStringLiteral( "?" );
135+
}
136+
modifiedUrlString += QString::fromUtf8( headerPair.first ) + QStringLiteral( "=" ) + QString::fromUtf8( headerPair.second ) ;
137+
}
138+
139+
QgsDebugMsgLevel( QStringLiteral( "Get %1" ).arg( modifiedUrlString ), 4 );
140+
modifiedUrlString = modifiedUrlString.mid( QStringLiteral( "http://" ).size() );
141+
#ifdef Q_OS_WIN
142+
// Passing "urls" like "http://c:/path" to QUrl 'eats' the : after c,
143+
// so we must restore it
144+
if ( modifiedUrlString[1] == '/' )
145+
{
146+
modifiedUrlString = modifiedUrlString[0] + ":/" + modifiedUrlString.mid( 2 );
147+
}
148+
#endif
136149

137150
// For REST API using URL subpaths, normalize the subpaths
138151
const int afterEndpointStartPos = static_cast<int>( modifiedUrlString.indexOf( "fake_qgis_http_endpoint" ) + strlen( "fake_qgis_http_endpoint" ) );
@@ -183,6 +196,8 @@ bool QgsBaseNetworkRequest::sendGET( const QUrl &url, const QString &acceptHeade
183196
{
184197
request.setRawHeader( "Accept", acceptHeader.toUtf8() );
185198
}
199+
for ( const QNetworkReply::RawHeaderPair &headerPair : extraHeaders )
200+
request.setRawHeader( headerPair.first, headerPair.second );
186201

187202
QgsSetRequestInitiatorClass( request, QStringLiteral( "QgsBaseNetworkRequest" ) );
188203
if ( !mAuth.setAuthorization( request ) )
@@ -199,19 +214,58 @@ bool QgsBaseNetworkRequest::sendGET( const QUrl &url, const QString &acceptHeade
199214
request.setAttribute( QNetworkRequest::CacheSaveControlAttribute, true );
200215
}
201216

217+
const bool success = issueRequest( request, QByteArray( "GET" ), nullptr, synchronous );
218+
if ( !success || !mErrorMessage.isEmpty() )
219+
{
220+
return false;
221+
}
222+
223+
if ( synchronous )
224+
{
225+
// Insert response of requests GetCapabilities, DescribeFeatureType or GetFeature
226+
// with a COUNT=1 into a short-lived memory cache, as they are emitted
227+
// repeatedly in interactive scenarios when adding a WFS layer.
228+
QString urlString = url.toString();
229+
if ( urlString.contains( QStringLiteral( "REQUEST=GetCapabilities" ) ) ||
230+
urlString.contains( QStringLiteral( "REQUEST=DescribeFeatureType" ) ) ||
231+
( urlString.contains( QStringLiteral( "REQUEST=GetFeature" ) ) && urlString.contains( QStringLiteral( "COUNT=1" ) ) ) )
232+
{
233+
QgsSettings s;
234+
if ( s.value( QStringLiteral( "qgis/wfsMemoryCacheAllowed" ), true ).toBool() )
235+
{
236+
insertIntoMemoryCache( url, mResponse );
237+
}
238+
}
239+
}
240+
241+
return true;
242+
}
243+
244+
bool QgsBaseNetworkRequest::issueRequest( QNetworkRequest &request, const QByteArray &verb, const QByteArray *data, bool synchronous )
245+
{
246+
202247
QWaitCondition waitCondition;
203248
QMutex waitConditionMutex;
204249

205250
bool threadFinished = false;
206251
bool success = false;
207252

208-
const std::function<void()> downloaderFunction = [ this, request, synchronous, &waitConditionMutex, &waitCondition, &threadFinished, &success ]()
253+
const std::function<void()> downloaderFunction = [ this, request, synchronous, data, &verb, &waitConditionMutex, &waitCondition, &threadFinished, &success ]()
209254
{
210255
if ( QThread::currentThread() != QApplication::instance()->thread() )
211256
QgsNetworkAccessManager::instance( Qt::DirectConnection );
212257

213258
success = true;
214-
mReply = QgsNetworkAccessManager::instance()->get( request );
259+
if ( verb == QByteArray( "GET" ) )
260+
mReply = QgsNetworkAccessManager::instance()->get( request );
261+
else if ( verb == QByteArray( "POST" ) )
262+
mReply = QgsNetworkAccessManager::instance()->post( request, *data );
263+
else if ( verb == QByteArray( "PUT" ) )
264+
mReply = QgsNetworkAccessManager::instance()->put( request, *data );
265+
else if ( verb == QByteArray( "PATCH" ) )
266+
mReply = QgsNetworkAccessManager::instance()->sendCustomRequest( request, verb, *data );
267+
else
268+
mReply = QgsNetworkAccessManager::instance()->sendCustomRequest( request, verb );
215269

216270
if ( !mAuth.setAuthorizationReply( mReply ) )
217271
{
@@ -300,38 +354,172 @@ bool QgsBaseNetworkRequest::sendGET( const QUrl &url, const QString &acceptHeade
300354
downloaderFunction();
301355
}
302356

303-
if ( !success || !mErrorMessage.isEmpty() )
357+
return success;
358+
}
359+
360+
bool QgsBaseNetworkRequest::sendPOSTOrPUTOrPATCH( const QUrl &url, const QByteArray &verb, const QString &contentTypeHeader, const QByteArray &data, const QList<QNetworkReply::RawHeaderPair> &extraHeaders )
361+
{
362+
abort(); // cancel previous
363+
mIsAborted = false;
364+
mTimedout = false;
365+
mGotNonEmptyResponse = false;
366+
367+
mErrorMessage.clear();
368+
mErrorCode = QgsBaseNetworkRequest::NoError;
369+
mForceRefresh = true;
370+
mResponse.clear();
371+
372+
if ( url.toEncoded().contains( "fake_qgis_http_endpoint" ) )
304373
{
374+
// Hack for testing purposes
375+
QUrl modifiedUrl( url );
376+
QUrlQuery query( modifiedUrl );
377+
query.addQueryItem( QString( QString::fromUtf8( verb ) + QStringLiteral( "DATA" ) ), QString::fromUtf8( data ) );
378+
modifiedUrl.setQuery( query );
379+
QList<QNetworkReply::RawHeaderPair> extraHeadersModified( extraHeaders );
380+
if ( mFakeURLIncludesContentType && !contentTypeHeader.isEmpty() )
381+
{
382+
extraHeadersModified.append( QNetworkReply::RawHeaderPair( QByteArray( "Content-Type" ), contentTypeHeader.toUtf8() ) );
383+
}
384+
bool ret = sendGET( modifiedUrl, QString(), true, true, false, extraHeadersModified );
385+
386+
if ( mFakeResponseHasHeaders )
387+
{
388+
// Expect the file content to be formatted like:
389+
// header1: value1\r\n
390+
// headerN: valueN\r\n
391+
// \r\n
392+
// content
393+
int from = 0;
394+
while ( true )
395+
{
396+
int pos = mResponse.indexOf( QByteArray( "\r\n" ), from );
397+
if ( pos < 0 )
398+
{
399+
break;
400+
}
401+
QByteArray line = mResponse.mid( from, pos - from );
402+
int posColon = line.indexOf( QByteArray( ":" ) );
403+
if ( posColon > 0 )
404+
{
405+
mResponseHeaders.append( QNetworkReply::RawHeaderPair( line.mid( 0, posColon ), line.mid( posColon + 1 ).trimmed() ) );
406+
}
407+
from = pos + 2;
408+
if ( from + 2 < mResponse.size() && mResponse[from] == '\r' && mResponse[from] == '\n' )
409+
{
410+
from += 2;
411+
break;
412+
}
413+
}
414+
mResponse = mResponse.mid( from );
415+
}
416+
return ret;
417+
}
418+
419+
QNetworkRequest request( url );
420+
QgsSetRequestInitiatorClass( request, QStringLiteral( "QgsBaseNetworkRequest" ) );
421+
if ( !mAuth.setAuthorization( request ) )
422+
{
423+
mErrorCode = QgsBaseNetworkRequest::NetworkError;
424+
mErrorMessage = errorMessageFailedAuth();
425+
logMessageIfEnabled();
305426
return false;
306427
}
428+
request.setHeader( QNetworkRequest::ContentTypeHeader, contentTypeHeader );
429+
for ( const QNetworkReply::RawHeaderPair &headerPair : extraHeaders )
430+
request.setRawHeader( headerPair.first, headerPair.second );
307431

308-
if ( synchronous )
432+
if ( !issueRequest( request, verb, &data, /*synchronous=*/true ) )
309433
{
310-
// Insert response of requests GetCapabilities, DescribeFeatureType or GetFeature
311-
// with a COUNT=1 into a short-lived memory cache, as they are emitted
312-
// repeatedly in interactive scenarios when adding a WFS layer.
313-
QString urlString = url.toString();
314-
if ( urlString.contains( QStringLiteral( "REQUEST=GetCapabilities" ) ) ||
315-
urlString.contains( QStringLiteral( "REQUEST=DescribeFeatureType" ) ) ||
316-
( urlString.contains( QStringLiteral( "REQUEST=GetFeature" ) ) && urlString.contains( QStringLiteral( "COUNT=1" ) ) ) )
434+
return false;
435+
}
436+
437+
return mErrorMessage.isEmpty();
438+
}
439+
440+
bool QgsBaseNetworkRequest::sendPOST( const QUrl &url, const QString &contentTypeHeader, const QByteArray &data, const QList<QNetworkReply::RawHeaderPair> &extraHeaders )
441+
{
442+
return sendPOSTOrPUTOrPATCH( url, QByteArray( "POST" ), contentTypeHeader, data, extraHeaders );
443+
}
444+
445+
bool QgsBaseNetworkRequest::sendPUT( const QUrl &url, const QString &contentTypeHeader, const QByteArray &data, const QList<QNetworkReply::RawHeaderPair> &extraHeaders )
446+
{
447+
return sendPOSTOrPUTOrPATCH( url, QByteArray( "PUT" ), contentTypeHeader, data, extraHeaders );
448+
}
449+
450+
bool QgsBaseNetworkRequest::sendPATCH( const QUrl &url, const QString &contentTypeHeader, const QByteArray &data, const QList<QNetworkReply::RawHeaderPair> &extraHeaders )
451+
{
452+
return sendPOSTOrPUTOrPATCH( url, QByteArray( "PATCH" ), contentTypeHeader, data, extraHeaders );
453+
}
454+
455+
QStringList QgsBaseNetworkRequest::sendOPTIONS( const QUrl &url )
456+
{
457+
abort(); // cancel previous
458+
mIsAborted = false;
459+
mTimedout = false;
460+
mGotNonEmptyResponse = false;
461+
mEmptyResponseIsValid = true;
462+
463+
mErrorMessage.clear();
464+
mErrorCode = QgsBaseNetworkRequest::NoError;
465+
mForceRefresh = true;
466+
mResponse.clear();
467+
468+
QByteArray allowValue;
469+
if ( url.toEncoded().contains( "fake_qgis_http_endpoint" ) )
470+
{
471+
// Hack for testing purposes
472+
QUrl modifiedUrl( url );
473+
QUrlQuery query( modifiedUrl );
474+
query.addQueryItem( QStringLiteral( "VERB" ), QStringLiteral( "OPTIONS" ) );
475+
modifiedUrl.setQuery( query );
476+
if ( !sendGET( modifiedUrl, QString(), true, true, false ) )
477+
return QStringList();
478+
allowValue = mResponse;
479+
}
480+
else
481+
{
482+
QNetworkRequest request( url );
483+
QgsSetRequestInitiatorClass( request, QStringLiteral( "QgsBaseNetworkRequest" ) );
484+
if ( !mAuth.setAuthorization( request ) )
317485
{
318-
QgsSettings s;
319-
if ( s.value( QStringLiteral( "qgis/wfsMemoryCacheAllowed" ), true ).toBool() )
486+
mErrorCode = QgsBaseNetworkRequest::NetworkError;
487+
mErrorMessage = errorMessageFailedAuth();
488+
logMessageIfEnabled();
489+
return QStringList();
490+
}
491+
492+
if ( !issueRequest( request, QByteArray( "OPTIONS" ), /*data=*/nullptr, /*synchronous=*/true ) )
493+
{
494+
return QStringList();
495+
}
496+
497+
for ( const auto &headerKeyValue : mResponseHeaders )
498+
{
499+
if ( headerKeyValue.first == QByteArray( "Allow" ) )
320500
{
321-
insertIntoMemoryCache( url, mResponse );
501+
allowValue = headerKeyValue.second;
502+
break;
322503
}
323504
}
324505
}
325506

326-
return true;
507+
QStringList res;
508+
QStringList l = QString::fromLatin1( allowValue ).split( QLatin1Char( ',' ) );
509+
for ( const QString &s : l )
510+
{
511+
res.append( s.trimmed() );
512+
}
513+
return res;
327514
}
328515

329-
bool QgsBaseNetworkRequest::sendPOST( const QUrl &url, const QString &contentTypeHeader, const QByteArray &data )
516+
bool QgsBaseNetworkRequest::sendDELETE( const QUrl &url )
330517
{
331518
abort(); // cancel previous
332519
mIsAborted = false;
333520
mTimedout = false;
334521
mGotNonEmptyResponse = false;
522+
mEmptyResponseIsValid = true;
335523

336524
mErrorMessage.clear();
337525
mErrorCode = QgsBaseNetworkRequest::NoError;
@@ -343,7 +531,7 @@ bool QgsBaseNetworkRequest::sendPOST( const QUrl &url, const QString &contentTyp
343531
// Hack for testing purposes
344532
QUrl modifiedUrl( url );
345533
QUrlQuery query( modifiedUrl );
346-
query.addQueryItem( QStringLiteral( "POSTDATA" ), QString::fromUtf8( data ) );
534+
query.addQueryItem( QStringLiteral( "VERB" ), QString::fromUtf8( "DELETE" ) );
347535
modifiedUrl.setQuery( query );
348536
return sendGET( modifiedUrl, QString(), true, true, false );
349537
}
@@ -357,23 +545,11 @@ bool QgsBaseNetworkRequest::sendPOST( const QUrl &url, const QString &contentTyp
357545
logMessageIfEnabled();
358546
return false;
359547
}
360-
request.setHeader( QNetworkRequest::ContentTypeHeader, contentTypeHeader );
361548

362-
mReply = QgsNetworkAccessManager::instance()->post( request, data );
363-
if ( !mAuth.setAuthorizationReply( mReply ) )
549+
if ( !issueRequest( request, QByteArray( "DELETE" ), nullptr, /*synchronous=*/true ) )
364550
{
365-
mErrorCode = QgsBaseNetworkRequest::NetworkError;
366-
mErrorMessage = errorMessageFailedAuth();
367-
logMessageIfEnabled();
368551
return false;
369552
}
370-
connect( mReply, &QNetworkReply::finished, this, &QgsBaseNetworkRequest::replyFinished );
371-
connect( mReply, &QNetworkReply::downloadProgress, this, &QgsBaseNetworkRequest::replyProgress );
372-
connect( mReply, &QNetworkReply::readyRead, this, &QgsBaseNetworkRequest::replyReadyRead );
373-
374-
QEventLoop loop;
375-
connect( this, &QgsBaseNetworkRequest::downloadFinished, &loop, &QEventLoop::quit );
376-
loop.exec( QEventLoop::ExcludeUserInputEvents );
377553

378554
return mErrorMessage.isEmpty();
379555
}
@@ -507,7 +683,7 @@ void QgsBaseNetworkRequest::replyFinished()
507683

508684
mResponse = mReply->readAll();
509685

510-
if ( mResponse.isEmpty() && !mGotNonEmptyResponse )
686+
if ( mResponse.isEmpty() && !mGotNonEmptyResponse && !mEmptyResponseIsValid )
511687
{
512688
mErrorMessage = tr( "empty response: %1" ).arg( mReply->errorString() );
513689
mErrorCode = QgsBaseNetworkRequest::ServerExceptionError;
@@ -542,6 +718,8 @@ void QgsBaseNetworkRequest::replyFinished()
542718

543719
if ( mReply )
544720
{
721+
mResponseHeaders = mReply->rawHeaderPairs();
722+
545723
mReply->deleteLater();
546724
mReply = nullptr;
547725
}

‎src/providers/wfs/qgsbasenetworkrequest.h

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,22 @@ class QgsBaseNetworkRequest : public QObject
3535
~QgsBaseNetworkRequest() override;
3636

3737
//! \brief proceed to sending a GET request
38-
bool sendGET( const QUrl &url, const QString &acceptHeader, bool synchronous, bool forceRefresh = false, bool cache = true );
38+
bool sendGET( const QUrl &url, const QString &acceptHeader, bool synchronous, bool forceRefresh = false, bool cache = true, const QList<QNetworkReply::RawHeaderPair> &extraHeaders = QList<QNetworkReply::RawHeaderPair>() );
3939

4040
//! \brief proceed to sending a synchronous POST request
41-
bool sendPOST( const QUrl &url, const QString &contentTypeHeader, const QByteArray &data );
41+
bool sendPOST( const QUrl &url, const QString &contentTypeHeader, const QByteArray &data, const QList<QNetworkReply::RawHeaderPair> &extraHeaders = QList<QNetworkReply::RawHeaderPair>() );
42+
43+
//! \brief proceed to sending a synchronous PUT request
44+
bool sendPUT( const QUrl &url, const QString &contentTypeHeader, const QByteArray &data, const QList<QNetworkReply::RawHeaderPair> &extraHeaders = QList<QNetworkReply::RawHeaderPair>() );
45+
46+
//! \brief proceed to sending a synchronous PATCH request
47+
bool sendPATCH( const QUrl &url, const QString &contentTypeHeader, const QByteArray &data, const QList<QNetworkReply::RawHeaderPair> &extraHeaders = QList<QNetworkReply::RawHeaderPair>() );
48+
49+
//! \brief proceed to sending a synchronous OPTIONS request and return the supported verbs
50+
QStringList sendOPTIONS( const QUrl &url );
51+
52+
//! \brief proceed to sending a synchronous DELETE request
53+
bool sendDELETE( const QUrl &url );
4254

4355
//! Set whether to log error messages.
4456
void setLogErrors( bool enabled ) { mLogErrors = enabled; }
@@ -95,6 +107,9 @@ class QgsBaseNetworkRequest : public QObject
95107
//! Raw response
96108
QByteArray mResponse;
97109

110+
//! Response headers
111+
QList<QNetworkReply::RawHeaderPair> mResponseHeaders;
112+
98113
//! Whether the request is aborted.
99114
bool mIsAborted = false;
100115

@@ -107,9 +122,18 @@ class QgsBaseNetworkRequest : public QObject
107122
//! Whether we already received bytes
108123
bool mGotNonEmptyResponse = false;
109124

125+
//! Whether an empty response is valid
126+
bool mEmptyResponseIsValid = false;
127+
110128
//! Whether to log error messages
111129
bool mLogErrors = true;
112130

131+
//! Whether in simulated HTTP mode, the response read in the file has HTTP headers
132+
bool mFakeResponseHasHeaders = false;
133+
134+
//! Whether in simulated HTTP mode, the Content-Type request header should be included
135+
bool mFakeURLIncludesContentType = false;
136+
113137
protected:
114138

115139
/**
@@ -125,6 +149,11 @@ class QgsBaseNetworkRequest : public QObject
125149
QString errorMessageFailedAuth();
126150

127151
void logMessageIfEnabled();
152+
153+
//! \brief proceed to sending a synchronous POST, PUT or PATCH request
154+
bool sendPOSTOrPUTOrPATCH( const QUrl &url, const QByteArray &verb, const QString &contentTypeHeader, const QByteArray &data, const QList<QNetworkReply::RawHeaderPair> &extraHeaders = QList<QNetworkReply::RawHeaderPair>() );
155+
156+
bool issueRequest( QNetworkRequest &request, const QByteArray &verb, const QByteArray *data, bool synchronous );
128157
};
129158

130159

‎tests/src/python/test_provider_oapif.py

Lines changed: 338 additions & 0 deletions
Large diffs are not rendered by default.

0 commit comments

Comments
 (0)
Please sign in to comment.