diff --git a/python/core/auto_generated/qgsaction.sip.in b/python/core/auto_generated/qgsaction.sip.in
index 71ae7de3669d..29a4eb531db0 100644
--- a/python/core/auto_generated/qgsaction.sip.in
+++ b/python/core/auto_generated/qgsaction.sip.in
@@ -27,6 +27,8 @@ Utility class that encapsulates an action based on vector attributes.
Windows,
Unix,
OpenUrl,
+ SubmitUrlEncoded,
+ SubmitUrlMultipart,
};
QgsAction();
diff --git a/resources/function_help/json/url_encode b/resources/function_help/json/url_encode
new file mode 100644
index 000000000000..0cd91eab1aac
--- /dev/null
+++ b/resources/function_help/json/url_encode
@@ -0,0 +1,18 @@
+{
+ "name": "url_encode",
+ "type": "function",
+ "groups": ["Maps"],
+ "description": "Returns an URL encoded string from a map. Transforms all characters in their properly-encoded form producing a fully-compliant query string.
Note that the plus sign '+' is not converted.",
+ "arguments": [
+ {
+ "arg": "map",
+ "description": "a map."
+ }
+ ],
+ "examples": [
+ {
+ "expression": "url_encode(map('a&+b', 'a and plus b', 'a=b', 'a equals b'))",
+ "returns": "'a%26+b=a%20and%20plus%20b&a%3Db=a%20equals%20b'"
+ }
+ ]
+}
diff --git a/src/core/expression/qgsexpressionfunction.cpp b/src/core/expression/qgsexpressionfunction.cpp
index 530373ee388c..52f43be2d13c 100644
--- a/src/core/expression/qgsexpressionfunction.cpp
+++ b/src/core/expression/qgsexpressionfunction.cpp
@@ -69,6 +69,7 @@
#include
#include
#include
+#include
typedef QList ExpressionFunctionList;
@@ -6536,6 +6537,17 @@ static QVariant fcnToBase64( const QVariantList &values, const QgsExpressionCont
return QVariant( QString( input.toBase64() ) );
}
+static QVariant fcnToFormUrlEncode( const QVariantList &values, const QgsExpressionContext *, QgsExpression *parent, const QgsExpressionNodeFunction * )
+{
+ const QVariantMap map = QgsExpressionUtils::getMapValue( values.at( 0 ), parent );
+ QUrlQuery query;
+ for ( auto it = map.cbegin(); it != map.cend(); it++ )
+ {
+ query.addQueryItem( it.key(), it.value().toString() );
+ }
+ return query.toString( QUrl::ComponentFormattingOption::FullyEncoded );
+}
+
static QVariant fcnFromBase64( const QVariantList &values, const QgsExpressionContext *, QgsExpression *parent, const QgsExpressionNodeFunction * )
{
const QString value = QgsExpressionUtils::getStringValue( values.at( 0 ), parent );
@@ -8020,6 +8032,8 @@ const QList &QgsExpression::Functions()
<< new QgsStaticExpressionFunction( QStringLiteral( "map_prefix_keys" ), QgsExpressionFunction::ParameterList() << QgsExpressionFunction::Parameter( QStringLiteral( "map" ) )
<< QgsExpressionFunction::Parameter( QStringLiteral( "prefix" ) ),
fcnMapPrefixKeys, QStringLiteral( "Maps" ) )
+ << new QgsStaticExpressionFunction( QStringLiteral( "url_encode" ), QgsExpressionFunction::ParameterList() << QgsExpressionFunction::Parameter( QStringLiteral( "map" ) ),
+ fcnToFormUrlEncode, QStringLiteral( "Maps" ) )
;
diff --git a/src/core/network/qgsnetworkaccessmanager.cpp b/src/core/network/qgsnetworkaccessmanager.cpp
index 01c67c08ec65..651c2002af97 100644
--- a/src/core/network/qgsnetworkaccessmanager.cpp
+++ b/src/core/network/qgsnetworkaccessmanager.cpp
@@ -353,6 +353,11 @@ QNetworkReply *QgsNetworkAccessManager::createRequest( QNetworkAccessManager::Op
{
content = buffer->buffer();
}
+ else if ( outgoingData )
+ {
+ content = outgoingData->readAll();
+ outgoingData->seek( 0 );
+ }
emit requestAboutToBeCreated( QgsNetworkRequestParameters( op, req, requestId, content ) );
Q_NOWARN_DEPRECATED_PUSH
diff --git a/src/core/qgsaction.cpp b/src/core/qgsaction.cpp
index 261d3ba5340c..60783ea2498a 100644
--- a/src/core/qgsaction.cpp
+++ b/src/core/qgsaction.cpp
@@ -19,6 +19,13 @@
#include
#include
#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
#include "qgspythonrunner.h"
#include "qgsrunprocess.h"
@@ -26,12 +33,18 @@
#include "qgsvectorlayer.h"
#include "qgslogger.h"
#include "qgsexpressioncontextutils.h"
+#include "qgswebview.h"
+#include "qgsnetworkaccessmanager.h"
+#include "qgsmessagelog.h"
+
bool QgsAction::runable() const
{
return mType == Generic ||
mType == GenericPython ||
mType == OpenUrl ||
+ mType == SubmitUrlEncoded ||
+ mType == SubmitUrlMultipart ||
#if defined(Q_OS_WIN)
mType == Windows
#elif defined(Q_OS_MAC)
@@ -52,6 +65,173 @@ void QgsAction::run( QgsVectorLayer *layer, const QgsFeature &feature, const Qgs
run( actionContext );
}
+void QgsAction::handleFormSubmitAction( const QString &expandedAction ) const
+{
+
+ QUrl url{ expandedAction };
+
+ // Encode '+' (fully encoded doesn't encode it)
+ const QString payload { url.query( QUrl::ComponentFormattingOption::FullyEncoded ).replace( QChar( '+' ), QStringLiteral( "%2B" ) ) };
+
+ // Remove query string from URL
+ const QUrlQuery queryString { url.query( ) };
+ url.setQuery( QString( ) );
+
+ QNetworkRequest req { url };
+
+ // Specific code for testing, produces an invalid POST but we can still listen to
+ // signals and examine the request
+ if ( url.toString().contains( QLatin1String( "fake_qgis_http_endpoint" ) ) )
+ {
+ req.setUrl( QStringLiteral( "file://%1" ).arg( url.path() ) );
+ }
+
+ QNetworkReply *reply = nullptr;
+
+ if ( mType != QgsAction::SubmitUrlMultipart )
+ {
+ QString contentType { QStringLiteral( "application/x-www-form-urlencoded" ) };
+ // check for json
+ QJsonParseError jsonError;
+ QJsonDocument::fromJson( payload.toUtf8(), &jsonError );
+ if ( jsonError.error == QJsonParseError::ParseError::NoError )
+ {
+ contentType = QStringLiteral( "application/json" );
+ }
+ req.setHeader( QNetworkRequest::KnownHeaders::ContentTypeHeader, contentType );
+ reply = QgsNetworkAccessManager::instance()->post( req, payload.toUtf8() );
+ }
+ // for multipart create parts and headers
+ else
+ {
+ QHttpMultiPart *multiPart = new QHttpMultiPart( QHttpMultiPart::FormDataType );
+ const QList> queryItems { queryString.queryItems( QUrl::ComponentFormattingOption::FullyDecoded ) };
+ for ( const QPair &queryItem : std::as_const( queryItems ) )
+ {
+ QHttpPart part;
+ part.setHeader( QNetworkRequest::ContentDispositionHeader,
+ QStringLiteral( "form-data; name=\"%1\"" )
+ .arg( QString( queryItem.first ).replace( '"', QStringLiteral( R"(\")" ) ) ) );
+ part.setBody( queryItem.second.toUtf8() );
+ multiPart->append( part );
+ }
+ reply = QgsNetworkAccessManager::instance()->post( req, multiPart );
+ multiPart->setParent( reply );
+ }
+
+ QObject::connect( reply, &QNetworkReply::finished, reply, [ reply ]
+ {
+ if ( reply->error() == QNetworkReply::NoError )
+ {
+
+ if ( reply->attribute( QNetworkRequest::RedirectionTargetAttribute ).isNull() )
+ {
+
+ const QByteArray replyData = reply->readAll();
+
+ QString filename { "download.bin" };
+ if ( const std::string header = reply->header( QNetworkRequest::KnownHeaders::ContentDispositionHeader ).toString().toStdString(); ! header.empty() )
+ {
+
+ std::string ascii;
+ const std::string q1 { R"(filename=")" };
+ if ( const unsigned long pos = header.find( q1 ); pos != std::string::npos )
+ {
+ const unsigned long len = pos + q1.size();
+
+ const std::string q2 { R"(")" };
+ if ( unsigned long pos = header.find( q2, len ); pos != std::string::npos )
+ {
+ bool escaped = false;
+ while ( pos != std::string::npos && header[pos - 1] == '\\' )
+ {
+ pos = header.find( q2, pos + 1 );
+ escaped = true;
+ }
+ ascii = header.substr( len, pos - len );
+ if ( escaped )
+ {
+ std::string cleaned;
+ for ( size_t i = 0; i < ascii.size(); ++i )
+ {
+ if ( ascii[i] == '\\' )
+ {
+ if ( i > 0 && ascii[i - 1] == '\\' )
+ {
+ cleaned.push_back( ascii[i] );
+ }
+ }
+ else
+ {
+ cleaned.push_back( ascii[i] );
+ }
+ }
+ ascii = cleaned;
+ }
+ }
+ }
+
+ std::string utf8;
+
+ const std::string u { R"(UTF-8'')" };
+ if ( const unsigned long pos = header.find( u ); pos != std::string::npos )
+ {
+ utf8 = header.substr( pos + u.size() );
+ }
+
+ // Prefer ascii over utf8
+ if ( ascii.empty() )
+ {
+ if ( ! utf8.empty( ) )
+ {
+ filename = QString::fromStdString( utf8 );
+ }
+ }
+ else
+ {
+ filename = QString::fromStdString( ascii );
+ }
+ }
+ else if ( !reply->header( QNetworkRequest::KnownHeaders::ContentTypeHeader ).isNull() )
+ {
+ QString contentTypeHeader { reply->header( QNetworkRequest::KnownHeaders::ContentTypeHeader ).toString() };
+ // Strip charset if any
+ if ( contentTypeHeader.contains( ';' ) )
+ {
+ contentTypeHeader = contentTypeHeader.left( contentTypeHeader.indexOf( ';' ) );
+ }
+
+ QMimeType mimeType { QMimeDatabase().mimeTypeForName( contentTypeHeader ) };
+ if ( mimeType.isValid() )
+ {
+ filename = QStringLiteral( "download.%1" ).arg( mimeType.preferredSuffix() );
+ }
+ }
+
+ QTemporaryDir tempDir;
+ tempDir.setAutoRemove( false );
+ tempDir.path();
+ const QString tempFilePath{ tempDir.path() + QDir::separator() + filename };
+ QFile tempFile{ tempFilePath };
+ tempFile.open( QIODevice::WriteOnly );
+ tempFile.write( replyData );
+ tempFile.close();
+ QDesktopServices::openUrl( QUrl::fromLocalFile( tempFilePath ) );
+ }
+ else
+ {
+ QgsMessageLog::logMessage( QObject::tr( "Redirect is not supported!" ), QStringLiteral( "Form Submit Action" ), Qgis::MessageLevel::Critical );
+ }
+ }
+ else
+ {
+ QgsMessageLog::logMessage( reply->errorString(), QStringLiteral( "Form Submit Action" ), Qgis::MessageLevel::Critical );
+ }
+ reply->deleteLater();
+ } );
+
+}
+
void QgsAction::run( const QgsExpressionContext &expressionContext ) const
{
if ( !isValid() )
@@ -74,6 +254,11 @@ void QgsAction::run( const QgsExpressionContext &expressionContext ) const
else
QDesktopServices::openUrl( QUrl( expandedAction, QUrl::TolerantMode ) );
}
+ else if ( mType == QgsAction::SubmitUrlEncoded || mType == QgsAction::SubmitUrlMultipart )
+ {
+ handleFormSubmitAction( expandedAction );
+
+ }
else if ( mType == QgsAction::GenericPython )
{
// TODO: capture output from QgsPythonRunner (like QgsRunProcess does)
@@ -200,6 +385,16 @@ QString QgsAction::html() const
typeText = QObject::tr( "Open URL" );
break;
}
+ case SubmitUrlEncoded:
+ {
+ typeText = QObject::tr( "Submit URL (urlencoded or JSON)" );
+ break;
+ }
+ case SubmitUrlMultipart:
+ {
+ typeText = QObject::tr( "Submit URL (multipart)" );
+ break;
+ }
}
return { QObject::tr( R"html(
Action Details
diff --git a/src/core/qgsaction.h b/src/core/qgsaction.h
index 95e011bdd175..e99fadbad5ce 100644
--- a/src/core/qgsaction.h
+++ b/src/core/qgsaction.h
@@ -42,6 +42,8 @@ class CORE_EXPORT QgsAction
Windows,
Unix,
OpenUrl,
+ SubmitUrlEncoded, //!< POST data to an URL, using "application/x-www-form-urlencoded" or "application/json" if the body is valid JSON \since QGIS 3.24
+ SubmitUrlMultipart, //!< POST data to an URL using "multipart/form-data" \since QGIS 3.24
};
/**
@@ -255,6 +257,8 @@ class CORE_EXPORT QgsAction
QString html( ) const;
private:
+
+ void handleFormSubmitAction( const QString &expandedAction ) const;
ActionType mType = Generic;
QString mDescription;
QString mShortTitle;
diff --git a/src/core/qgsactionmanager.cpp b/src/core/qgsactionmanager.cpp
index bad9fba15519..edef8fd12a37 100644
--- a/src/core/qgsactionmanager.cpp
+++ b/src/core/qgsactionmanager.cpp
@@ -38,11 +38,9 @@
#include
#include
#include
-#include
#include
#include
-
QUuid QgsActionManager::addAction( QgsAction::ActionType type, const QString &name, const QString &command, bool capture )
{
QgsAction action( type, name, command, capture );
@@ -189,24 +187,34 @@ QList QgsActionManager::actions( const QString &actionScope ) const
void QgsActionManager::runAction( const QgsAction &action )
{
- if ( action.type() == QgsAction::OpenUrl )
- {
- QFileInfo finfo( action.command() );
- if ( finfo.exists() && finfo.isFile() )
- QDesktopServices::openUrl( QUrl::fromLocalFile( action.command() ) );
- else
- QDesktopServices::openUrl( QUrl( action.command(), QUrl::TolerantMode ) );
- }
- else if ( action.type() == QgsAction::GenericPython )
- {
- // TODO: capture output from QgsPythonRunner (like QgsRunProcess does)
- QgsPythonRunner::run( action.command() );
- }
- else
+ switch ( action.type() )
{
- // The QgsRunProcess instance created by this static function
- // deletes itself when no longer needed.
- QgsRunProcess::create( action.command(), action.capture() );
+ case QgsAction::OpenUrl:
+ {
+ QFileInfo finfo( action.command() );
+ if ( finfo.exists() && finfo.isFile() )
+ QDesktopServices::openUrl( QUrl::fromLocalFile( action.command() ) );
+ else
+ QDesktopServices::openUrl( QUrl( action.command(), QUrl::TolerantMode ) );
+ break;
+ }
+ case QgsAction::GenericPython:
+ case QgsAction::SubmitUrlEncoded:
+ case QgsAction::SubmitUrlMultipart:
+ {
+ action.run( QgsExpressionContext() );
+ break;
+ }
+ case QgsAction::Generic:
+ case QgsAction::Mac:
+ case QgsAction::Unix:
+ case QgsAction::Windows:
+ {
+ // The QgsRunProcess instance created by this static function
+ // deletes itself when no longer needed.
+ QgsRunProcess::create( action.command(), action.capture() );
+ break;
+ }
}
}
diff --git a/src/gui/vector/qgsattributeactiondialog.cpp b/src/gui/vector/qgsattributeactiondialog.cpp
index 7b911550ae8d..2a101cdb4db9 100644
--- a/src/gui/vector/qgsattributeactiondialog.cpp
+++ b/src/gui/vector/qgsattributeactiondialog.cpp
@@ -256,6 +256,10 @@ QString QgsAttributeActionDialog::textForType( QgsAction::ActionType type )
return tr( "Unix" );
case QgsAction::OpenUrl:
return tr( "Open URL" );
+ case QgsAction::SubmitUrlEncoded:
+ return tr( "Submit URL (urlencoded or JSON)" );
+ case QgsAction::SubmitUrlMultipart:
+ return tr( "Submit URL (multipart)" );
}
return QString();
}
diff --git a/src/ui/qgsattributeactionpropertiesdialogbase.ui b/src/ui/qgsattributeactionpropertiesdialogbase.ui
index c0556056f5a2..ecca0e177017 100644
--- a/src/ui/qgsattributeactionpropertiesdialogbase.ui
+++ b/src/ui/qgsattributeactionpropertiesdialogbase.ui
@@ -234,7 +234,17 @@
-
- Open
+ Open URL
+
+
+ -
+
+ Submit URL (urlencoded or JSON)
+
+
+ -
+
+ Submit URL (multipart)
@@ -325,32 +335,6 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/tests/src/core/testqgsexpression.cpp b/tests/src/core/testqgsexpression.cpp
index 45a3228a00b5..5bf266064f9c 100644
--- a/tests/src/core/testqgsexpression.cpp
+++ b/tests/src/core/testqgsexpression.cpp
@@ -1980,6 +1980,11 @@ class TestQgsExpression: public QObject
QTest::newRow( "exif bad file path" ) << QStringLiteral( "exif('bad path','Exif.Image.DateTime')" ) << false << QVariant();
QTest::newRow( "exif_geotag" ) << QStringLiteral( "geom_to_wkt(exif_geotag('%1photos/0997.JPG'))" ).arg( testDataDir ) << false << QVariant( "PointZ (149.27516667 -37.2305 422.19101124)" );
QTest::newRow( "exif_geotag bad file path" ) << QStringLiteral( "geom_to_wkt(exif_geotag('bad path'))" ).arg( testDataDir ) << false << QVariant( "Point EMPTY" );
+
+ // Form encoding tests
+ QTest::newRow( "url_encode" ) << QStringLiteral( "url_encode(map())" ).arg( testDataDir ) << false << QVariant( "" );
+ QTest::newRow( "url_encode" ) << QStringLiteral( "url_encode(map('a b', 'a b', 'c &% d', 'c &% d'))" ).arg( testDataDir ) << false << QVariant( "a%20b=a%20b&c%20%26%25%20d=c%20%26%25%20d" );
+ QTest::newRow( "url_encode" ) << QStringLiteral( "url_encode(map('a&+b', 'a and plus b', 'a=b', 'a equals b'))" ).arg( testDataDir ) << false << QVariant( "a%26+b=a%20and%20plus%20b&a%3Db=a%20equals%20b" );
}
void run_evaluation_test( QgsExpression &exp, bool evalError, QVariant &expected )
diff --git a/tests/src/python/CMakeLists.txt b/tests/src/python/CMakeLists.txt
index eaae0775623e..de1615a17314 100644
--- a/tests/src/python/CMakeLists.txt
+++ b/tests/src/python/CMakeLists.txt
@@ -13,6 +13,7 @@ ADD_PYTHON_TEST(PyCoreAdditions test_core_additions.py)
ADD_PYTHON_TEST(PyPythonRepr test_python_repr.py)
ADD_PYTHON_TEST(PyPythonUtils test_python_utils.py)
ADD_PYTHON_TEST(PyQgsActionManager test_qgsactionmanager.py)
+ADD_PYTHON_TEST(PyQgsAction test_qgsaction.py)
ADD_PYTHON_TEST(PyQgsAFSProvider test_provider_afs.py)
ADD_PYTHON_TEST(PyQgsAggregateMappingWidget test_qgsaggregatemappingwidget.py)
ADD_PYTHON_TEST(PyQgsAlignmentComboBox test_qgsalignmentcombobox.py)
diff --git a/tests/src/python/test_qgsaction.py b/tests/src/python/test_qgsaction.py
new file mode 100644
index 000000000000..e64bb9ed26fe
--- /dev/null
+++ b/tests/src/python/test_qgsaction.py
@@ -0,0 +1,102 @@
+# -*- coding: utf-8 -*-
+"""QGIS Unit tests for QgsAction.
+
+From build dir, run: ctest -R PyQgsAction -V
+
+.. note:: 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.
+"""
+__author__ = 'Alessandro Pasotti'
+__date__ = '24/11/2021'
+__copyright__ = 'Copyright 2021, The QGIS Project'
+
+import qgis # NOQA switch sip api
+
+from qgis.core import (
+ QgsExpressionContext,
+ QgsAction,
+ QgsNetworkAccessManager,
+ QgsNetworkRequestParameters,
+ QgsApplication,
+)
+
+from qgis.PyQt.QtCore import QTemporaryDir
+
+from qgis.testing import start_app, unittest
+
+import os
+import re
+import time
+import platform
+from functools import partial
+
+start_app()
+
+
+class TestQgsAction(unittest.TestCase):
+
+ def setUp(self):
+ self.body = None
+
+ def _req_logger(self, params):
+ self.body = bytes(params.content())
+
+ def test_post_urlencoded_action(self):
+ """Test form www urlencoded"""
+
+ def _req_logger(self, params):
+ self.body = bytes(params.content())
+
+ QgsNetworkAccessManager.instance().requestAboutToBeCreated[QgsNetworkRequestParameters].connect(partial(_req_logger, self))
+
+ temp_dir = QTemporaryDir()
+ temp_path = temp_dir.path()
+ temp_file = os.path.join(temp_path, 'urlencoded.txt')
+
+ action = QgsAction(QgsAction.SubmitUrlEncoded, 'url_encoded', "http://fake_qgis_http_endpoint" + temp_file + r"?[% url_encode(map('a&+b', 'a and plus b', 'a=b', 'a equals b')) %]")
+ ctx = QgsExpressionContext()
+ action.run(ctx)
+
+ while not self.body:
+ QgsApplication.instance().processEvents()
+
+ self.assertEqual(self.body, br"a%26%2Bb=a%20and%20plus%20b&a%3Db=a%20equals%20b")
+
+ def test_post_multipart_action(self):
+ """Test multipart"""
+
+ self.body = None
+
+ def _req_logger(self, params):
+ self.body = bytes(params.content())
+
+ QgsNetworkAccessManager.instance().requestAboutToBeCreated[QgsNetworkRequestParameters].connect(partial(_req_logger, self))
+
+ temp_dir = QTemporaryDir()
+ temp_path = temp_dir.path()
+ temp_file = os.path.join(temp_path, 'multipart.txt')
+
+ action = QgsAction(QgsAction.SubmitUrlMultipart, 'url_encoded', "http://fake_qgis_http_endpoint" + temp_file + r"?[% url_encode(map('a&+b', 'a and plus b', 'a=b', 'a equals b')) %]")
+ ctx = QgsExpressionContext()
+ action.run(ctx)
+
+ while not self.body:
+ QgsApplication.instance().processEvents()
+
+ self.assertEqual(re.sub(r'\.oOo\.[^\r]*', '.oOo.UUID', self.body.decode('utf8')), '\r\n'.join([
+ '--boundary_.oOo.UUID',
+ 'Content-Disposition: form-data; name="a&+b"',
+ '',
+ 'a and plus b',
+ '--boundary_.oOo.UUID',
+ 'Content-Disposition: form-data; name="a=b"',
+ '',
+ 'a equals b',
+ '--boundary_.oOo.UUID',
+ '']))
+
+
+if __name__ == '__main__':
+ unittest.main()