diff --git a/src/providers/wfs/CMakeLists.txt b/src/providers/wfs/CMakeLists.txt index 45ea885ca2e0..99bc01e6de72 100644 --- a/src/providers/wfs/CMakeLists.txt +++ b/src/providers/wfs/CMakeLists.txt @@ -26,6 +26,7 @@ set(WFS_SRCS oapif/qgsoapiflandingpagerequest.cpp oapif/qgsoapifapirequest.cpp oapif/qgsoapifcollection.cpp + oapif/qgsoapifconformancerequest.cpp oapif/qgsoapifitemsrequest.cpp oapif/qgsoapifprovider.cpp oapif/qgsoapifutils.cpp diff --git a/src/providers/wfs/oapif/qgsoapifcollection.cpp b/src/providers/wfs/oapif/qgsoapifcollection.cpp index bbebfb473aa5..cebf2b42440a 100644 --- a/src/providers/wfs/oapif/qgsoapifcollection.cpp +++ b/src/providers/wfs/oapif/qgsoapifcollection.cpp @@ -25,7 +25,7 @@ using namespace nlohmann; #include -bool QgsOapifCollection::deserialize( const json &j ) +bool QgsOapifCollection::deserialize( const json &j, const json &jCollections ) { if ( !j.is_object() ) return false; @@ -121,7 +121,6 @@ bool QgsOapifCollection::deserialize( const json &j ) crs = QgsCoordinateReferenceSystem::fromOgcWmsCrs( QString::fromStdString( jCrs.get() ) ); } } - mLayerMetadata.setCrs( crs ); const auto jBboxes = spatial["bbox"]; if ( jBboxes.is_array() ) @@ -148,6 +147,7 @@ bool QgsOapifCollection::deserialize( const json &j ) { if ( firstBbox ) { + mBboxCrs = crs; mBbox.set( values[0], values[1], values[2], values[3] ); } spatialExtent.bounds = QgsBox3d( mBbox ); @@ -156,6 +156,7 @@ bool QgsOapifCollection::deserialize( const json &j ) { if ( firstBbox ) { + mBboxCrs = crs; mBbox.set( values[0], values[1], values[3], values[4] ); } spatialExtent.bounds = QgsBox3d( values[0], values[1], values[2], @@ -298,25 +299,74 @@ bool QgsOapifCollection::deserialize( const json &j ) } } + // Usage storageCrs from Part 2 in priority + bool layerCrsSet = false; + if ( j.contains( "storageCrs" ) ) + { + const auto crsUrl = j["storageCrs"]; + if ( crsUrl.is_string() ) + { + QString crsStr = QString::fromStdString( crsUrl.get() ); + QgsCoordinateReferenceSystem crs = QgsCoordinateReferenceSystem::fromOgcWmsCrs( crsStr ); + + if ( j.contains( "storageCrsCoordinateEpoch" ) ) + { + const auto storageCrsCoordinateEpoch = j["storageCrsCoordinateEpoch"]; + if ( storageCrsCoordinateEpoch.is_number() ) + { + crs.setCoordinateEpoch( storageCrsCoordinateEpoch.get() ); + } + } + + layerCrsSet = true; + mLayerMetadata.setCrs( crs ); + mCrsList.append( crs.authid() ); + } + } + if ( j.contains( "crs" ) ) { - const auto crsUrls = j["crs"]; - if ( crsUrls.is_array() ) + json jCrs = j["crs"]; + // Resolve "#/crs" link + if ( jCrs.is_array() && jCrs.size() == 1 && + jCrs[0].is_string() && jCrs[0].get() == "#/crs" && + jCollections.is_object() && jCollections.contains( "crs" ) ) { - for ( const auto &crsUrl : crsUrls ) + jCrs = jCollections["crs"]; + } + + if ( jCrs.is_array() ) + { + for ( const auto &crsUrl : jCrs ) { if ( crsUrl.is_string() ) { - QString crs = QString::fromStdString( crsUrl.get() ); - mLayerMetadata.setCrs( QgsCoordinateReferenceSystem::fromOgcWmsCrs( crs ) ); + QString crsStr = QString::fromStdString( crsUrl.get() ); + QgsCoordinateReferenceSystem crs( QgsCoordinateReferenceSystem::fromOgcWmsCrs( crsStr ) ); + if ( !layerCrsSet ) + { + // Take the first CRS of the list + layerCrsSet = true; + mLayerMetadata.setCrs( crs ); + } - // Take the first CRS of the list - break; + if ( !mCrsList.contains( crs.authid() ) ) + { + mCrsList.append( crs.authid() ); + } } } } } + if ( mCrsList.isEmpty() ) + { + QgsCoordinateReferenceSystem crs = QgsCoordinateReferenceSystem::fromOgcWmsCrs( + QgsOapifProvider::OAPIF_PROVIDER_DEFAULT_CRS ); + mLayerMetadata.setCrs( QgsCoordinateReferenceSystem::fromOgcWmsCrs( crs.authid() ) ); + mCrsList.append( crs.authid() ); + } + return true; } @@ -408,7 +458,7 @@ void QgsOapifCollectionsRequest::processReply() for ( const auto &jCollection : collections ) { QgsOapifCollection collection; - if ( collection.deserialize( jCollection ) ) + if ( collection.deserialize( jCollection, j ) ) { if ( collection.mLayerMetadata.licenses().isEmpty() ) { @@ -502,7 +552,7 @@ void QgsOapifCollectionRequest::processReply() try { const json j = json::parse( utf8Text.toStdString() ); - mCollection.deserialize( j ); + mCollection.deserialize( j, json() ); } catch ( const json::parse_error &ex ) { diff --git a/src/providers/wfs/oapif/qgsoapifcollection.h b/src/providers/wfs/oapif/qgsoapifcollection.h index 1f2a587adcdb..699a606d4973 100644 --- a/src/providers/wfs/oapif/qgsoapifcollection.h +++ b/src/providers/wfs/oapif/qgsoapifcollection.h @@ -39,14 +39,20 @@ struct QgsOapifCollection //! Description QString mDescription; - //! Bounding box (in CRS84) + //! Bounding box QgsRectangle mBbox; + //! Bounding box Crs + QgsCoordinateReferenceSystem mBboxCrs; + + //! List of available CRS + QList mCrsList; + //! Layer metadata QgsLayerMetadata mLayerMetadata; //! Fills a collection from its JSON serialization - bool deserialize( const json &j ); + bool deserialize( const json &j, const json &jCollections ); }; //! Manages the /collections request diff --git a/src/providers/wfs/oapif/qgsoapifconformancerequest.cpp b/src/providers/wfs/oapif/qgsoapifconformancerequest.cpp new file mode 100644 index 000000000000..3b713871c916 --- /dev/null +++ b/src/providers/wfs/oapif/qgsoapifconformancerequest.cpp @@ -0,0 +1,100 @@ +/*************************************************************************** + qgsoapifconformancerequest.cpp + ------------------------------ + begin : April 2023 + copyright : (C) 2023 by Even Rouault + email : even.rouault at spatialys.com + *************************************************************************** + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + ***************************************************************************/ + +#include +using namespace nlohmann; + +#include "qgslogger.h" +#include "qgsoapifconformancerequest.h" +#include "qgsoapifutils.h" +#include "qgswfsconstants.h" + +#include + +QgsOapifConformanceRequest::QgsOapifConformanceRequest( const QgsDataSourceUri &uri ): + QgsBaseNetworkRequest( QgsAuthorizationSettings( uri.username(), uri.password(), uri.authConfigId() ), "OAPIF" ) +{ + // Using Qt::DirectConnection since the download might be running on a different thread. + // In this case, the request was sent from the main thread and is executed with the main + // thread being blocked in future.waitForFinished() so we can run code on this object which + // lives in the main thread without risking havoc. + connect( this, &QgsBaseNetworkRequest::downloadFinished, this, &QgsOapifConformanceRequest::processReply, Qt::DirectConnection ); +} + +QStringList QgsOapifConformanceRequest::conformanceClasses( const QUrl &conformanceUrl ) +{ + sendGET( conformanceUrl, QString( "application/json" ), /*synchronous=*/true, /*forceRefresh=*/false ); + return mConformanceClasses; +} + +QString QgsOapifConformanceRequest::errorMessageWithReason( const QString &reason ) +{ + return tr( "Download of conformance classes failed: %1" ).arg( reason ); +} + +void QgsOapifConformanceRequest::processReply() +{ + if ( mErrorCode != QgsBaseNetworkRequest::NoError ) + { + return; + } + const QByteArray &buffer = mResponse; + if ( buffer.isEmpty() ) + { + mErrorMessage = tr( "empty response" ); + mErrorCode = QgsBaseNetworkRequest::ServerExceptionError; + return; + } + + QgsDebugMsgLevel( QStringLiteral( "parsing Conformance response: " ) + buffer, 4 ); + + QTextCodec::ConverterState state; + QTextCodec *codec = QTextCodec::codecForName( "UTF-8" ); + Q_ASSERT( codec ); + + const QString utf8Text = codec->toUnicode( buffer.constData(), buffer.size(), &state ); + if ( state.invalidChars != 0 ) + { + mErrorCode = QgsBaseNetworkRequest::ApplicationLevelError; + mErrorMessage = errorMessageWithReason( tr( "Invalid UTF-8 content" ) ); + return; + } + + try + { + const json j = json::parse( utf8Text.toStdString() ); + + if ( j.is_object() && j.contains( "conformsTo" ) ) + { + const json jConformsTo = j["conformsTo"]; + if ( jConformsTo.is_array() ) + { + for ( const auto &subj : jConformsTo ) + { + if ( subj.is_string() ) + { + mConformanceClasses.append( QString::fromStdString( subj.get() ) ); + } + } + } + } + } + catch ( const json::parse_error &ex ) + { + mErrorCode = QgsBaseNetworkRequest::ApplicationLevelError; + mErrorMessage = errorMessageWithReason( tr( "Cannot decode JSON document: %1" ).arg( QString::fromStdString( ex.what() ) ) ); + return; + } +} diff --git a/src/providers/wfs/oapif/qgsoapifconformancerequest.h b/src/providers/wfs/oapif/qgsoapifconformancerequest.h new file mode 100644 index 000000000000..699b6318b978 --- /dev/null +++ b/src/providers/wfs/oapif/qgsoapifconformancerequest.h @@ -0,0 +1,44 @@ +/*************************************************************************** + qgsoapifconformancerequest.h + ----------------------------- + begin : April 2023 + copyright : (C) 2023 by Even Rouault + email : even.rouault at spatialys.com + *************************************************************************** + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + ***************************************************************************/ + +#ifndef QGSOAPIFCONFORMANCEREQUEST_H +#define QGSOAPIFCONFORMANCEREQUEST_H + +#include + +#include "qgsdatasourceuri.h" +#include "qgsbasenetworkrequest.h" + +//! Manages the conformance request +class QgsOapifConformanceRequest : public QgsBaseNetworkRequest +{ + Q_OBJECT + public: + explicit QgsOapifConformanceRequest( const QgsDataSourceUri &uri ); + + //! Issue the request synchronously and return conformance classes + QStringList conformanceClasses( const QUrl &conformanceUrl ); + + private slots: + void processReply(); + + private: + QStringList mConformanceClasses; + + protected: + QString errorMessageWithReason( const QString &reason ) override; +}; + +#endif // QGSOAPIFCONFORMANCEREQUEST_H diff --git a/src/providers/wfs/oapif/qgsoapiflandingpagerequest.cpp b/src/providers/wfs/oapif/qgsoapiflandingpagerequest.cpp index 44cc2e5006de..3c94b28312ce 100644 --- a/src/providers/wfs/oapif/qgsoapiflandingpagerequest.cpp +++ b/src/providers/wfs/oapif/qgsoapiflandingpagerequest.cpp @@ -124,6 +124,8 @@ void QgsOapifLandingPageRequest::processReply() apiTypes ); } #endif + + mConformanceUrl = QgsOAPIFJson::findLink( links, QStringLiteral( "conformance" ) ); } catch ( const json::parse_error &ex ) { diff --git a/src/providers/wfs/oapif/qgsoapiflandingpagerequest.h b/src/providers/wfs/oapif/qgsoapiflandingpagerequest.h index afba26a96003..766ca7e037dd 100644 --- a/src/providers/wfs/oapif/qgsoapiflandingpagerequest.h +++ b/src/providers/wfs/oapif/qgsoapiflandingpagerequest.h @@ -45,9 +45,12 @@ class QgsOapifLandingPageRequest : public QgsBaseNetworkRequest //! Return URL of the api endpoint const QString &apiUrl() const { return mApiUrl; } - //! Return URL of the api endpoint + //! Return URL of the collections endpoint const QString &collectionsUrl() const { return mCollectionsUrl; } + //! Return URL of the conformance endpoint + const QString &conformanceUrl() const { return mConformanceUrl; } + signals: //! emitted when the capabilities have been fully parsed, or an error occurred void gotResponse(); @@ -67,6 +70,9 @@ class QgsOapifLandingPageRequest : public QgsBaseNetworkRequest //! URL of the collections endpoint. QString mCollectionsUrl; + //! URL of the conformance endpoint. + QString mConformanceUrl; + ApplicationLevelError mAppLevelError = ApplicationLevelError::NoError; }; diff --git a/src/providers/wfs/oapif/qgsoapifprovider.cpp b/src/providers/wfs/oapif/qgsoapifprovider.cpp index 7c5623b7b40c..180af7a3f0e7 100644 --- a/src/providers/wfs/oapif/qgsoapifprovider.cpp +++ b/src/providers/wfs/oapif/qgsoapifprovider.cpp @@ -20,6 +20,7 @@ #include "qgsoapiflandingpagerequest.h" #include "qgsoapifapirequest.h" #include "qgsoapifcollection.h" +#include "qgsoapifconformancerequest.h" #include "qgsoapifitemsrequest.h" #include "qgswfsconstants.h" #include "qgswfsutils.h" // for isCompatibleType() @@ -151,20 +152,50 @@ bool QgsOapifProvider::init() return false; } } - mShared->mCapabilityExtent = collectionRequest->collection().mBbox; + + bool implementsPart2 = false; + const QString &conformanceUrl = landingPageRequest.conformanceUrl(); + if ( !conformanceUrl.isEmpty() ) + { + QgsOapifConformanceRequest conformanceRequest( mShared->mURI.uri() ); + const QStringList conformanceClasses = conformanceRequest.conformanceClasses( conformanceUrl ); + implementsPart2 = conformanceClasses.contains( QLatin1String( "http://www.opengis.net/spec/ogcapi-features-2/1.0/conf/crs" ) ); + } + mLayerMetadata = collectionRequest->collection().mLayerMetadata; - if ( mLayerMetadata.crs().isValid() ) + QString srsName = mShared->mURI.SRSName(); + if ( implementsPart2 && !srsName.isEmpty() ) + { + // Use URI SRSName parameter if defined + mShared->mSourceCrs = QgsCoordinateReferenceSystem::fromOgcWmsCrs( srsName ); + if ( mLayerMetadata.crs().isValid() && mShared->mSourceCrs.authid() == mLayerMetadata.crs().authid() ) + mShared->mSourceCrs.setCoordinateEpoch( mLayerMetadata.crs().coordinateEpoch() ); + } + else if ( implementsPart2 && mLayerMetadata.crs().isValid() ) { // WORKAROUND: Recreate a CRS object with fromOgcWmsCrs because when copying the // CRS his mPj pointer gets deleted and it is impossible to create a transform mShared->mSourceCrs = QgsCoordinateReferenceSystem::fromOgcWmsCrs( mLayerMetadata.crs().authid() ); + mShared->mSourceCrs.setCoordinateEpoch( mLayerMetadata.crs().coordinateEpoch() ); } else { mShared->mSourceCrs = QgsCoordinateReferenceSystem::fromOgcWmsCrs( QgsOapifProvider::OAPIF_PROVIDER_DEFAULT_CRS ); } + mShared->mCapabilityExtent = collectionRequest->collection().mBbox; + + // Reproject extent of /collection request to the layer CRS + if ( !mShared->mCapabilityExtent.isNull() && + collectionRequest->collection().mBboxCrs != mShared->mSourceCrs ) + { + QgsCoordinateTransform ct( collectionRequest->collection().mBboxCrs, mShared->mSourceCrs, transformContext() ); + ct.setBallparkTransformsAreAppropriate( true ); + QgsDebugMsgLevel( "before ext:" + mShared->mCapabilityExtent.toString(), 4 ); + mShared->mCapabilityExtent = ct.transformBoundingBox( mShared->mCapabilityExtent ); + QgsDebugMsgLevel( "after ext:" + mShared->mCapabilityExtent.toString(), 4 ); + } // Merge contact info from /api mLayerMetadata.setContacts( apiRequest.metadata().contacts() ); @@ -788,6 +819,10 @@ void QgsOapifFeatureDownloaderImpl::run( bool serializeFeatures, long long maxFe } } + if ( mShared->mSourceCrs + != QgsCoordinateReferenceSystem::fromOgcWmsCrs( QgsOapifProvider::OAPIF_PROVIDER_DEFAULT_CRS ) ) + url += QStringLiteral( "&crs=%1" ).arg( mShared->mSourceCrs.toOgcUri() ); + while ( !url.isEmpty() ) { url = mShared->appendExtraQueryParameters( url ); @@ -843,7 +878,13 @@ void QgsOapifFeatureDownloaderImpl::run( bool serializeFeatures, long long maxFe // as the layer, convert them const QgsFeature &f = pair.first; QgsFeature dstFeat( dstFields, f.id() ); - dstFeat.setGeometry( f.geometry() ); + if ( f.hasGeometry() ) + { + QgsGeometry g = f.geometry(); + if ( mShared->mSourceCrs.hasAxisInverted() ) + g.transform( QTransform( 0, 1, 1, 0, 0, 0 ) ); + dstFeat.setGeometry( g ); + } const auto srcAttrs = f.attributes(); for ( int j = 0; j < dstFields.size(); j++ ) { diff --git a/src/providers/wfs/qgsbasenetworkrequest.cpp b/src/providers/wfs/qgsbasenetworkrequest.cpp index 87b3dc90a819..d4ba883a918b 100644 --- a/src/providers/wfs/qgsbasenetworkrequest.cpp +++ b/src/providers/wfs/qgsbasenetworkrequest.cpp @@ -122,12 +122,6 @@ bool QgsBaseNetworkRequest::sendGET( const QUrl &url, const QString &acceptHeade } #endif - // For REST API using URL subpaths, normalize the subpaths - const int afterEndpointStartPos = modifiedUrlString.indexOf( "fake_qgis_http_endpoint" ) + strlen( "fake_qgis_http_endpoint" ); - QString afterEndpointStart = modifiedUrlString.mid( afterEndpointStartPos ); - afterEndpointStart.replace( QLatin1String( "/" ), QLatin1String( "_" ) ); - modifiedUrlString = modifiedUrlString.mid( 0, afterEndpointStartPos ) + afterEndpointStart; - if ( !acceptHeader.isEmpty() ) { if ( modifiedUrlString.indexOf( '?' ) > 0 ) @@ -139,6 +133,13 @@ bool QgsBaseNetworkRequest::sendGET( const QUrl &url, const QString &acceptHeade modifiedUrlString += QStringLiteral( "?Accept=" ) + acceptHeader; } } + + // For REST API using URL subpaths, normalize the subpaths + const int afterEndpointStartPos = static_cast( modifiedUrlString.indexOf( "fake_qgis_http_endpoint" ) + strlen( "fake_qgis_http_endpoint" ) ); + QString afterEndpointStart = modifiedUrlString.mid( afterEndpointStartPos ); + afterEndpointStart.replace( QLatin1String( "/" ), QLatin1String( "_" ) ); + modifiedUrlString = modifiedUrlString.mid( 0, afterEndpointStartPos ) + afterEndpointStart; + const auto posQuotationMark = modifiedUrlString.indexOf( '?' ); if ( posQuotationMark > 0 ) { diff --git a/src/providers/wfs/qgswfssourceselect.cpp b/src/providers/wfs/qgswfssourceselect.cpp index bc89474494fc..8602c2c52376 100644 --- a/src/providers/wfs/qgswfssourceselect.cpp +++ b/src/providers/wfs/qgswfssourceselect.cpp @@ -176,35 +176,33 @@ void QgsWFSSourceSelect::populateConnectionList() changeConnection(); } -QString QgsWFSSourceSelect::getPreferredCrs( const QSet &crsSet ) const +QString QgsWFSSourceSelect::getPreferredCrs( const QList &crsList ) const { - if ( crsSet.size() < 1 ) + if ( crsList.size() < 1 ) { return QString(); } - //first: project CRS - QgsCoordinateReferenceSystem projectRefSys = QgsProject::instance()->crs(); - //convert to EPSG - QString ProjectCRS; - if ( projectRefSys.isValid() ) + //first: project CRS (if the project is not empty) + QgsProject *project = QgsProject::instance(); + if ( !project->mapLayers().isEmpty() ) { - ProjectCRS = projectRefSys.authid(); - } - - if ( !ProjectCRS.isEmpty() && crsSet.contains( ProjectCRS ) ) - { - return ProjectCRS; - } + QgsCoordinateReferenceSystem projectRefSys = QgsProject::instance()->crs(); + //convert to EPSG + QString projectCRS; + if ( projectRefSys.isValid() ) + { + projectCRS = projectRefSys.authid(); + } - //second: WGS84 - if ( crsSet.contains( geoEpsgCrsAuthId() ) ) - { - return geoEpsgCrsAuthId(); + if ( !projectCRS.isEmpty() && crsList.contains( projectCRS ) ) + { + return projectCRS; + } } - //third: first entry in set - return *( crsSet.constBegin() ); + //otherwise: first entry in set + return crsList[0]; } void QgsWFSSourceSelect::refresh() @@ -380,6 +378,7 @@ void QgsWFSSourceSelect::oapifCollectionsReplyFinished() return; } + mAvailableCRS.clear(); for ( const auto &collection : mOAPIFCollections->collections() ) { // insert the typenames, titles and abstracts into the tree view @@ -393,8 +392,8 @@ void QgsWFSSourceSelect::oapifCollectionsReplyFinished() typedef QList< QStandardItem * > StandardItemList; mModel->appendRow( StandardItemList() << titleItem << nameItem << abstractItem << filterItem ); - gbCRS->setEnabled( false ); - labelCoordRefSys->setText( collection.mLayerMetadata.crs().authid() ); + // insert the available CRS into mAvailableCRS + mAvailableCRS.insert( collection.mId, collection.mCrsList ); } if ( !mOAPIFCollections->nextUrl().isEmpty() ) @@ -704,7 +703,7 @@ void QgsWFSSourceSelect::changeCRSFilter() mProjectionSelector = new QgsProjectionSelectionDialog( this ); mProjectionSelector->setOgcWmsCrsFilter( crsNames ); - QString preferredCRS = getPreferredCrs( crsNames ); //get preferred EPSG system + QString preferredCRS = getPreferredCrs( *crsIterator ); //get preferred EPSG system if ( !preferredCRS.isEmpty() ) { QgsCoordinateReferenceSystem refSys = QgsCoordinateReferenceSystem::fromOgcWmsCrs( preferredCRS ); diff --git a/src/providers/wfs/qgswfssourceselect.h b/src/providers/wfs/qgswfssourceselect.h index 5e03f87094a9..020b0c463402 100644 --- a/src/providers/wfs/qgswfssourceselect.h +++ b/src/providers/wfs/qgswfssourceselect.h @@ -80,15 +80,14 @@ class QgsWFSSourceSelect: public QgsAbstractDataSourceWidget, private Ui::QgsWFS QString mVersion; /** - * Returns the best suited CRS from a set of authority ids + * Returns the best suited CRS from a list of authority ids * - * 1. project CRS if contained in the set - * 2. WGS84 if contained in the set - * 3. the first entry in the set else + * 1. project CRS if contained in the list and the project is not empty + * 2. the first entry in the list else * * \returns the authority id of the crs or an empty string in case of error */ - QString getPreferredCrs( const QSet &crsSet ) const; + QString getPreferredCrs( const QList &crsList ) const; void showHelp(); diff --git a/tests/src/python/test_provider_oapif.py b/tests/src/python/test_provider_oapif.py index 847da93547c2..e6d929980859 100644 --- a/tests/src/python/test_provider_oapif.py +++ b/tests/src/python/test_provider_oapif.py @@ -31,15 +31,29 @@ from providertestbase import ProviderTestCase -def sanitize(endpoint, x): - if len(endpoint + x) > 256: - ret = endpoint + hashlib.md5(x.replace('/', '_').encode()).hexdigest() - # print('Before: ' + endpoint + x) +def sanitize(endpoint, query_params): + # Implement the logic of QgsBaseNetworkRequest::sendGET() + # Note query_params can actually contain subpaths, so create the full url + # by concatenating boths, and then figure out things... + + url = endpoint + query_params + # For REST API using URL subpaths, normalize the subpaths + afterEndpointStartPos = url.find("fake_qgis_http_endpoint") + len("fake_qgis_http_endpoint") + afterEndpointStart = url[afterEndpointStartPos:] + afterEndpointStart = afterEndpointStart.replace('/', '_') + url = url[0:afterEndpointStartPos] + afterEndpointStart + posQuotationMark = url.find('?') + endpoint = url[0:posQuotationMark] + query_params = url[posQuotationMark:] + + if len(endpoint + query_params) > 256: + ret = endpoint + hashlib.md5(query_params.encode()).hexdigest() + # print('Before: ' + endpoint + query_params) # print('After: ' + ret) return ret - ret = endpoint + x.replace('?', '_').replace('&', '_').replace('<', '_').replace('>', '_').replace('"', - '_').replace("'", - '_').replace( + ret = endpoint + query_params.replace('?', '_').replace('&', '_').replace('<', '_').replace('>', '_').replace('"', + '_').replace("'", + '_').replace( ' ', '_').replace(':', '_').replace('/', '_').replace('\n', '_') return ret @@ -51,12 +65,14 @@ def GDAL_COMPUTE_VERSION(maj, min, rev): ACCEPT_LANDING = 'Accept=application/json' ACCEPT_API = 'Accept=application/vnd.oai.openapi+json;version=3.0, application/openapi+json;version=3.0, application/json' ACCEPT_COLLECTION = 'Accept=application/json' +ACCEPT_CONFORMANCE = 'Accept=application/json' ACCEPT_ITEMS = 'Accept=application/geo+json, application/json' def create_landing_page_api_collection(endpoint, extraparam='', - crs_url="http://www.opengis.net/def/crs/EPSG/0/4326", + storageCrs=None, + crsList=None, bbox=[-71.123, 66.33, -65.32, 78.3]): questionmark_extraparam = '?' + extraparam if extraparam else '' @@ -71,7 +87,8 @@ def add_params(x, y): f.write(json.dumps({ "links": [ {"href": "http://" + endpoint + "/api" + questionmark_extraparam, "rel": "service-desc"}, - {"href": "http://" + endpoint + "/collections" + questionmark_extraparam, "rel": "data"} + {"href": "http://" + endpoint + "/collections" + questionmark_extraparam, "rel": "data"}, + {"href": "http://" + endpoint + "/conformance" + questionmark_extraparam, "rel": "conformance"}, ]}).encode('UTF-8')) # API @@ -89,6 +106,12 @@ def add_params(x, y): } }).encode('UTF-8')) + # conformance + with open(sanitize(endpoint, '/conformance?' + add_params(extraparam, ACCEPT_CONFORMANCE)), 'wb') as f: + f.write(json.dumps({ + "conformsTo": ["http://www.opengis.net/spec/ogcapi-features-2/1.0/conf/crs"] + }).encode('UTF-8')) + # collection collection = { "id": "mycollection", @@ -102,9 +125,10 @@ def add_params(x, y): } } } - if crs_url: - collection["crs"] = [crs_url] - collection["extent"]["spatial"]["crs"] = crs_url + if storageCrs: + collection["storageCrs"] = storageCrs + if crsList: + collection["crs"] = crsList with open(sanitize(endpoint, '/collections/mycollection?' + add_params(extraparam, ACCEPT_COLLECTION)), 'wb') as f: f.write(json.dumps(collection).encode('UTF-8')) @@ -175,6 +199,9 @@ def tearDownClass(cls): shutil.rmtree(cls.basetestpath, True) cls.vl = None # so as to properly close the provider and remove any temporary file + def testCrs(self): + self.assertEqual(self.source.sourceCrs().authid(), 'OGC:CRS84') + def testExtentSubsetString(self): # can't run the base provider test suite here - WFS/OAPIF extent handling is different # to other providers @@ -253,14 +280,14 @@ def testFeaturePaging(self): def testBbox(self): endpoint = self.__class__.basetestpath + '/fake_qgis_http_endpoint_testBbox' - create_landing_page_api_collection(endpoint) + create_landing_page_api_collection(endpoint, storageCrs="http://www.opengis.net/def/crs/EPSG/0/4326") # first items first_items = { "type": "FeatureCollection", "features": [ {"type": "Feature", "id": "feat.1", "properties": {"pk": 1, "cnt": 100}, - "geometry": {"type": "Point", "coordinates": [-70.332, 66.33]}} + "geometry": {"type": "Point", "coordinates": [66.33, -70.332]}} ] } with open(sanitize(endpoint, '/collections/mycollection/items?limit=10&' + ACCEPT_ITEMS), 'wb') as f: @@ -274,12 +301,12 @@ def testBbox(self): "type": "FeatureCollection", "features": [ {"type": "Feature", "id": "feat.1", "properties": {"pk": 1, "cnt": 100}, - "geometry": {"type": "Point", "coordinates": [-70.332, 66.33]}}, + "geometry": {"type": "Point", "coordinates": [66.33, -70.332]}}, {"type": "Feature", "id": "feat.2", "properties": {"pk": 2, "cnt": 200}, - "geometry": {"type": "Point", "coordinates": [-68.2, 70.8]}} + "geometry": {"type": "Point", "coordinates": [70.8, -68.2]}} ] } - with open(sanitize(endpoint, '/collections/mycollection/items?limit=1000&bbox=65.5,-71,78,-65&bbox-crs=http://www.opengis.net/def/crs/EPSG/0/4326&' + ACCEPT_ITEMS), + with open(sanitize(endpoint, '/collections/mycollection/items?limit=1000&bbox=65.5,-71,78,-65&bbox-crs=http://www.opengis.net/def/crs/EPSG/0/4326&crs=http://www.opengis.net/def/crs/EPSG/0/4326&' + ACCEPT_ITEMS), 'wb') as f: f.write(json.dumps(items).encode('UTF-8')) @@ -297,7 +324,7 @@ def testBbox(self): # Test clamping of bbox with open( - sanitize(endpoint, '/collections/mycollection/items?limit=1000&bbox=64.5,-180,78,-65&bbox-crs=http://www.opengis.net/def/crs/EPSG/0/4326&' + ACCEPT_ITEMS), + sanitize(endpoint, '/collections/mycollection/items?limit=1000&bbox=64.5,-180,78,-65&bbox-crs=http://www.opengis.net/def/crs/EPSG/0/4326&crs=http://www.opengis.net/def/crs/EPSG/0/4326&' + ACCEPT_ITEMS), 'wb') as f: f.write(json.dumps(items).encode('UTF-8')) @@ -317,14 +344,14 @@ def testBbox(self): "type": "FeatureCollection", "features": [ {"type": "Feature", "id": "feat.1", "properties": {"pk": 1, "cnt": 100}, - "geometry": {"type": "Point", "coordinates": [-70.332, 66.33]}}, + "geometry": {"type": "Point", "coordinates": [66.33, -70.332]}}, {"type": "Feature", "id": "feat.2", "properties": {"pk": 2, "cnt": 200}, - "geometry": {"type": "Point", "coordinates": [-68.2, 70.8]}}, + "geometry": {"type": "Point", "coordinates": [70.8, -68.2]}}, {"type": "Feature", "id": "feat.3", "properties": {"pk": 4, "cnt": 400}, - "geometry": {"type": "Point", "coordinates": [-65.32, 78.3]}} + "geometry": {"type": "Point", "coordinates": [78.3, -65.32]}} ] } - with open(sanitize(endpoint, '/collections/mycollection/items?limit=1000&bbox=-90,-180,90,180&bbox-crs=http://www.opengis.net/def/crs/EPSG/0/4326&' + ACCEPT_ITEMS), 'wb') as f: + with open(sanitize(endpoint, '/collections/mycollection/items?limit=1000&bbox=-90,-180,90,180&bbox-crs=http://www.opengis.net/def/crs/EPSG/0/4326&crs=http://www.opengis.net/def/crs/EPSG/0/4326&' + ACCEPT_ITEMS), 'wb') as f: f.write(json.dumps(items).encode('UTF-8')) extent = QgsRectangle(-181, -91, 181, 91) @@ -397,8 +424,7 @@ def testLayerMetadata(self): ["1980-01-01T12:34:56.789Z", None], [None, "2020-01-01T00:00:00Z"] ] - }, - "crs": ["http://www.opengis.net/def/crs/EPSG/0/4326"] + } }, "links": [ {"href": "href_self", "rel": "self", "type": "application/json", "title": "my self link"}, @@ -456,6 +482,7 @@ def testLayerMetadata(self): assert md.keywords()['keywords'] == ["keyword_a", "keyword_b"] assert md.crs().isValid() + assert md.crs().authid() == "OGC:CRS84" assert md.crs().isGeographic() assert not md.crs().hasAxisInverted() @@ -554,6 +581,40 @@ def testLayerMetadata(self): assert len(md.licenses()) == 1 assert md.licenses()[0] == 'proprietary' + # Variant with storageCrs + collection = copy.deepcopy(base_collection) + collection['storageCrs'] = "http://www.opengis.net/def/crs/EPSG/0/4258" + collection['storageCrsCoordinateEpoch'] = 2020.0 + with open(sanitize(endpoint, '/collections/mycollection?' + ACCEPT_COLLECTION), 'wb') as f: + f.write(json.dumps(collection).encode('UTF-8')) + + vl = QgsVectorLayer("url='http://" + endpoint + "' typename='mycollection' restrictToRequestBBOX=1", 'test', + 'OAPIF') + self.assertTrue(vl.isValid()) + + md = vl.metadata() + assert vl.sourceCrs().isValid() + assert vl.sourceCrs().authid() == "EPSG:4258" + assert vl.sourceCrs().isGeographic() + assert vl.sourceCrs().coordinateEpoch() == 2020.0 + assert vl.sourceCrs().hasAxisInverted() + + # Variant with a list of crs + collection = copy.deepcopy(base_collection) + collection['crs'] = ["http://www.opengis.net/def/crs/EPSG/0/4258", "http://www.opengis.net/def/crs/EPSG/0/4326"] + with open(sanitize(endpoint, '/collections/mycollection?' + ACCEPT_COLLECTION), 'wb') as f: + f.write(json.dumps(collection).encode('UTF-8')) + + vl = QgsVectorLayer("url='http://" + endpoint + "' typename='mycollection' restrictToRequestBBOX=1", 'test', + 'OAPIF') + self.assertTrue(vl.isValid()) + + md = vl.metadata() + assert vl.sourceCrs().isValid() + assert vl.sourceCrs().authid() == "EPSG:4258" + assert vl.sourceCrs().isGeographic() + assert vl.sourceCrs().hasAxisInverted() + def testDateTimeFiltering(self): endpoint = self.__class__.basetestpath + '/fake_qgis_http_endpoint_testDateTimeFiltering' @@ -722,7 +783,6 @@ def testDefaultCRS(self): endpoint = basetestpath + '/fake_qgis_http_endpoint_ogc84' create_landing_page_api_collection(endpoint, - crs_url="", # OGC norm says that if crs is not explicitly defined it is OGC:CRS84 bbox=[66.33, -71.123, 78.3, -65.32]) items = { @@ -769,8 +829,8 @@ def testCRS2056(self): endpoint = basetestpath + '/fake_qgis_http_endpoint_epsg_2056' create_landing_page_api_collection(endpoint, - crs_url="http://www.opengis.net/def/crs/EPSG/0/2056", - bbox=[2508500, 1152000, 2513450, 1156950]) + storageCrs="http://www.opengis.net/def/crs/EPSG/0/2056", + crsList=["http://www.opengis.net/def/crs/OGC/0/CRS84", "http://www.opengis.net/def/crs/EPSG/0/2056"]) items = { "type": "FeatureCollection", @@ -808,6 +868,13 @@ def testCRS2056(self): self.assertEqual(source.sourceCrs().authid(), 'EPSG:2056') + # Test srsname parameter overrides default CRS + vl = QgsVectorLayer("url='http://" + endpoint + "' typename='mycollection' srsname='OGC:CRS84'", 'test', 'OAPIF') + assert vl.isValid() + source = vl.dataProvider() + + self.assertEqual(source.sourceCrs().authid(), 'OGC:CRS84') + def testFeatureCountFallback(self): # On Windows we must make sure that any backslash in the path is @@ -815,9 +882,7 @@ def testFeatureCountFallback(self): basetestpath = tempfile.mkdtemp().replace('\\', '/') endpoint = basetestpath + '/fake_qgis_http_endpoint_feature_count_fallback' - create_landing_page_api_collection(endpoint, - crs_url="http://www.opengis.net/def/crs/EPSG/0/2056", - bbox=[2508500, 1152000, 2513450, 1156950]) + create_landing_page_api_collection(endpoint, storageCrs="http://www.opengis.net/def/crs/EPSG/0/2056") items = { "type": "FeatureCollection", @@ -846,7 +911,7 @@ def testFeatureCountFallback(self): f.write(json.dumps(items).encode('UTF-8')) # real page - with open(sanitize(endpoint, '/collections/mycollection/items?limit=1000&' + ACCEPT_ITEMS), 'wb') as f: + with open(sanitize(endpoint, '/collections/mycollection/items?limit=1000&crs=http://www.opengis.net/def/crs/EPSG/0/2056&' + ACCEPT_ITEMS), 'wb') as f: f.write(json.dumps(items).encode('UTF-8')) # Create test layer