diff --git a/external/inja/inja.hpp b/external/inja/inja.hpp new file mode 100644 index 00000000000..918f0a1da4b --- /dev/null +++ b/external/inja/inja.hpp @@ -0,0 +1,3345 @@ +#ifndef PANTOR_INJA_HPP +#define PANTOR_INJA_HPP + +#include +#include +#include +#include +#include +#include +#include + +#include + +// #include "environment.hpp" +#ifndef PANTOR_INJA_ENVIRONMENT_HPP +#define PANTOR_INJA_ENVIRONMENT_HPP + +#include +#include +#include +#include + +#include + +// #include "config.hpp" +#ifndef PANTOR_INJA_CONFIG_HPP +#define PANTOR_INJA_CONFIG_HPP + +#include +#include + +// #include "string_view.hpp" +// Copyright 2017-2019 by Martin Moene +// +// string-view lite, a C++17-like string_view for C++98 and later. +// For more information see https://github.com/martinmoene/string-view-lite +// +// Distributed under the Boost Software License, Version 1.0. +// (See accompanying file LICENSE.txt or copy at http://www.boost.org/LICENSE_1_0.txt) + + + +#ifndef NONSTD_SV_LITE_H_INCLUDED +#define NONSTD_SV_LITE_H_INCLUDED + +#define string_view_lite_MAJOR 1 +#define string_view_lite_MINOR 1 +#define string_view_lite_PATCH 0 + +#define string_view_lite_VERSION nssv_STRINGIFY(string_view_lite_MAJOR) "." nssv_STRINGIFY(string_view_lite_MINOR) "." nssv_STRINGIFY(string_view_lite_PATCH) + +#define nssv_STRINGIFY( x ) nssv_STRINGIFY_( x ) +#define nssv_STRINGIFY_( x ) #x + +// string-view lite configuration: + +#define nssv_STRING_VIEW_DEFAULT 0 +#define nssv_STRING_VIEW_NONSTD 1 +#define nssv_STRING_VIEW_STD 2 + +#if !defined( nssv_CONFIG_SELECT_STRING_VIEW ) +# define nssv_CONFIG_SELECT_STRING_VIEW ( nssv_HAVE_STD_STRING_VIEW ? nssv_STRING_VIEW_STD : nssv_STRING_VIEW_NONSTD ) +#endif + +#if defined( nssv_CONFIG_SELECT_STD_STRING_VIEW ) || defined( nssv_CONFIG_SELECT_NONSTD_STRING_VIEW ) +# error nssv_CONFIG_SELECT_STD_STRING_VIEW and nssv_CONFIG_SELECT_NONSTD_STRING_VIEW are deprecated and removed, please use nssv_CONFIG_SELECT_STRING_VIEW=nssv_STRING_VIEW_... +#endif + +#ifndef nssv_CONFIG_STD_SV_OPERATOR +# define nssv_CONFIG_STD_SV_OPERATOR 0 +#endif + +#ifndef nssv_CONFIG_USR_SV_OPERATOR +# define nssv_CONFIG_USR_SV_OPERATOR 1 +#endif + +#ifdef nssv_CONFIG_CONVERSION_STD_STRING +# define nssv_CONFIG_CONVERSION_STD_STRING_CLASS_METHODS nssv_CONFIG_CONVERSION_STD_STRING +# define nssv_CONFIG_CONVERSION_STD_STRING_FREE_FUNCTIONS nssv_CONFIG_CONVERSION_STD_STRING +#endif + +#ifndef nssv_CONFIG_CONVERSION_STD_STRING_CLASS_METHODS +# define nssv_CONFIG_CONVERSION_STD_STRING_CLASS_METHODS 1 +#endif + +#ifndef nssv_CONFIG_CONVERSION_STD_STRING_FREE_FUNCTIONS +# define nssv_CONFIG_CONVERSION_STD_STRING_FREE_FUNCTIONS 1 +#endif + +// Control presence of exception handling (try and auto discover): + +#ifndef nssv_CONFIG_NO_EXCEPTIONS +# if defined(__cpp_exceptions) || defined(__EXCEPTIONS) || defined(_CPPUNWIND) +# define nssv_CONFIG_NO_EXCEPTIONS 0 +# else +# define nssv_CONFIG_NO_EXCEPTIONS 1 +# endif +#endif + +// C++ language version detection (C++20 is speculative): +// Note: VC14.0/1900 (VS2015) lacks too much from C++14. + +#ifndef nssv_CPLUSPLUS +# if defined(_MSVC_LANG ) && !defined(__clang__) +# define nssv_CPLUSPLUS (_MSC_VER == 1900 ? 201103L : _MSVC_LANG ) +# else +# define nssv_CPLUSPLUS __cplusplus +# endif +#endif + +#define nssv_CPP98_OR_GREATER ( nssv_CPLUSPLUS >= 199711L ) +#define nssv_CPP11_OR_GREATER ( nssv_CPLUSPLUS >= 201103L ) +#define nssv_CPP11_OR_GREATER_ ( nssv_CPLUSPLUS >= 201103L ) +#define nssv_CPP14_OR_GREATER ( nssv_CPLUSPLUS >= 201402L ) +#define nssv_CPP17_OR_GREATER ( nssv_CPLUSPLUS >= 201703L ) +#define nssv_CPP20_OR_GREATER ( nssv_CPLUSPLUS >= 202000L ) + +// use C++17 std::string_view if available and requested: + +#if nssv_CPP17_OR_GREATER && defined(__has_include ) +# if __has_include( ) +# define nssv_HAVE_STD_STRING_VIEW 1 +# else +# define nssv_HAVE_STD_STRING_VIEW 0 +# endif +#else +# define nssv_HAVE_STD_STRING_VIEW 0 +#endif + +#define nssv_USES_STD_STRING_VIEW ( (nssv_CONFIG_SELECT_STRING_VIEW == nssv_STRING_VIEW_STD) || ((nssv_CONFIG_SELECT_STRING_VIEW == nssv_STRING_VIEW_DEFAULT) && nssv_HAVE_STD_STRING_VIEW) ) + +#define nssv_HAVE_STARTS_WITH ( nssv_CPP20_OR_GREATER || !nssv_USES_STD_STRING_VIEW ) +#define nssv_HAVE_ENDS_WITH nssv_HAVE_STARTS_WITH + +// +// Use C++17 std::string_view: +// + +#if nssv_USES_STD_STRING_VIEW + +#include + +// Extensions for std::string: + +#if nssv_CONFIG_CONVERSION_STD_STRING_FREE_FUNCTIONS + +namespace nonstd { + +template< class CharT, class Traits, class Allocator = std::allocator > +std::basic_string +to_string( std::basic_string_view v, Allocator const & a = Allocator() ) +{ + return std::basic_string( v.begin(), v.end(), a ); +} + +template< class CharT, class Traits, class Allocator > +std::basic_string_view +to_string_view( std::basic_string const & s ) +{ + return std::basic_string_view( s.data(), s.size() ); +} + +// Literal operators sv and _sv: + +#if nssv_CONFIG_STD_SV_OPERATOR + +using namespace std::literals::string_view_literals; + +#endif + +#if nssv_CONFIG_USR_SV_OPERATOR + +inline namespace literals { +inline namespace string_view_literals { + + +constexpr std::string_view operator "" _sv( const char* str, size_t len ) noexcept // (1) +{ + return std::string_view{ str, len }; +} + +constexpr std::u16string_view operator "" _sv( const char16_t* str, size_t len ) noexcept // (2) +{ + return std::u16string_view{ str, len }; +} + +constexpr std::u32string_view operator "" _sv( const char32_t* str, size_t len ) noexcept // (3) +{ + return std::u32string_view{ str, len }; +} + +constexpr std::wstring_view operator "" _sv( const wchar_t* str, size_t len ) noexcept // (4) +{ + return std::wstring_view{ str, len }; +} + +}} // namespace literals::string_view_literals + +#endif // nssv_CONFIG_USR_SV_OPERATOR + +} // namespace nonstd + +#endif // nssv_CONFIG_CONVERSION_STD_STRING_FREE_FUNCTIONS + +namespace nonstd { + +using std::string_view; +using std::wstring_view; +using std::u16string_view; +using std::u32string_view; +using std::basic_string_view; + +// literal "sv" and "_sv", see above + +using std::operator==; +using std::operator!=; +using std::operator<; +using std::operator<=; +using std::operator>; +using std::operator>=; + +using std::operator<<; + +} // namespace nonstd + +#else // nssv_HAVE_STD_STRING_VIEW + +// +// Before C++17: use string_view lite: +// + +// Compiler versions: +// +// MSVC++ 6.0 _MSC_VER == 1200 (Visual Studio 6.0) +// MSVC++ 7.0 _MSC_VER == 1300 (Visual Studio .NET 2002) +// MSVC++ 7.1 _MSC_VER == 1310 (Visual Studio .NET 2003) +// MSVC++ 8.0 _MSC_VER == 1400 (Visual Studio 2005) +// MSVC++ 9.0 _MSC_VER == 1500 (Visual Studio 2008) +// MSVC++ 10.0 _MSC_VER == 1600 (Visual Studio 2010) +// MSVC++ 11.0 _MSC_VER == 1700 (Visual Studio 2012) +// MSVC++ 12.0 _MSC_VER == 1800 (Visual Studio 2013) +// MSVC++ 14.0 _MSC_VER == 1900 (Visual Studio 2015) +// MSVC++ 14.1 _MSC_VER >= 1910 (Visual Studio 2017) + +#if defined(_MSC_VER ) && !defined(__clang__) +# define nssv_COMPILER_MSVC_VER (_MSC_VER ) +# define nssv_COMPILER_MSVC_VERSION (_MSC_VER / 10 - 10 * ( 5 + (_MSC_VER < 1900 ) ) ) +#else +# define nssv_COMPILER_MSVC_VER 0 +# define nssv_COMPILER_MSVC_VERSION 0 +#endif + +#define nssv_COMPILER_VERSION( major, minor, patch ) (10 * ( 10 * major + minor) + patch) + +#if defined(__clang__) +# define nssv_COMPILER_CLANG_VERSION nssv_COMPILER_VERSION(__clang_major__, __clang_minor__, __clang_patchlevel__) +#else +# define nssv_COMPILER_CLANG_VERSION 0 +#endif + +#if defined(__GNUC__) && !defined(__clang__) +# define nssv_COMPILER_GNUC_VERSION nssv_COMPILER_VERSION(__GNUC__, __GNUC_MINOR__, __GNUC_PATCHLEVEL__) +#else +# define nssv_COMPILER_GNUC_VERSION 0 +#endif + +// half-open range [lo..hi): +#define nssv_BETWEEN( v, lo, hi ) ( (lo) <= (v) && (v) < (hi) ) + +// Presence of language and library features: + +#ifdef _HAS_CPP0X +# define nssv_HAS_CPP0X _HAS_CPP0X +#else +# define nssv_HAS_CPP0X 0 +#endif + +// Unless defined otherwise below, consider VC14 as C++11 for variant-lite: + +#if nssv_COMPILER_MSVC_VER >= 1900 +# undef nssv_CPP11_OR_GREATER +# define nssv_CPP11_OR_GREATER 1 +#endif + +#define nssv_CPP11_90 (nssv_CPP11_OR_GREATER_ || nssv_COMPILER_MSVC_VER >= 1500) +#define nssv_CPP11_100 (nssv_CPP11_OR_GREATER_ || nssv_COMPILER_MSVC_VER >= 1600) +#define nssv_CPP11_110 (nssv_CPP11_OR_GREATER_ || nssv_COMPILER_MSVC_VER >= 1700) +#define nssv_CPP11_120 (nssv_CPP11_OR_GREATER_ || nssv_COMPILER_MSVC_VER >= 1800) +#define nssv_CPP11_140 (nssv_CPP11_OR_GREATER_ || nssv_COMPILER_MSVC_VER >= 1900) +#define nssv_CPP11_141 (nssv_CPP11_OR_GREATER_ || nssv_COMPILER_MSVC_VER >= 1910) + +#define nssv_CPP14_000 (nssv_CPP14_OR_GREATER) +#define nssv_CPP17_000 (nssv_CPP17_OR_GREATER) + +// Presence of C++11 language features: + +#define nssv_HAVE_CONSTEXPR_11 nssv_CPP11_140 +#define nssv_HAVE_EXPLICIT_CONVERSION nssv_CPP11_140 +#define nssv_HAVE_INLINE_NAMESPACE nssv_CPP11_140 +#define nssv_HAVE_NOEXCEPT nssv_CPP11_140 +#define nssv_HAVE_NULLPTR nssv_CPP11_100 +#define nssv_HAVE_REF_QUALIFIER nssv_CPP11_140 +#define nssv_HAVE_UNICODE_LITERALS nssv_CPP11_140 +#define nssv_HAVE_USER_DEFINED_LITERALS nssv_CPP11_140 +#define nssv_HAVE_WCHAR16_T nssv_CPP11_100 +#define nssv_HAVE_WCHAR32_T nssv_CPP11_100 + +#if ! ( ( nssv_CPP11 && nssv_COMPILER_CLANG_VERSION ) || nssv_BETWEEN( nssv_COMPILER_CLANG_VERSION, 300, 400 ) ) +# define nssv_HAVE_STD_DEFINED_LITERALS nssv_CPP11_140 +#endif + +// Presence of C++14 language features: + +#define nssv_HAVE_CONSTEXPR_14 nssv_CPP14_000 + +// Presence of C++17 language features: + +#define nssv_HAVE_NODISCARD nssv_CPP17_000 + +// Presence of C++ library features: + +#define nssv_HAVE_STD_HASH nssv_CPP11_120 + +// C++ feature usage: + +#if nssv_HAVE_CONSTEXPR_11 +# define nssv_constexpr constexpr +#else +# define nssv_constexpr /*constexpr*/ +#endif + +#if nssv_HAVE_CONSTEXPR_14 +# define nssv_constexpr14 constexpr +#else +# define nssv_constexpr14 /*constexpr*/ +#endif + +#if nssv_HAVE_EXPLICIT_CONVERSION +# define nssv_explicit explicit +#else +# define nssv_explicit /*explicit*/ +#endif + +#if nssv_HAVE_INLINE_NAMESPACE +# define nssv_inline_ns inline +#else +# define nssv_inline_ns /*inline*/ +#endif + +#if nssv_HAVE_NOEXCEPT +# define nssv_noexcept noexcept +#else +# define nssv_noexcept /*noexcept*/ +#endif + +//#if nssv_HAVE_REF_QUALIFIER +//# define nssv_ref_qual & +//# define nssv_refref_qual && +//#else +//# define nssv_ref_qual /*&*/ +//# define nssv_refref_qual /*&&*/ +//#endif + +#if nssv_HAVE_NULLPTR +# define nssv_nullptr nullptr +#else +# define nssv_nullptr NULL +#endif + +#if nssv_HAVE_NODISCARD +# define nssv_nodiscard [[nodiscard]] +#else +# define nssv_nodiscard /*[[nodiscard]]*/ +#endif + +// Additional includes: + +#include +#include +#include +#include +#include +#include // std::char_traits<> + +#if ! nssv_CONFIG_NO_EXCEPTIONS +# include +#endif + +#if nssv_CPP11_OR_GREATER +# include +#endif + +// Clang, GNUC, MSVC warning suppression macros: + +#if defined(__clang__) +# pragma clang diagnostic ignored "-Wreserved-user-defined-literal" +# pragma clang diagnostic push +# pragma clang diagnostic ignored "-Wuser-defined-literals" +#elif defined(__GNUC__) +# pragma GCC diagnostic push +# pragma GCC diagnostic ignored "-Wliteral-suffix" +#endif // __clang__ + +#if nssv_COMPILER_MSVC_VERSION >= 140 +# define nssv_SUPPRESS_MSGSL_WARNING(expr) [[gsl::suppress(expr)]] +# define nssv_SUPPRESS_MSVC_WARNING(code, descr) __pragma(warning(suppress: code) ) +# define nssv_DISABLE_MSVC_WARNINGS(codes) __pragma(warning(push)) __pragma(warning(disable: codes)) +#else +# define nssv_SUPPRESS_MSGSL_WARNING(expr) +# define nssv_SUPPRESS_MSVC_WARNING(code, descr) +# define nssv_DISABLE_MSVC_WARNINGS(codes) +#endif + +#if defined(__clang__) +# define nssv_RESTORE_WARNINGS() _Pragma("clang diagnostic pop") +#elif defined(__GNUC__) +# define nssv_RESTORE_WARNINGS() _Pragma("GCC diagnostic pop") +#elif nssv_COMPILER_MSVC_VERSION >= 140 +# define nssv_RESTORE_WARNINGS() __pragma(warning(pop )) +#else +# define nssv_RESTORE_WARNINGS() +#endif + +// Suppress the following MSVC (GSL) warnings: +// - C4455, non-gsl : 'operator ""sv': literal suffix identifiers that do not +// start with an underscore are reserved +// - C26472, gsl::t.1 : don't use a static_cast for arithmetic conversions; +// use brace initialization, gsl::narrow_cast or gsl::narow +// - C26481: gsl::b.1 : don't use pointer arithmetic. Use span instead + +nssv_DISABLE_MSVC_WARNINGS( 4455 26481 26472 ) +//nssv_DISABLE_CLANG_WARNINGS( "-Wuser-defined-literals" ) +//nssv_DISABLE_GNUC_WARNINGS( -Wliteral-suffix ) + +namespace nonstd { namespace sv_lite { + +template +< + class CharT, + class Traits = std::char_traits +> +class basic_string_view; + +// +// basic_string_view: +// + +template +< + class CharT, + class Traits /* = std::char_traits */ +> +class basic_string_view +{ +public: + // Member types: + + typedef Traits traits_type; + typedef CharT value_type; + + typedef CharT * pointer; + typedef CharT const * const_pointer; + typedef CharT & reference; + typedef CharT const & const_reference; + + typedef const_pointer iterator; + typedef const_pointer const_iterator; + typedef std::reverse_iterator< const_iterator > reverse_iterator; + typedef std::reverse_iterator< const_iterator > const_reverse_iterator; + + typedef std::size_t size_type; + typedef std::ptrdiff_t difference_type; + + // 24.4.2.1 Construction and assignment: + + nssv_constexpr basic_string_view() nssv_noexcept + : data_( nssv_nullptr ) + , size_( 0 ) + {} + +#if nssv_CPP11_OR_GREATER + nssv_constexpr basic_string_view( basic_string_view const & other ) nssv_noexcept = default; +#else + nssv_constexpr basic_string_view( basic_string_view const & other ) nssv_noexcept + : data_( other.data_) + , size_( other.size_) + {} +#endif + + nssv_constexpr basic_string_view( CharT const * s, size_type count ) + : data_( s ) + , size_( count ) + {} + + nssv_constexpr basic_string_view( CharT const * s) + : data_( s ) + , size_( Traits::length(s) ) + {} + + // Assignment: + +#if nssv_CPP11_OR_GREATER + nssv_constexpr14 basic_string_view & operator=( basic_string_view const & other ) nssv_noexcept = default; +#else + nssv_constexpr14 basic_string_view & operator=( basic_string_view const & other ) nssv_noexcept + { + data_ = other.data_; + size_ = other.size_; + return *this; + } +#endif + + // 24.4.2.2 Iterator support: + + nssv_constexpr const_iterator begin() const nssv_noexcept { return data_; } + nssv_constexpr const_iterator end() const nssv_noexcept { return data_ + size_; } + + nssv_constexpr const_iterator cbegin() const nssv_noexcept { return begin(); } + nssv_constexpr const_iterator cend() const nssv_noexcept { return end(); } + + nssv_constexpr const_reverse_iterator rbegin() const nssv_noexcept { return const_reverse_iterator( end() ); } + nssv_constexpr const_reverse_iterator rend() const nssv_noexcept { return const_reverse_iterator( begin() ); } + + nssv_constexpr const_reverse_iterator crbegin() const nssv_noexcept { return rbegin(); } + nssv_constexpr const_reverse_iterator crend() const nssv_noexcept { return rend(); } + + // 24.4.2.3 Capacity: + + nssv_constexpr size_type size() const nssv_noexcept { return size_; } + nssv_constexpr size_type length() const nssv_noexcept { return size_; } + nssv_constexpr size_type max_size() const nssv_noexcept { return (std::numeric_limits< size_type >::max)(); } + + // since C++20 + nssv_nodiscard nssv_constexpr bool empty() const nssv_noexcept + { + return 0 == size_; + } + + // 24.4.2.4 Element access: + + nssv_constexpr const_reference operator[]( size_type pos ) const + { + return data_at( pos ); + } + + nssv_constexpr14 const_reference at( size_type pos ) const + { +#if nssv_CONFIG_NO_EXCEPTIONS + assert( pos < size() ); +#else + if ( pos >= size() ) + { + throw std::out_of_range("nonst::string_view::at()"); + } +#endif + return data_at( pos ); + } + + nssv_constexpr const_reference front() const { return data_at( 0 ); } + nssv_constexpr const_reference back() const { return data_at( size() - 1 ); } + + nssv_constexpr const_pointer data() const nssv_noexcept { return data_; } + + // 24.4.2.5 Modifiers: + + nssv_constexpr14 void remove_prefix( size_type n ) + { + assert( n <= size() ); + data_ += n; + size_ -= n; + } + + nssv_constexpr14 void remove_suffix( size_type n ) + { + assert( n <= size() ); + size_ -= n; + } + + nssv_constexpr14 void swap( basic_string_view & other ) nssv_noexcept + { + using std::swap; + swap( data_, other.data_ ); + swap( size_, other.size_ ); + } + + // 24.4.2.6 String operations: + + size_type copy( CharT * dest, size_type n, size_type pos = 0 ) const + { +#if nssv_CONFIG_NO_EXCEPTIONS + assert( pos <= size() ); +#else + if ( pos > size() ) + { + throw std::out_of_range("nonst::string_view::copy()"); + } +#endif + const size_type rlen = (std::min)( n, size() - pos ); + + (void) Traits::copy( dest, data() + pos, rlen ); + + return rlen; + } + + nssv_constexpr14 basic_string_view substr( size_type pos = 0, size_type n = npos ) const + { +#if nssv_CONFIG_NO_EXCEPTIONS + assert( pos <= size() ); +#else + if ( pos > size() ) + { + throw std::out_of_range("nonst::string_view::substr()"); + } +#endif + return basic_string_view( data() + pos, (std::min)( n, size() - pos ) ); + } + + // compare(), 6x: + + nssv_constexpr14 int compare( basic_string_view other ) const nssv_noexcept // (1) + { + if ( const int result = Traits::compare( data(), other.data(), (std::min)( size(), other.size() ) ) ) + return result; + + return size() == other.size() ? 0 : size() < other.size() ? -1 : 1; + } + + nssv_constexpr int compare( size_type pos1, size_type n1, basic_string_view other ) const // (2) + { + return substr( pos1, n1 ).compare( other ); + } + + nssv_constexpr int compare( size_type pos1, size_type n1, basic_string_view other, size_type pos2, size_type n2 ) const // (3) + { + return substr( pos1, n1 ).compare( other.substr( pos2, n2 ) ); + } + + nssv_constexpr int compare( CharT const * s ) const // (4) + { + return compare( basic_string_view( s ) ); + } + + nssv_constexpr int compare( size_type pos1, size_type n1, CharT const * s ) const // (5) + { + return substr( pos1, n1 ).compare( basic_string_view( s ) ); + } + + nssv_constexpr int compare( size_type pos1, size_type n1, CharT const * s, size_type n2 ) const // (6) + { + return substr( pos1, n1 ).compare( basic_string_view( s, n2 ) ); + } + + // 24.4.2.7 Searching: + + // starts_with(), 3x, since C++20: + + nssv_constexpr bool starts_with( basic_string_view v ) const nssv_noexcept // (1) + { + return size() >= v.size() && compare( 0, v.size(), v ) == 0; + } + + nssv_constexpr bool starts_with( CharT c ) const nssv_noexcept // (2) + { + return starts_with( basic_string_view( &c, 1 ) ); + } + + nssv_constexpr bool starts_with( CharT const * s ) const // (3) + { + return starts_with( basic_string_view( s ) ); + } + + // ends_with(), 3x, since C++20: + + nssv_constexpr bool ends_with( basic_string_view v ) const nssv_noexcept // (1) + { + return size() >= v.size() && compare( size() - v.size(), npos, v ) == 0; + } + + nssv_constexpr bool ends_with( CharT c ) const nssv_noexcept // (2) + { + return ends_with( basic_string_view( &c, 1 ) ); + } + + nssv_constexpr bool ends_with( CharT const * s ) const // (3) + { + return ends_with( basic_string_view( s ) ); + } + + // find(), 4x: + + nssv_constexpr14 size_type find( basic_string_view v, size_type pos = 0 ) const nssv_noexcept // (1) + { + return assert( v.size() == 0 || v.data() != nssv_nullptr ) + , pos >= size() + ? npos + : to_pos( std::search( cbegin() + pos, cend(), v.cbegin(), v.cend(), Traits::eq ) ); + } + + nssv_constexpr14 size_type find( CharT c, size_type pos = 0 ) const nssv_noexcept // (2) + { + return find( basic_string_view( &c, 1 ), pos ); + } + + nssv_constexpr14 size_type find( CharT const * s, size_type pos, size_type n ) const // (3) + { + return find( basic_string_view( s, n ), pos ); + } + + nssv_constexpr14 size_type find( CharT const * s, size_type pos = 0 ) const // (4) + { + return find( basic_string_view( s ), pos ); + } + + // rfind(), 4x: + + nssv_constexpr14 size_type rfind( basic_string_view v, size_type pos = npos ) const nssv_noexcept // (1) + { + if ( size() < v.size() ) + return npos; + + if ( v.empty() ) + return (std::min)( size(), pos ); + + const_iterator last = cbegin() + (std::min)( size() - v.size(), pos ) + v.size(); + const_iterator result = std::find_end( cbegin(), last, v.cbegin(), v.cend(), Traits::eq ); + + return result != last ? size_type( result - cbegin() ) : npos; + } + + nssv_constexpr14 size_type rfind( CharT c, size_type pos = npos ) const nssv_noexcept // (2) + { + return rfind( basic_string_view( &c, 1 ), pos ); + } + + nssv_constexpr14 size_type rfind( CharT const * s, size_type pos, size_type n ) const // (3) + { + return rfind( basic_string_view( s, n ), pos ); + } + + nssv_constexpr14 size_type rfind( CharT const * s, size_type pos = npos ) const // (4) + { + return rfind( basic_string_view( s ), pos ); + } + + // find_first_of(), 4x: + + nssv_constexpr size_type find_first_of( basic_string_view v, size_type pos = 0 ) const nssv_noexcept // (1) + { + return pos >= size() + ? npos + : to_pos( std::find_first_of( cbegin() + pos, cend(), v.cbegin(), v.cend(), Traits::eq ) ); + } + + nssv_constexpr size_type find_first_of( CharT c, size_type pos = 0 ) const nssv_noexcept // (2) + { + return find_first_of( basic_string_view( &c, 1 ), pos ); + } + + nssv_constexpr size_type find_first_of( CharT const * s, size_type pos, size_type n ) const // (3) + { + return find_first_of( basic_string_view( s, n ), pos ); + } + + nssv_constexpr size_type find_first_of( CharT const * s, size_type pos = 0 ) const // (4) + { + return find_first_of( basic_string_view( s ), pos ); + } + + // find_last_of(), 4x: + + nssv_constexpr size_type find_last_of( basic_string_view v, size_type pos = npos ) const nssv_noexcept // (1) + { + return empty() + ? npos + : pos >= size() + ? find_last_of( v, size() - 1 ) + : to_pos( std::find_first_of( const_reverse_iterator( cbegin() + pos + 1 ), crend(), v.cbegin(), v.cend(), Traits::eq ) ); + } + + nssv_constexpr size_type find_last_of( CharT c, size_type pos = npos ) const nssv_noexcept // (2) + { + return find_last_of( basic_string_view( &c, 1 ), pos ); + } + + nssv_constexpr size_type find_last_of( CharT const * s, size_type pos, size_type count ) const // (3) + { + return find_last_of( basic_string_view( s, count ), pos ); + } + + nssv_constexpr size_type find_last_of( CharT const * s, size_type pos = npos ) const // (4) + { + return find_last_of( basic_string_view( s ), pos ); + } + + // find_first_not_of(), 4x: + + nssv_constexpr size_type find_first_not_of( basic_string_view v, size_type pos = 0 ) const nssv_noexcept // (1) + { + return pos >= size() + ? npos + : to_pos( std::find_if( cbegin() + pos, cend(), not_in_view( v ) ) ); + } + + nssv_constexpr size_type find_first_not_of( CharT c, size_type pos = 0 ) const nssv_noexcept // (2) + { + return find_first_not_of( basic_string_view( &c, 1 ), pos ); + } + + nssv_constexpr size_type find_first_not_of( CharT const * s, size_type pos, size_type count ) const // (3) + { + return find_first_not_of( basic_string_view( s, count ), pos ); + } + + nssv_constexpr size_type find_first_not_of( CharT const * s, size_type pos = 0 ) const // (4) + { + return find_first_not_of( basic_string_view( s ), pos ); + } + + // find_last_not_of(), 4x: + + nssv_constexpr size_type find_last_not_of( basic_string_view v, size_type pos = npos ) const nssv_noexcept // (1) + { + return empty() + ? npos + : pos >= size() + ? find_last_not_of( v, size() - 1 ) + : to_pos( std::find_if( const_reverse_iterator( cbegin() + pos + 1 ), crend(), not_in_view( v ) ) ); + } + + nssv_constexpr size_type find_last_not_of( CharT c, size_type pos = npos ) const nssv_noexcept // (2) + { + return find_last_not_of( basic_string_view( &c, 1 ), pos ); + } + + nssv_constexpr size_type find_last_not_of( CharT const * s, size_type pos, size_type count ) const // (3) + { + return find_last_not_of( basic_string_view( s, count ), pos ); + } + + nssv_constexpr size_type find_last_not_of( CharT const * s, size_type pos = npos ) const // (4) + { + return find_last_not_of( basic_string_view( s ), pos ); + } + + // Constants: + +#if nssv_CPP17_OR_GREATER + static nssv_constexpr size_type npos = size_type(-1); +#elif nssv_CPP11_OR_GREATER + enum : size_type { npos = size_type(-1) }; +#else + enum { npos = size_type(-1) }; +#endif + +private: + struct not_in_view + { + const basic_string_view v; + + nssv_constexpr not_in_view( basic_string_view v ) : v( v ) {} + + nssv_constexpr bool operator()( CharT c ) const + { + return npos == v.find_first_of( c ); + } + }; + + nssv_constexpr size_type to_pos( const_iterator it ) const + { + return it == cend() ? npos : size_type( it - cbegin() ); + } + + nssv_constexpr size_type to_pos( const_reverse_iterator it ) const + { + return it == crend() ? npos : size_type( crend() - it - 1 ); + } + + nssv_constexpr const_reference data_at( size_type pos ) const + { +#if nssv_BETWEEN( nssv_COMPILER_GNUC_VERSION, 1, 500 ) + return data_[pos]; +#else + return assert( pos < size() ), data_[pos]; +#endif + } + +private: + const_pointer data_; + size_type size_; + +public: +#if nssv_CONFIG_CONVERSION_STD_STRING_CLASS_METHODS + + template< class Allocator > + basic_string_view( std::basic_string const & s ) nssv_noexcept + : data_( s.data() ) + , size_( s.size() ) + {} + +#if nssv_HAVE_EXPLICIT_CONVERSION + + template< class Allocator > + explicit operator std::basic_string() const + { + return to_string( Allocator() ); + } + +#endif // nssv_HAVE_EXPLICIT_CONVERSION + +#if nssv_CPP11_OR_GREATER + + template< class Allocator = std::allocator > + std::basic_string + to_string( Allocator const & a = Allocator() ) const + { + return std::basic_string( begin(), end(), a ); + } + +#else + + std::basic_string + to_string() const + { + return std::basic_string( begin(), end() ); + } + + template< class Allocator > + std::basic_string + to_string( Allocator const & a ) const + { + return std::basic_string( begin(), end(), a ); + } + +#endif // nssv_CPP11_OR_GREATER + +#endif // nssv_CONFIG_CONVERSION_STD_STRING_CLASS_METHODS +}; + +// +// Non-member functions: +// + +// 24.4.3 Non-member comparison functions: +// lexicographically compare two string views (function template): + +template< class CharT, class Traits > +nssv_constexpr bool operator== ( + basic_string_view lhs, + basic_string_view rhs ) nssv_noexcept +{ return lhs.compare( rhs ) == 0 ; } + +template< class CharT, class Traits > +nssv_constexpr bool operator!= ( + basic_string_view lhs, + basic_string_view rhs ) nssv_noexcept +{ return lhs.compare( rhs ) != 0 ; } + +template< class CharT, class Traits > +nssv_constexpr bool operator< ( + basic_string_view lhs, + basic_string_view rhs ) nssv_noexcept +{ return lhs.compare( rhs ) < 0 ; } + +template< class CharT, class Traits > +nssv_constexpr bool operator<= ( + basic_string_view lhs, + basic_string_view rhs ) nssv_noexcept +{ return lhs.compare( rhs ) <= 0 ; } + +template< class CharT, class Traits > +nssv_constexpr bool operator> ( + basic_string_view lhs, + basic_string_view rhs ) nssv_noexcept +{ return lhs.compare( rhs ) > 0 ; } + +template< class CharT, class Traits > +nssv_constexpr bool operator>= ( + basic_string_view lhs, + basic_string_view rhs ) nssv_noexcept +{ return lhs.compare( rhs ) >= 0 ; } + +// Let S be basic_string_view, and sv be an instance of S. +// Implementations shall provide sufficient additional overloads marked +// constexpr and noexcept so that an object t with an implicit conversion +// to S can be compared according to Table 67. + +#if nssv_CPP11_OR_GREATER && ! nssv_BETWEEN( nssv_COMPILER_MSVC_VERSION, 100, 141 ) + +#define nssv_BASIC_STRING_VIEW_I(T,U) typename std::decay< basic_string_view >::type + +#if nssv_BETWEEN( nssv_COMPILER_MSVC_VERSION, 140, 150 ) +# define nssv_MSVC_ORDER(x) , int=x +#else +# define nssv_MSVC_ORDER(x) /*, int=x*/ +#endif + +// == + +template< class CharT, class Traits nssv_MSVC_ORDER(1) > +nssv_constexpr bool operator==( + basic_string_view lhs, + nssv_BASIC_STRING_VIEW_I(CharT, Traits) rhs ) nssv_noexcept +{ return lhs.compare( rhs ) == 0; } + +template< class CharT, class Traits nssv_MSVC_ORDER(2) > +nssv_constexpr bool operator==( + nssv_BASIC_STRING_VIEW_I(CharT, Traits) lhs, + basic_string_view rhs ) nssv_noexcept +{ return lhs.size() == rhs.size() && lhs.compare( rhs ) == 0; } + +// != + +template< class CharT, class Traits nssv_MSVC_ORDER(1) > +nssv_constexpr bool operator!= ( + basic_string_view < CharT, Traits > lhs, + nssv_BASIC_STRING_VIEW_I( CharT, Traits ) rhs ) nssv_noexcept +{ return lhs.size() != rhs.size() || lhs.compare( rhs ) != 0 ; } + +template< class CharT, class Traits nssv_MSVC_ORDER(2) > +nssv_constexpr bool operator!= ( + nssv_BASIC_STRING_VIEW_I( CharT, Traits ) lhs, + basic_string_view < CharT, Traits > rhs ) nssv_noexcept +{ return lhs.compare( rhs ) != 0 ; } + +// < + +template< class CharT, class Traits nssv_MSVC_ORDER(1) > +nssv_constexpr bool operator< ( + basic_string_view < CharT, Traits > lhs, + nssv_BASIC_STRING_VIEW_I( CharT, Traits ) rhs ) nssv_noexcept +{ return lhs.compare( rhs ) < 0 ; } + +template< class CharT, class Traits nssv_MSVC_ORDER(2) > +nssv_constexpr bool operator< ( + nssv_BASIC_STRING_VIEW_I( CharT, Traits ) lhs, + basic_string_view < CharT, Traits > rhs ) nssv_noexcept +{ return lhs.compare( rhs ) < 0 ; } + +// <= + +template< class CharT, class Traits nssv_MSVC_ORDER(1) > +nssv_constexpr bool operator<= ( + basic_string_view < CharT, Traits > lhs, + nssv_BASIC_STRING_VIEW_I( CharT, Traits ) rhs ) nssv_noexcept +{ return lhs.compare( rhs ) <= 0 ; } + +template< class CharT, class Traits nssv_MSVC_ORDER(2) > +nssv_constexpr bool operator<= ( + nssv_BASIC_STRING_VIEW_I( CharT, Traits ) lhs, + basic_string_view < CharT, Traits > rhs ) nssv_noexcept +{ return lhs.compare( rhs ) <= 0 ; } + +// > + +template< class CharT, class Traits nssv_MSVC_ORDER(1) > +nssv_constexpr bool operator> ( + basic_string_view < CharT, Traits > lhs, + nssv_BASIC_STRING_VIEW_I( CharT, Traits ) rhs ) nssv_noexcept +{ return lhs.compare( rhs ) > 0 ; } + +template< class CharT, class Traits nssv_MSVC_ORDER(2) > +nssv_constexpr bool operator> ( + nssv_BASIC_STRING_VIEW_I( CharT, Traits ) lhs, + basic_string_view < CharT, Traits > rhs ) nssv_noexcept +{ return lhs.compare( rhs ) > 0 ; } + +// >= + +template< class CharT, class Traits nssv_MSVC_ORDER(1) > +nssv_constexpr bool operator>= ( + basic_string_view < CharT, Traits > lhs, + nssv_BASIC_STRING_VIEW_I( CharT, Traits ) rhs ) nssv_noexcept +{ return lhs.compare( rhs ) >= 0 ; } + +template< class CharT, class Traits nssv_MSVC_ORDER(2) > +nssv_constexpr bool operator>= ( + nssv_BASIC_STRING_VIEW_I( CharT, Traits ) lhs, + basic_string_view < CharT, Traits > rhs ) nssv_noexcept +{ return lhs.compare( rhs ) >= 0 ; } + +#undef nssv_MSVC_ORDER +#undef nssv_BASIC_STRING_VIEW_I + +#endif // nssv_CPP11_OR_GREATER + +// 24.4.4 Inserters and extractors: + +namespace detail { + +template< class Stream > +void write_padding( Stream & os, std::streamsize n ) +{ + for ( std::streamsize i = 0; i < n; ++i ) + os.rdbuf()->sputc( os.fill() ); +} + +template< class Stream, class View > +Stream & write_to_stream( Stream & os, View const & sv ) +{ + typename Stream::sentry sentry( os ); + + if ( !os ) + return os; + + const std::streamsize length = static_cast( sv.length() ); + + // Whether, and how, to pad: + const bool pad = ( length < os.width() ); + const bool left_pad = pad && ( os.flags() & std::ios_base::adjustfield ) == std::ios_base::right; + + if ( left_pad ) + write_padding( os, os.width() - length ); + + // Write span characters: + os.rdbuf()->sputn( sv.begin(), length ); + + if ( pad && !left_pad ) + write_padding( os, os.width() - length ); + + // Reset output stream width: + os.width( 0 ); + + return os; +} + +} // namespace detail + +template< class CharT, class Traits > +std::basic_ostream & +operator<<( + std::basic_ostream& os, + basic_string_view sv ) +{ + return detail::write_to_stream( os, sv ); +} + +// Several typedefs for common character types are provided: + +typedef basic_string_view string_view; +typedef basic_string_view wstring_view; +#if nssv_HAVE_WCHAR16_T +typedef basic_string_view u16string_view; +typedef basic_string_view u32string_view; +#endif + +}} // namespace nonstd::sv_lite + +// +// 24.4.6 Suffix for basic_string_view literals: +// + +#if nssv_HAVE_USER_DEFINED_LITERALS + +namespace nonstd { +nssv_inline_ns namespace literals { +nssv_inline_ns namespace string_view_literals { + +#if nssv_CONFIG_STD_SV_OPERATOR && nssv_HAVE_STD_DEFINED_LITERALS + +nssv_constexpr nonstd::sv_lite::string_view operator "" sv( const char* str, size_t len ) nssv_noexcept // (1) +{ + return nonstd::sv_lite::string_view{ str, len }; +} + +nssv_constexpr nonstd::sv_lite::u16string_view operator "" sv( const char16_t* str, size_t len ) nssv_noexcept // (2) +{ + return nonstd::sv_lite::u16string_view{ str, len }; +} + +nssv_constexpr nonstd::sv_lite::u32string_view operator "" sv( const char32_t* str, size_t len ) nssv_noexcept // (3) +{ + return nonstd::sv_lite::u32string_view{ str, len }; +} + +nssv_constexpr nonstd::sv_lite::wstring_view operator "" sv( const wchar_t* str, size_t len ) nssv_noexcept // (4) +{ + return nonstd::sv_lite::wstring_view{ str, len }; +} + +#endif // nssv_CONFIG_STD_SV_OPERATOR && nssv_HAVE_STD_DEFINED_LITERALS + +#if nssv_CONFIG_USR_SV_OPERATOR + +nssv_constexpr nonstd::sv_lite::string_view operator "" _sv( const char* str, size_t len ) nssv_noexcept // (1) +{ + return nonstd::sv_lite::string_view{ str, len }; +} + +nssv_constexpr nonstd::sv_lite::u16string_view operator "" _sv( const char16_t* str, size_t len ) nssv_noexcept // (2) +{ + return nonstd::sv_lite::u16string_view{ str, len }; +} + +nssv_constexpr nonstd::sv_lite::u32string_view operator "" _sv( const char32_t* str, size_t len ) nssv_noexcept // (3) +{ + return nonstd::sv_lite::u32string_view{ str, len }; +} + +nssv_constexpr nonstd::sv_lite::wstring_view operator "" _sv( const wchar_t* str, size_t len ) nssv_noexcept // (4) +{ + return nonstd::sv_lite::wstring_view{ str, len }; +} + +#endif // nssv_CONFIG_USR_SV_OPERATOR + +}}} // namespace nonstd::literals::string_view_literals + +#endif + +// +// Extensions for std::string: +// + +#if nssv_CONFIG_CONVERSION_STD_STRING_FREE_FUNCTIONS + +namespace nonstd { +namespace sv_lite { + +// Exclude MSVC 14 (19.00): it yields ambiguous to_string(): + +#if nssv_CPP11_OR_GREATER && nssv_COMPILER_MSVC_VERSION != 140 + +template< class CharT, class Traits, class Allocator = std::allocator > +std::basic_string +to_string( basic_string_view v, Allocator const & a = Allocator() ) +{ + return std::basic_string( v.begin(), v.end(), a ); +} + +#else + +template< class CharT, class Traits > +std::basic_string +to_string( basic_string_view v ) +{ + return std::basic_string( v.begin(), v.end() ); +} + +template< class CharT, class Traits, class Allocator > +std::basic_string +to_string( basic_string_view v, Allocator const & a ) +{ + return std::basic_string( v.begin(), v.end(), a ); +} + +#endif // nssv_CPP11_OR_GREATER + +template< class CharT, class Traits, class Allocator > +basic_string_view +to_string_view( std::basic_string const & s ) +{ + return basic_string_view( s.data(), s.size() ); +} + +}} // namespace nonstd::sv_lite + +#endif // nssv_CONFIG_CONVERSION_STD_STRING_FREE_FUNCTIONS + +// +// make types and algorithms available in namespace nonstd: +// + +namespace nonstd { + +using sv_lite::basic_string_view; +using sv_lite::string_view; +using sv_lite::wstring_view; + +#if nssv_HAVE_WCHAR16_T +using sv_lite::u16string_view; +#endif +#if nssv_HAVE_WCHAR32_T +using sv_lite::u32string_view; +#endif + +// literal "sv" + +using sv_lite::operator==; +using sv_lite::operator!=; +using sv_lite::operator<; +using sv_lite::operator<=; +using sv_lite::operator>; +using sv_lite::operator>=; + +using sv_lite::operator<<; + +#if nssv_CONFIG_CONVERSION_STD_STRING_FREE_FUNCTIONS +using sv_lite::to_string; +using sv_lite::to_string_view; +#endif + +} // namespace nonstd + +// 24.4.5 Hash support (C++11): + +// Note: The hash value of a string view object is equal to the hash value of +// the corresponding string object. + +#if nssv_HAVE_STD_HASH + +#include + +namespace std { + +template<> +struct hash< nonstd::string_view > +{ +public: + std::size_t operator()( nonstd::string_view v ) const nssv_noexcept + { + return std::hash()( std::string( v.data(), v.size() ) ); + } +}; + +template<> +struct hash< nonstd::wstring_view > +{ +public: + std::size_t operator()( nonstd::wstring_view v ) const nssv_noexcept + { + return std::hash()( std::wstring( v.data(), v.size() ) ); + } +}; + +template<> +struct hash< nonstd::u16string_view > +{ +public: + std::size_t operator()( nonstd::u16string_view v ) const nssv_noexcept + { + return std::hash()( std::u16string( v.data(), v.size() ) ); + } +}; + +template<> +struct hash< nonstd::u32string_view > +{ +public: + std::size_t operator()( nonstd::u32string_view v ) const nssv_noexcept + { + return std::hash()( std::u32string( v.data(), v.size() ) ); + } +}; + +} // namespace std + +#endif // nssv_HAVE_STD_HASH + +nssv_RESTORE_WARNINGS() + +#endif // nssv_HAVE_STD_STRING_VIEW +#endif // NONSTD_SV_LITE_H_INCLUDED + + + +namespace inja { + +enum class ElementNotation { + Dot, + Pointer +}; + +struct LexerConfig { + std::string statement_open {"{%"}; + std::string statement_close {"%}"}; + std::string line_statement {"##"}; + std::string expression_open {"{{"}; + std::string expression_close {"}}"}; + std::string comment_open {"{#"}; + std::string comment_close {"#}"}; + std::string open_chars {"#{"}; + + void update_open_chars() { + open_chars = ""; + if (open_chars.find(line_statement[0]) == std::string::npos) { + open_chars += line_statement[0]; + } + if (open_chars.find(statement_open[0]) == std::string::npos) { + open_chars += statement_open[0]; + } + if (open_chars.find(expression_open[0]) == std::string::npos) { + open_chars += expression_open[0]; + } + if (open_chars.find(comment_open[0]) == std::string::npos) { + open_chars += comment_open[0]; + } + } +}; + +struct ParserConfig { + ElementNotation notation {ElementNotation::Dot}; +}; + +} + +#endif // PANTOR_INJA_CONFIG_HPP + +// #include "function_storage.hpp" +#ifndef PANTOR_INJA_FUNCTION_STORAGE_HPP +#define PANTOR_INJA_FUNCTION_STORAGE_HPP + +// #include "bytecode.hpp" +#ifndef PANTOR_INJA_BYTECODE_HPP +#define PANTOR_INJA_BYTECODE_HPP + +#include + +#include + +// #include "string_view.hpp" + + + +namespace inja { + +using namespace nlohmann; + + +struct Bytecode { + enum class Op : uint8_t { + Nop, + // print StringRef (always immediate) + PrintText, + // print value + PrintValue, + // push value onto stack (always immediate) + Push, + + // builtin functions + // result is pushed to stack + // args specify number of arguments + // all functions can take their "last" argument either immediate + // or popped off stack (e.g. if immediate, it's like the immediate was + // just pushed to the stack) + Not, + And, + Or, + In, + Equal, + Greater, + GreaterEqual, + Less, + LessEqual, + Different, + DivisibleBy, + Even, + First, + Float, + Int, + Last, + Length, + Lower, + Max, + Min, + Odd, + Range, + Result, + Round, + Sort, + Upper, + Exists, + ExistsInObject, + IsBoolean, + IsNumber, + IsInteger, + IsFloat, + IsObject, + IsArray, + IsString, + Default, + + // include another template + // value is the template name + Include, + + // callback function + // str is the function name (this means it cannot be a lookup) + // args specify number of arguments + // as with builtin functions, "last" argument can be immediate + Callback, + + // unconditional jump + // args is the index of the bytecode to jump to. + Jump, + + // conditional jump + // value popped off stack is checked for truthyness + // if false, args is the index of the bytecode to jump to. + // if true, no action is taken (falls through) + ConditionalJump, + + // start loop + // value popped off stack is what is iterated over + // args is index of bytecode after end loop (jumped to if iterable is + // empty) + // immediate value is key name (for maps) + // str is value name + StartLoop, + + // end a loop + // args is index of the first bytecode in the loop body + EndLoop, + }; + + enum Flag { + // location of value for value-taking ops (mask) + ValueMask = 0x03, + // pop value off stack + ValuePop = 0x00, + // value is immediate rather than on stack + ValueImmediate = 0x01, + // lookup immediate str (dot notation) + ValueLookupDot = 0x02, + // lookup immediate str (json pointer notation) + ValueLookupPointer = 0x03, + }; + + Op op {Op::Nop}; + uint32_t args: 30; + uint32_t flags: 2; + + json value; + std::string str; + + Bytecode(): args(0), flags(0) {} + explicit Bytecode(Op op, unsigned int args = 0): op(op), args(args), flags(0) {} + explicit Bytecode(Op op, nonstd::string_view str, unsigned int flags): op(op), args(0), flags(flags), str(str) {} + explicit Bytecode(Op op, json&& value, unsigned int flags): op(op), args(0), flags(flags), value(std::move(value)) {} +}; + +} // namespace inja + +#endif // PANTOR_INJA_BYTECODE_HPP + +// #include "string_view.hpp" + + + +namespace inja { + +using namespace nlohmann; + +using Arguments = std::vector; +using CallbackFunction = std::function; + +class FunctionStorage { + public: + void add_builtin(nonstd::string_view name, unsigned int num_args, Bytecode::Op op) { + auto& data = get_or_new(name, num_args); + data.op = op; + } + + void add_callback(nonstd::string_view name, unsigned int num_args, const CallbackFunction& function) { + auto& data = get_or_new(name, num_args); + data.function = function; + } + + Bytecode::Op find_builtin(nonstd::string_view name, unsigned int num_args) const { + if (auto ptr = get(name, num_args)) { + return ptr->op; + } + return Bytecode::Op::Nop; + } + + CallbackFunction find_callback(nonstd::string_view name, unsigned int num_args) const { + if (auto ptr = get(name, num_args)) { + return ptr->function; + } + return nullptr; + } + + private: + struct FunctionData { + unsigned int num_args {0}; + Bytecode::Op op {Bytecode::Op::Nop}; // for builtins + CallbackFunction function; // for callbacks + }; + + FunctionData& get_or_new(nonstd::string_view name, unsigned int num_args) { + auto &vec = m_map[static_cast(name)]; + for (auto &i: vec) { + if (i.num_args == num_args) return i; + } + vec.emplace_back(); + vec.back().num_args = num_args; + return vec.back(); + } + + const FunctionData* get(nonstd::string_view name, unsigned int num_args) const { + auto it = m_map.find(static_cast(name)); + if (it == m_map.end()) return nullptr; + for (auto &&i: it->second) { + if (i.num_args == num_args) return &i; + } + return nullptr; + } + + std::map> m_map; +}; + +} + +#endif // PANTOR_INJA_FUNCTION_STORAGE_HPP + +// #include "parser.hpp" +#ifndef PANTOR_INJA_PARSER_HPP +#define PANTOR_INJA_PARSER_HPP + +#include + +// #include "bytecode.hpp" + +// #include "config.hpp" + +// #include "function_storage.hpp" + +// #include "lexer.hpp" +#ifndef PANTOR_INJA_LEXER_HPP +#define PANTOR_INJA_LEXER_HPP + +#include +#include + +// #include "config.hpp" + +// #include "token.hpp" +#ifndef PANTOR_INJA_TOKEN_HPP +#define PANTOR_INJA_TOKEN_HPP + +// #include "string_view.hpp" + + + +namespace inja { + +struct Token { + enum class Kind { + Text, + ExpressionOpen, // {{ + ExpressionClose, // }} + LineStatementOpen, // ## + LineStatementClose, // \n + StatementOpen, // {% + StatementClose, // %} + CommentOpen, // {# + CommentClose, // #} + Id, // this, this.foo + Number, // 1, 2, -1, 5.2, -5.3 + String, // "this" + Comma, // , + Colon, // : + LeftParen, // ( + RightParen, // ) + LeftBracket, // [ + RightBracket, // ] + LeftBrace, // { + RightBrace, // } + Equal, // == + GreaterThan, // > + GreaterEqual, // >= + LessThan, // < + LessEqual, // <= + NotEqual, // != + Unknown, + Eof + } kind {Kind::Unknown}; + + nonstd::string_view text; + + constexpr Token() = default; + constexpr Token(Kind kind, nonstd::string_view text): kind(kind), text(text) {} + + std::string describe() const { + switch (kind) { + case Kind::Text: + return ""; + case Kind::LineStatementClose: + return ""; + case Kind::Eof: + return ""; + default: + return static_cast(text); + } + } +}; + +} + +#endif // PANTOR_INJA_TOKEN_HPP + +// #include "utils.hpp" +#ifndef PANTOR_INJA_UTILS_HPP +#define PANTOR_INJA_UTILS_HPP + +#include + +// #include "string_view.hpp" + + + +namespace inja { + +inline void inja_throw(const std::string& type, const std::string& message) { + throw std::runtime_error("[inja.exception." + type + "] " + message); +} + +namespace string_view { + inline nonstd::string_view slice(nonstd::string_view view, size_t start, size_t end) { + start = std::min(start, view.size()); + end = std::min(std::max(start, end), view.size()); + return view.substr(start, end - start); // StringRef(Data + Start, End - Start); + } + + inline std::pair split(nonstd::string_view view, char Separator) { + size_t idx = view.find(Separator); + if (idx == nonstd::string_view::npos) { + return std::make_pair(view, nonstd::string_view()); + } + return std::make_pair(slice(view, 0, idx), slice(view, idx + 1, nonstd::string_view::npos)); + } + + inline bool starts_with(nonstd::string_view view, nonstd::string_view prefix) { + return (view.size() >= prefix.size() && view.compare(0, prefix.size(), prefix) == 0); + } +} // namespace string + +} // namespace inja + +#endif // PANTOR_INJA_UTILS_HPP + + + +namespace inja { + +class Lexer { + enum class State { + Text, + ExpressionStart, + ExpressionBody, + LineStart, + LineBody, + StatementStart, + StatementBody, + CommentStart, + CommentBody + } m_state; + + const LexerConfig& m_config; + nonstd::string_view m_in; + size_t m_tok_start; + size_t m_pos; + + public: + explicit Lexer(const LexerConfig& config) : m_config(config) {} + + void start(nonstd::string_view in) { + m_in = in; + m_tok_start = 0; + m_pos = 0; + m_state = State::Text; + } + + Token scan() { + m_tok_start = m_pos; + + again: + if (m_tok_start >= m_in.size()) return make_token(Token::Kind::Eof); + + switch (m_state) { + default: + case State::Text: { + // fast-scan to first open character + size_t open_start = m_in.substr(m_pos).find_first_of(m_config.open_chars); + if (open_start == nonstd::string_view::npos) { + // didn't find open, return remaining text as text token + m_pos = m_in.size(); + return make_token(Token::Kind::Text); + } + m_pos += open_start; + + // try to match one of the opening sequences, and get the close + nonstd::string_view open_str = m_in.substr(m_pos); + if (inja::string_view::starts_with(open_str, m_config.expression_open)) { + m_state = State::ExpressionStart; + } else if (inja::string_view::starts_with(open_str, m_config.statement_open)) { + m_state = State::StatementStart; + } else if (inja::string_view::starts_with(open_str, m_config.comment_open)) { + m_state = State::CommentStart; + } else if ((m_pos == 0 || m_in[m_pos - 1] == '\n') && + inja::string_view::starts_with(open_str, m_config.line_statement)) { + m_state = State::LineStart; + } else { + m_pos += 1; // wasn't actually an opening sequence + goto again; + } + if (m_pos == m_tok_start) goto again; // don't generate empty token + return make_token(Token::Kind::Text); + } + case State::ExpressionStart: { + m_state = State::ExpressionBody; + m_pos += m_config.expression_open.size(); + return make_token(Token::Kind::ExpressionOpen); + } + case State::LineStart: { + m_state = State::LineBody; + m_pos += m_config.line_statement.size(); + return make_token(Token::Kind::LineStatementOpen); + } + case State::StatementStart: { + m_state = State::StatementBody; + m_pos += m_config.statement_open.size(); + return make_token(Token::Kind::StatementOpen); + } + case State::CommentStart: { + m_state = State::CommentBody; + m_pos += m_config.comment_open.size(); + return make_token(Token::Kind::CommentOpen); + } + case State::ExpressionBody: + return scan_body(m_config.expression_close, Token::Kind::ExpressionClose); + case State::LineBody: + return scan_body("\n", Token::Kind::LineStatementClose); + case State::StatementBody: + return scan_body(m_config.statement_close, Token::Kind::StatementClose); + case State::CommentBody: { + // fast-scan to comment close + size_t end = m_in.substr(m_pos).find(m_config.comment_close); + if (end == nonstd::string_view::npos) { + m_pos = m_in.size(); + return make_token(Token::Kind::Eof); + } + // return the entire comment in the close token + m_state = State::Text; + m_pos += end + m_config.comment_close.size(); + return make_token(Token::Kind::CommentClose); + } + } + } + + const LexerConfig& get_config() const { return m_config; } + + private: + Token scan_body(nonstd::string_view close, Token::Kind closeKind) { + again: + // skip whitespace (except for \n as it might be a close) + if (m_tok_start >= m_in.size()) return make_token(Token::Kind::Eof); + char ch = m_in[m_tok_start]; + if (ch == ' ' || ch == '\t' || ch == '\r') { + m_tok_start += 1; + goto again; + } + + // check for close + if (inja::string_view::starts_with(m_in.substr(m_tok_start), close)) { + m_state = State::Text; + m_pos = m_tok_start + close.size(); + return make_token(closeKind); + } + + // skip \n + if (ch == '\n') { + m_tok_start += 1; + goto again; + } + + m_pos = m_tok_start + 1; + if (std::isalpha(ch)) return scan_id(); + switch (ch) { + case ',': + return make_token(Token::Kind::Comma); + case ':': + return make_token(Token::Kind::Colon); + case '(': + return make_token(Token::Kind::LeftParen); + case ')': + return make_token(Token::Kind::RightParen); + case '[': + return make_token(Token::Kind::LeftBracket); + case ']': + return make_token(Token::Kind::RightBracket); + case '{': + return make_token(Token::Kind::LeftBrace); + case '}': + return make_token(Token::Kind::RightBrace); + case '>': + if (m_pos < m_in.size() && m_in[m_pos] == '=') { + m_pos += 1; + return make_token(Token::Kind::GreaterEqual); + } + return make_token(Token::Kind::GreaterThan); + case '<': + if (m_pos < m_in.size() && m_in[m_pos] == '=') { + m_pos += 1; + return make_token(Token::Kind::LessEqual); + } + return make_token(Token::Kind::LessThan); + case '=': + if (m_pos < m_in.size() && m_in[m_pos] == '=') { + m_pos += 1; + return make_token(Token::Kind::Equal); + } + return make_token(Token::Kind::Unknown); + case '!': + if (m_pos < m_in.size() && m_in[m_pos] == '=') { + m_pos += 1; + return make_token(Token::Kind::NotEqual); + } + return make_token(Token::Kind::Unknown); + case '\"': + return scan_string(); + case '0': + case '1': + case '2': + case '3': + case '4': + case '5': + case '6': + case '7': + case '8': + case '9': + case '-': + return scan_number(); + case '_': + return scan_id(); + default: + return make_token(Token::Kind::Unknown); + } + } + + Token scan_id() { + for (;;) { + if (m_pos >= m_in.size()) { + break; + } + char ch = m_in[m_pos]; + if (!std::isalnum(ch) && ch != '.' && ch != '/' && ch != '_' && ch != '-') { + break; + } + m_pos += 1; + } + return make_token(Token::Kind::Id); + } + + Token scan_number() { + for (;;) { + if (m_pos >= m_in.size()) { + break; + } + char ch = m_in[m_pos]; + // be very permissive in lexer (we'll catch errors when conversion happens) + if (!std::isdigit(ch) && ch != '.' && ch != 'e' && ch != 'E' && ch != '+' && ch != '-') { + break; + } + m_pos += 1; + } + return make_token(Token::Kind::Number); + } + + Token scan_string() { + bool escape {false}; + for (;;) { + if (m_pos >= m_in.size()) break; + char ch = m_in[m_pos++]; + if (ch == '\\') { + escape = true; + } else if (!escape && ch == m_in[m_tok_start]) { + break; + } else { + escape = false; + } + } + return make_token(Token::Kind::String); + } + + Token make_token(Token::Kind kind) const { + return Token(kind, string_view::slice(m_in, m_tok_start, m_pos)); + } +}; + +} + +#endif // PANTOR_INJA_LEXER_HPP + +// #include "template.hpp" +#ifndef PANTOR_INJA_TEMPLATE_HPP +#define PANTOR_INJA_TEMPLATE_HPP + +#include +#include + +// #include "bytecode.hpp" + + + +namespace inja { + +struct Template { + std::vector bytecodes; + std::string content; +}; + +using TemplateStorage = std::map; + +} + +#endif // PANTOR_INJA_TEMPLATE_HPP + +// #include "token.hpp" + +// #include "utils.hpp" + + +#include + + +namespace inja { + +class ParserStatic { + ParserStatic() { + functions.add_builtin("default", 2, Bytecode::Op::Default); + functions.add_builtin("divisibleBy", 2, Bytecode::Op::DivisibleBy); + functions.add_builtin("even", 1, Bytecode::Op::Even); + functions.add_builtin("first", 1, Bytecode::Op::First); + functions.add_builtin("float", 1, Bytecode::Op::Float); + functions.add_builtin("int", 1, Bytecode::Op::Int); + functions.add_builtin("last", 1, Bytecode::Op::Last); + functions.add_builtin("length", 1, Bytecode::Op::Length); + functions.add_builtin("lower", 1, Bytecode::Op::Lower); + functions.add_builtin("max", 1, Bytecode::Op::Max); + functions.add_builtin("min", 1, Bytecode::Op::Min); + functions.add_builtin("odd", 1, Bytecode::Op::Odd); + functions.add_builtin("range", 1, Bytecode::Op::Range); + functions.add_builtin("round", 2, Bytecode::Op::Round); + functions.add_builtin("sort", 1, Bytecode::Op::Sort); + functions.add_builtin("upper", 1, Bytecode::Op::Upper); + functions.add_builtin("exists", 1, Bytecode::Op::Exists); + functions.add_builtin("existsIn", 2, Bytecode::Op::ExistsInObject); + functions.add_builtin("isBoolean", 1, Bytecode::Op::IsBoolean); + functions.add_builtin("isNumber", 1, Bytecode::Op::IsNumber); + functions.add_builtin("isInteger", 1, Bytecode::Op::IsInteger); + functions.add_builtin("isFloat", 1, Bytecode::Op::IsFloat); + functions.add_builtin("isObject", 1, Bytecode::Op::IsObject); + functions.add_builtin("isArray", 1, Bytecode::Op::IsArray); + functions.add_builtin("isString", 1, Bytecode::Op::IsString); + } + + public: + ParserStatic(const ParserStatic&) = delete; + ParserStatic& operator=(const ParserStatic&) = delete; + + static const ParserStatic& get_instance() { + static ParserStatic inst; + return inst; + } + + FunctionStorage functions; +}; + +class Parser { + public: + explicit Parser(const ParserConfig& parser_config, const LexerConfig& lexer_config, TemplateStorage& included_templates): m_config(parser_config), m_lexer(lexer_config), m_included_templates(included_templates), m_static(ParserStatic::get_instance()) { } + + bool parse_expression(Template& tmpl) { + if (!parse_expression_and(tmpl)) return false; + if (m_tok.kind != Token::Kind::Id || m_tok.text != "or") return true; + get_next_token(); + if (!parse_expression_and(tmpl)) return false; + append_function(tmpl, Bytecode::Op::Or, 2); + return true; + } + + bool parse_expression_and(Template& tmpl) { + if (!parse_expression_not(tmpl)) return false; + if (m_tok.kind != Token::Kind::Id || m_tok.text != "and") return true; + get_next_token(); + if (!parse_expression_not(tmpl)) return false; + append_function(tmpl, Bytecode::Op::And, 2); + return true; + } + + bool parse_expression_not(Template& tmpl) { + if (m_tok.kind == Token::Kind::Id && m_tok.text == "not") { + get_next_token(); + if (!parse_expression_not(tmpl)) return false; + append_function(tmpl, Bytecode::Op::Not, 1); + return true; + } else { + return parse_expression_comparison(tmpl); + } + } + + bool parse_expression_comparison(Template& tmpl) { + if (!parse_expression_datum(tmpl)) return false; + Bytecode::Op op; + switch (m_tok.kind) { + case Token::Kind::Id: + if (m_tok.text == "in") + op = Bytecode::Op::In; + else + return true; + break; + case Token::Kind::Equal: + op = Bytecode::Op::Equal; + break; + case Token::Kind::GreaterThan: + op = Bytecode::Op::Greater; + break; + case Token::Kind::LessThan: + op = Bytecode::Op::Less; + break; + case Token::Kind::LessEqual: + op = Bytecode::Op::LessEqual; + break; + case Token::Kind::GreaterEqual: + op = Bytecode::Op::GreaterEqual; + break; + case Token::Kind::NotEqual: + op = Bytecode::Op::Different; + break; + default: + return true; + } + get_next_token(); + if (!parse_expression_datum(tmpl)) return false; + append_function(tmpl, op, 2); + return true; + } + + bool parse_expression_datum(Template& tmpl) { + nonstd::string_view json_first; + size_t bracket_level = 0; + size_t brace_level = 0; + + for (;;) { + switch (m_tok.kind) { + case Token::Kind::LeftParen: { + get_next_token(); + if (!parse_expression(tmpl)) return false; + if (m_tok.kind != Token::Kind::RightParen) { + inja_throw("parser_error", "unmatched '('"); + } + get_next_token(); + return true; + } + case Token::Kind::Id: + get_peek_token(); + if (m_peek_tok.kind == Token::Kind::LeftParen) { + // function call, parse arguments + Token func_token = m_tok; + get_next_token(); // id + get_next_token(); // leftParen + unsigned int num_args = 0; + if (m_tok.kind == Token::Kind::RightParen) { + // no args + get_next_token(); + } else { + for (;;) { + if (!parse_expression(tmpl)) { + inja_throw("parser_error", "expected expression, got '" + m_tok.describe() + "'"); + } + num_args += 1; + if (m_tok.kind == Token::Kind::RightParen) { + get_next_token(); + break; + } + if (m_tok.kind != Token::Kind::Comma) { + inja_throw("parser_error", "expected ')' or ',', got '" + m_tok.describe() + "'"); + } + get_next_token(); + } + } + + auto op = m_static.functions.find_builtin(func_token.text, num_args); + + if (op != Bytecode::Op::Nop) { + // swap arguments for default(); see comment in RenderTo() + if (op == Bytecode::Op::Default) + std::swap(tmpl.bytecodes.back(), *(tmpl.bytecodes.rbegin() + 1)); + append_function(tmpl, op, num_args); + return true; + } else { + append_callback(tmpl, func_token.text, num_args); + return true; + } + } else if (m_tok.text == "true" || m_tok.text == "false" || m_tok.text == "null") { + // true, false, null are json literals + if (brace_level == 0 && bracket_level == 0) { + json_first = m_tok.text; + goto returnJson; + } + break; + } else { + // normal literal (json read) + tmpl.bytecodes.emplace_back( + Bytecode::Op::Push, m_tok.text, + m_config.notation == ElementNotation::Pointer ? Bytecode::Flag::ValueLookupPointer : Bytecode::Flag::ValueLookupDot); + get_next_token(); + return true; + } + // json passthrough + case Token::Kind::Number: + case Token::Kind::String: + if (brace_level == 0 && bracket_level == 0) { + json_first = m_tok.text; + goto returnJson; + } + break; + case Token::Kind::Comma: + case Token::Kind::Colon: + if (brace_level == 0 && bracket_level == 0) { + inja_throw("parser_error", "unexpected token '" + m_tok.describe() + "'"); + } + break; + case Token::Kind::LeftBracket: + if (brace_level == 0 && bracket_level == 0) { + json_first = m_tok.text; + } + bracket_level += 1; + break; + case Token::Kind::LeftBrace: + if (brace_level == 0 && bracket_level == 0) { + json_first = m_tok.text; + } + brace_level += 1; + break; + case Token::Kind::RightBracket: + if (bracket_level == 0) { + inja_throw("parser_error", "unexpected ']'"); + } + --bracket_level; + if (brace_level == 0 && bracket_level == 0) goto returnJson; + break; + case Token::Kind::RightBrace: + if (brace_level == 0) { + inja_throw("parser_error", "unexpected '}'"); + } + --brace_level; + if (brace_level == 0 && bracket_level == 0) goto returnJson; + break; + default: + if (brace_level != 0) { + inja_throw("parser_error", "unmatched '{'"); + } + if (bracket_level != 0) { + inja_throw("parser_error", "unmatched '['"); + } + return false; + } + + get_next_token(); + } + + returnJson: + // bridge across all intermediate tokens + nonstd::string_view json_text(json_first.data(), m_tok.text.data() - json_first.data() + m_tok.text.size()); + tmpl.bytecodes.emplace_back(Bytecode::Op::Push, json::parse(json_text), Bytecode::Flag::ValueImmediate); + get_next_token(); + return true; + } + + bool parse_statement(Template& tmpl, nonstd::string_view path) { + if (m_tok.kind != Token::Kind::Id) return false; + + if (m_tok.text == "if") { + get_next_token(); + + // evaluate expression + if (!parse_expression(tmpl)) return false; + + // start a new if block on if stack + m_if_stack.emplace_back(tmpl.bytecodes.size()); + + // conditional jump; destination will be filled in by else or endif + tmpl.bytecodes.emplace_back(Bytecode::Op::ConditionalJump); + } else if (m_tok.text == "endif") { + if (m_if_stack.empty()) { + inja_throw("parser_error", "endif without matching if"); + } + auto& if_data = m_if_stack.back(); + get_next_token(); + + // previous conditional jump jumps here + if (if_data.prev_cond_jump != std::numeric_limits::max()) { + tmpl.bytecodes[if_data.prev_cond_jump].args = tmpl.bytecodes.size(); + } + + // update all previous unconditional jumps to here + for (unsigned int i: if_data.uncond_jumps) { + tmpl.bytecodes[i].args = tmpl.bytecodes.size(); + } + + // pop if stack + m_if_stack.pop_back(); + } else if (m_tok.text == "else") { + if (m_if_stack.empty()) + inja_throw("parser_error", "else without matching if"); + auto& if_data = m_if_stack.back(); + get_next_token(); + + // end previous block with unconditional jump to endif; destination will be + // filled in by endif + if_data.uncond_jumps.push_back(tmpl.bytecodes.size()); + tmpl.bytecodes.emplace_back(Bytecode::Op::Jump); + + // previous conditional jump jumps here + tmpl.bytecodes[if_data.prev_cond_jump].args = tmpl.bytecodes.size(); + if_data.prev_cond_jump = std::numeric_limits::max(); + + // chained else if + if (m_tok.kind == Token::Kind::Id && m_tok.text == "if") { + get_next_token(); + + // evaluate expression + if (!parse_expression(tmpl)) return false; + + // update "previous jump" + if_data.prev_cond_jump = tmpl.bytecodes.size(); + + // conditional jump; destination will be filled in by else or endif + tmpl.bytecodes.emplace_back(Bytecode::Op::ConditionalJump); + } + } else if (m_tok.text == "for") { + get_next_token(); + + // options: for a in arr; for a, b in obj + if (m_tok.kind != Token::Kind::Id) + inja_throw("parser_error", "expected id, got '" + m_tok.describe() + "'"); + Token value_token = m_tok; + get_next_token(); + + Token key_token; + if (m_tok.kind == Token::Kind::Comma) { + get_next_token(); + if (m_tok.kind != Token::Kind::Id) + inja_throw("parser_error", "expected id, got '" + m_tok.describe() + "'"); + key_token = std::move(value_token); + value_token = m_tok; + get_next_token(); + } + + if (m_tok.kind != Token::Kind::Id || m_tok.text != "in") + inja_throw("parser_error", + "expected 'in', got '" + m_tok.describe() + "'"); + get_next_token(); + + if (!parse_expression(tmpl)) return false; + + m_loop_stack.push_back(tmpl.bytecodes.size()); + + tmpl.bytecodes.emplace_back(Bytecode::Op::StartLoop); + if (!key_token.text.empty()) { + tmpl.bytecodes.back().value = key_token.text; + } + tmpl.bytecodes.back().str = static_cast(value_token.text); + } else if (m_tok.text == "endfor") { + get_next_token(); + if (m_loop_stack.empty()) { + inja_throw("parser_error", "endfor without matching for"); + } + + // update loop with EndLoop index (for empty case) + tmpl.bytecodes[m_loop_stack.back()].args = tmpl.bytecodes.size(); + + tmpl.bytecodes.emplace_back(Bytecode::Op::EndLoop); + tmpl.bytecodes.back().args = m_loop_stack.back() + 1; // loop body + m_loop_stack.pop_back(); + } else if (m_tok.text == "include") { + get_next_token(); + + if (m_tok.kind != Token::Kind::String) { + inja_throw("parser_error", "expected string, got '" + m_tok.describe() + "'"); + } + + // build the relative path + json json_name = json::parse(m_tok.text); + std::string pathname = static_cast(path); + pathname += json_name.get_ref(); + if (pathname.compare(0, 2, "./") == 0) { + pathname.erase(0, 2); + } + // sys::path::remove_dots(pathname, true, sys::path::Style::posix); + + Template include_template = parse_template(pathname); + m_included_templates.emplace(pathname, include_template); + + // generate a reference bytecode + tmpl.bytecodes.emplace_back(Bytecode::Op::Include, json(pathname), Bytecode::Flag::ValueImmediate); + + get_next_token(); + } else { + return false; + } + return true; + } + + void append_function(Template& tmpl, Bytecode::Op op, unsigned int num_args) { + // we can merge with back-to-back push + if (!tmpl.bytecodes.empty()) { + Bytecode& last = tmpl.bytecodes.back(); + if (last.op == Bytecode::Op::Push) { + last.op = op; + last.args = num_args; + return; + } + } + + // otherwise just add it to the end + tmpl.bytecodes.emplace_back(op, num_args); + } + + void append_callback(Template& tmpl, nonstd::string_view name, unsigned int num_args) { + // we can merge with back-to-back push value (not lookup) + if (!tmpl.bytecodes.empty()) { + Bytecode& last = tmpl.bytecodes.back(); + if (last.op == Bytecode::Op::Push && + (last.flags & Bytecode::Flag::ValueMask) == Bytecode::Flag::ValueImmediate) { + last.op = Bytecode::Op::Callback; + last.args = num_args; + last.str = static_cast(name); + return; + } + } + + // otherwise just add it to the end + tmpl.bytecodes.emplace_back(Bytecode::Op::Callback, num_args); + tmpl.bytecodes.back().str = static_cast(name); + } + + void parse_into(Template& tmpl, nonstd::string_view path) { + m_lexer.start(tmpl.content); + + for (;;) { + get_next_token(); + switch (m_tok.kind) { + case Token::Kind::Eof: + if (!m_if_stack.empty()) inja_throw("parser_error", "unmatched if"); + if (!m_loop_stack.empty()) inja_throw("parser_error", "unmatched for"); + return; + case Token::Kind::Text: + tmpl.bytecodes.emplace_back(Bytecode::Op::PrintText, m_tok.text, 0u); + break; + case Token::Kind::StatementOpen: + get_next_token(); + if (!parse_statement(tmpl, path)) { + inja_throw("parser_error", "expected statement, got '" + m_tok.describe() + "'"); + } + if (m_tok.kind != Token::Kind::StatementClose) { + inja_throw("parser_error", "expected statement close, got '" + m_tok.describe() + "'"); + } + break; + case Token::Kind::LineStatementOpen: + get_next_token(); + parse_statement(tmpl, path); + if (m_tok.kind != Token::Kind::LineStatementClose && + m_tok.kind != Token::Kind::Eof) { + inja_throw("parser_error", "expected line statement close, got '" + m_tok.describe() + "'"); + } + break; + case Token::Kind::ExpressionOpen: + get_next_token(); + if (!parse_expression(tmpl)) { + inja_throw("parser_error", "expected expression, got '" + m_tok.describe() + "'"); + } + append_function(tmpl, Bytecode::Op::PrintValue, 1); + if (m_tok.kind != Token::Kind::ExpressionClose) { + inja_throw("parser_error", "expected expression close, got '" + m_tok.describe() + "'"); + } + break; + case Token::Kind::CommentOpen: + get_next_token(); + if (m_tok.kind != Token::Kind::CommentClose) { + inja_throw("parser_error", "expected comment close, got '" + m_tok.describe() + "'"); + } + break; + default: + inja_throw("parser_error", "unexpected token '" + m_tok.describe() + "'"); + break; + } + } + } + + Template parse(nonstd::string_view input, nonstd::string_view path) { + Template result; + result.content = static_cast(input); + parse_into(result, path); + return result; + } + + Template parse(nonstd::string_view input) { + return parse(input, "./"); + } + + Template parse_template(nonstd::string_view filename) { + Template result; + result.content = load_file(filename); + + nonstd::string_view path = filename.substr(0, filename.find_last_of("/\\") + 1); + // StringRef path = sys::path::parent_path(filename); + Parser(m_config, m_lexer.get_config(), m_included_templates).parse_into(result, path); + return result; + } + + std::string load_file(nonstd::string_view filename) { + std::ifstream file(static_cast(filename)); + std::string text((std::istreambuf_iterator(file)), std::istreambuf_iterator()); + return text; + } + + private: + const ParserConfig& m_config; + Lexer m_lexer; + Token m_tok; + Token m_peek_tok; + bool m_have_peek_tok {false}; + TemplateStorage& m_included_templates; + const ParserStatic& m_static; + + struct IfData { + unsigned int prev_cond_jump; + std::vector uncond_jumps; + + explicit IfData(unsigned int condJump): prev_cond_jump(condJump) {} + }; + + std::vector m_if_stack; + std::vector m_loop_stack; + + void get_next_token() { + if (m_have_peek_tok) { + m_tok = m_peek_tok; + m_have_peek_tok = false; + } else { + m_tok = m_lexer.scan(); + } + } + + void get_peek_token() { + if (!m_have_peek_tok) { + m_peek_tok = m_lexer.scan(); + m_have_peek_tok = true; + } + } +}; + +} // namespace inja + +#endif // PANTOR_INJA_PARSER_HPP + +// #include "polyfill.hpp" +#ifndef PANTOR_INJA_POLYFILL_HPP +#define PANTOR_INJA_POLYFILL_HPP + + +#if __cplusplus < 201402L + +#include +#include +#include + + +namespace stdinja { + template struct _Unique_if { + typedef std::unique_ptr _Single_object; + }; + + template struct _Unique_if { + typedef std::unique_ptr _Unknown_bound; + }; + + template struct _Unique_if { + typedef void _Known_bound; + }; + + template + typename _Unique_if::_Single_object + make_unique(Args&&... args) { + return std::unique_ptr(new T(std::forward(args)...)); + } + + template + typename _Unique_if::_Unknown_bound + make_unique(size_t n) { + typedef typename std::remove_extent::type U; + return std::unique_ptr(new U[n]()); + } + + template + typename _Unique_if::_Known_bound + make_unique(Args&&...) = delete; +} + +#else + +namespace stdinja = std; + +#endif // memory */ + + +#endif // PANTOR_INJA_POLYFILL_HPP + +// #include "renderer.hpp" +#ifndef PANTOR_INJA_RENDERER_HPP +#define PANTOR_INJA_RENDERER_HPP + +#include +#include + +#include + +// #include "bytecode.hpp" + +// #include "template.hpp" + +// #include "utils.hpp" + + + +namespace inja { + +inline nonstd::string_view convert_dot_to_json_pointer(nonstd::string_view dot, std::string& out) { + out.clear(); + do { + nonstd::string_view part; + std::tie(part, dot) = string_view::split(dot, '.'); + out.push_back('/'); + out.append(part.begin(), part.end()); + } while (!dot.empty()); + return nonstd::string_view(out.data(), out.size()); +} + +class Renderer { + std::vector& get_args(const Bytecode& bc) { + m_tmp_args.clear(); + + bool has_imm = ((bc.flags & Bytecode::Flag::ValueMask) != Bytecode::Flag::ValuePop); + + // get args from stack + unsigned int pop_args = bc.args; + if (has_imm) { + pop_args -= 1; + } + + for (auto i = std::prev(m_stack.end(), pop_args); i != m_stack.end(); i++) { + m_tmp_args.push_back(&(*i)); + } + + // get immediate arg + if (has_imm) { + m_tmp_args.push_back(get_imm(bc)); + } + + return m_tmp_args; + } + + void pop_args(const Bytecode& bc) { + unsigned int popArgs = bc.args; + if ((bc.flags & Bytecode::Flag::ValueMask) != Bytecode::Flag::ValuePop) { + popArgs -= 1; + } + for (unsigned int i = 0; i < popArgs; ++i) { + m_stack.pop_back(); + } + } + + const json* get_imm(const Bytecode& bc) { + std::string ptr_buffer; + nonstd::string_view ptr; + switch (bc.flags & Bytecode::Flag::ValueMask) { + case Bytecode::Flag::ValuePop: + return nullptr; + case Bytecode::Flag::ValueImmediate: + return &bc.value; + case Bytecode::Flag::ValueLookupDot: + ptr = convert_dot_to_json_pointer(bc.str, ptr_buffer); + break; + case Bytecode::Flag::ValueLookupPointer: + ptr_buffer += '/'; + ptr_buffer += bc.str; + ptr = ptr_buffer; + break; + } + try { + return &m_data->at(json::json_pointer(ptr.data())); + } catch (std::exception&) { + // try to evaluate as a no-argument callback + if (auto callback = m_callbacks.find_callback(bc.str, 0)) { + std::vector arguments {}; + m_tmp_val = callback(arguments); + return &m_tmp_val; + } + inja_throw("render_error", "variable '" + static_cast(bc.str) + "' not found"); + return nullptr; + } + } + + bool truthy(const json& var) const { + if (var.empty()) { + return false; + } else if (var.is_number()) { + return (var != 0); + } else if (var.is_string()) { + return !var.empty(); + } + + try { + return var.get(); + } catch (json::type_error& e) { + inja_throw("json_error", e.what()); + throw; + } + } + + void update_loop_data() { + LoopLevel& level = m_loop_stack.back(); + + if (level.loop_type == LoopLevel::Type::Array) { + level.data[static_cast(level.value_name)] = level.values.at(level.index); // *level.it; + auto& loopData = level.data["loop"]; + loopData["index"] = level.index; + loopData["index1"] = level.index + 1; + loopData["is_first"] = (level.index == 0); + loopData["is_last"] = (level.index == level.size - 1); + } else { + level.data[static_cast(level.key_name)] = level.map_it->first; + level.data[static_cast(level.value_name)] = *level.map_it->second; + } + } + + const TemplateStorage& m_included_templates; + const FunctionStorage& m_callbacks; + + std::vector m_stack; + + + struct LoopLevel { + enum class Type { Map, Array }; + + Type loop_type; + nonstd::string_view key_name; // variable name for keys + nonstd::string_view value_name; // variable name for values + json data; // data with loop info added + + json values; // values to iterate over + + // loop over list + size_t index; // current list index + size_t size; // length of list + + // loop over map + using KeyValue = std::pair; + using MapValues = std::vector; + MapValues map_values; // values to iterate over + MapValues::iterator map_it; // iterator over values + + }; + + std::vector m_loop_stack; + const json* m_data; + + std::vector m_tmp_args; + json m_tmp_val; + + + public: + Renderer(const TemplateStorage& included_templates, const FunctionStorage& callbacks): m_included_templates(included_templates), m_callbacks(callbacks) { + m_stack.reserve(16); + m_tmp_args.reserve(4); + m_loop_stack.reserve(16); + } + + void render_to(std::ostream& os, const Template& tmpl, const json& data) { + m_data = &data; + + for (size_t i = 0; i < tmpl.bytecodes.size(); ++i) { + const auto& bc = tmpl.bytecodes[i]; + + switch (bc.op) { + case Bytecode::Op::Nop: { + break; + } + case Bytecode::Op::PrintText: { + os << bc.str; + break; + } + case Bytecode::Op::PrintValue: { + const json& val = *get_args(bc)[0]; + if (val.is_string()) + os << val.get_ref(); + else + os << val.dump(); + // val.dump(os); + pop_args(bc); + break; + } + case Bytecode::Op::Push: { + m_stack.emplace_back(*get_imm(bc)); + break; + } + case Bytecode::Op::Upper: { + auto result = get_args(bc)[0]->get(); + std::transform(result.begin(), result.end(), result.begin(), ::toupper); + pop_args(bc); + m_stack.emplace_back(std::move(result)); + break; + } + case Bytecode::Op::Lower: { + auto result = get_args(bc)[0]->get(); + std::transform(result.begin(), result.end(), result.begin(), ::tolower); + pop_args(bc); + m_stack.emplace_back(std::move(result)); + break; + } + case Bytecode::Op::Range: { + int number = get_args(bc)[0]->get(); + std::vector result(number); + std::iota(std::begin(result), std::end(result), 0); + pop_args(bc); + m_stack.emplace_back(std::move(result)); + break; + } + case Bytecode::Op::Length: { + auto result = get_args(bc)[0]->size(); + pop_args(bc); + m_stack.emplace_back(result); + break; + } + case Bytecode::Op::Sort: { + auto result = get_args(bc)[0]->get>(); + std::sort(result.begin(), result.end()); + pop_args(bc); + m_stack.emplace_back(std::move(result)); + break; + } + case Bytecode::Op::First: { + auto result = get_args(bc)[0]->front(); + pop_args(bc); + m_stack.emplace_back(result); + break; + } + case Bytecode::Op::Last: { + auto result = get_args(bc)[0]->back(); + pop_args(bc); + m_stack.emplace_back(result); + break; + } + case Bytecode::Op::Round: { + auto args = get_args(bc); + double number = args[0]->get(); + int precision = args[1]->get(); + pop_args(bc); + m_stack.emplace_back(std::round(number * std::pow(10.0, precision)) / std::pow(10.0, precision)); + break; + } + case Bytecode::Op::DivisibleBy: { + auto args = get_args(bc); + int number = args[0]->get(); + int divisor = args[1]->get(); + pop_args(bc); + m_stack.emplace_back((divisor != 0) && (number % divisor == 0)); + break; + } + case Bytecode::Op::Odd: { + int number = get_args(bc)[0]->get(); + pop_args(bc); + m_stack.emplace_back(number % 2 != 0); + break; + } + case Bytecode::Op::Even: { + int number = get_args(bc)[0]->get(); + pop_args(bc); + m_stack.emplace_back(number % 2 == 0); + break; + } + case Bytecode::Op::Max: { + auto args = get_args(bc); + auto result = *std::max_element(args[0]->begin(), args[0]->end()); + pop_args(bc); + m_stack.emplace_back(std::move(result)); + break; + } + case Bytecode::Op::Min: { + auto args = get_args(bc); + auto result = *std::min_element(args[0]->begin(), args[0]->end()); + pop_args(bc); + m_stack.emplace_back(std::move(result)); + break; + } + case Bytecode::Op::Not: { + bool result = !truthy(*get_args(bc)[0]); + pop_args(bc); + m_stack.emplace_back(result); + break; + } + case Bytecode::Op::And: { + auto args = get_args(bc); + bool result = truthy(*args[0]) && truthy(*args[1]); + pop_args(bc); + m_stack.emplace_back(result); + break; + } + case Bytecode::Op::Or: { + auto args = get_args(bc); + bool result = truthy(*args[0]) || truthy(*args[1]); + pop_args(bc); + m_stack.emplace_back(result); + break; + } + case Bytecode::Op::In: { + auto args = get_args(bc); + bool result = std::find(args[1]->begin(), args[1]->end(), *args[0]) != + args[1]->end(); + pop_args(bc); + m_stack.emplace_back(result); + break; + } + case Bytecode::Op::Equal: { + auto args = get_args(bc); + bool result = (*args[0] == *args[1]); + pop_args(bc); + m_stack.emplace_back(result); + break; + } + case Bytecode::Op::Greater: { + auto args = get_args(bc); + bool result = (*args[0] > *args[1]); + pop_args(bc); + m_stack.emplace_back(result); + break; + } + case Bytecode::Op::Less: { + auto args = get_args(bc); + bool result = (*args[0] < *args[1]); + pop_args(bc); + m_stack.emplace_back(result); + break; + } + case Bytecode::Op::GreaterEqual: { + auto args = get_args(bc); + bool result = (*args[0] >= *args[1]); + pop_args(bc); + m_stack.emplace_back(result); + break; + } + case Bytecode::Op::LessEqual: { + auto args = get_args(bc); + bool result = (*args[0] <= *args[1]); + pop_args(bc); + m_stack.emplace_back(result); + break; + } + case Bytecode::Op::Different: { + auto args = get_args(bc); + bool result = (*args[0] != *args[1]); + pop_args(bc); + m_stack.emplace_back(result); + break; + } + case Bytecode::Op::Float: { + double result = + std::stod(get_args(bc)[0]->get_ref()); + pop_args(bc); + m_stack.emplace_back(result); + break; + } + case Bytecode::Op::Int: { + int result = std::stoi(get_args(bc)[0]->get_ref()); + pop_args(bc); + m_stack.emplace_back(result); + break; + } + case Bytecode::Op::Exists: { + auto&& name = get_args(bc)[0]->get_ref(); + bool result = (data.find(name) != data.end()); + pop_args(bc); + m_stack.emplace_back(result); + break; + } + case Bytecode::Op::ExistsInObject: { + auto args = get_args(bc); + auto&& name = args[1]->get_ref(); + bool result = (args[0]->find(name) != args[0]->end()); + pop_args(bc); + m_stack.emplace_back(result); + break; + } + case Bytecode::Op::IsBoolean: { + bool result = get_args(bc)[0]->is_boolean(); + pop_args(bc); + m_stack.emplace_back(result); + break; + } + case Bytecode::Op::IsNumber: { + bool result = get_args(bc)[0]->is_number(); + pop_args(bc); + m_stack.emplace_back(result); + break; + } + case Bytecode::Op::IsInteger: { + bool result = get_args(bc)[0]->is_number_integer(); + pop_args(bc); + m_stack.emplace_back(result); + break; + } + case Bytecode::Op::IsFloat: { + bool result = get_args(bc)[0]->is_number_float(); + pop_args(bc); + m_stack.emplace_back(result); + break; + } + case Bytecode::Op::IsObject: { + bool result = get_args(bc)[0]->is_object(); + pop_args(bc); + m_stack.emplace_back(result); + break; + } + case Bytecode::Op::IsArray: { + bool result = get_args(bc)[0]->is_array(); + pop_args(bc); + m_stack.emplace_back(result); + break; + } + case Bytecode::Op::IsString: { + bool result = get_args(bc)[0]->is_string(); + pop_args(bc); + m_stack.emplace_back(result); + break; + } + case Bytecode::Op::Default: { + // default needs to be a bit "magic"; we can't evaluate the first + // argument during the push operation, so we swap the arguments during + // the parse phase so the second argument is pushed on the stack and + // the first argument is in the immediate + try { + const json* imm = get_imm(bc); + // if no exception was raised, replace the stack value with it + m_stack.back() = *imm; + } catch (std::exception&) { + // couldn't read immediate, just leave the stack as is + } + break; + } + case Bytecode::Op::Include: + Renderer(m_included_templates, m_callbacks).render_to(os, m_included_templates.find(get_imm(bc)->get_ref())->second, data); + break; + case Bytecode::Op::Callback: { + auto callback = m_callbacks.find_callback(bc.str, bc.args); + if (!callback) { + inja_throw("render_error", "function '" + static_cast(bc.str) + "' (" + std::to_string(static_cast(bc.args)) + ") not found"); + } + json result = callback(get_args(bc)); + pop_args(bc); + m_stack.emplace_back(std::move(result)); + break; + } + case Bytecode::Op::Jump: { + i = bc.args - 1; // -1 due to ++i in loop + break; + } + case Bytecode::Op::ConditionalJump: { + if (!truthy(m_stack.back())) { + i = bc.args - 1; // -1 due to ++i in loop + } + m_stack.pop_back(); + break; + } + case Bytecode::Op::StartLoop: { + // jump past loop body if empty + if (m_stack.back().empty()) { + m_stack.pop_back(); + i = bc.args; // ++i in loop will take it past EndLoop + break; + } + + m_loop_stack.emplace_back(); + LoopLevel& level = m_loop_stack.back(); + level.value_name = bc.str; + level.values = std::move(m_stack.back()); + level.data = (*m_data); + m_stack.pop_back(); + + if (bc.value.is_string()) { + // map iterator + if (!level.values.is_object()) { + m_loop_stack.pop_back(); + inja_throw("render_error", "for key, value requires object"); + } + level.loop_type = LoopLevel::Type::Map; + level.key_name = bc.value.get_ref(); + + // sort by key + for (auto it = level.values.begin(), end = level.values.end(); it != end; ++it) { + level.map_values.emplace_back(it.key(), &it.value()); + } + std::sort(level.map_values.begin(), level.map_values.end(), [](const LoopLevel::KeyValue& a, const LoopLevel::KeyValue& b) { return a.first < b.first; }); + level.map_it = level.map_values.begin(); + } else { + if (!level.values.is_array()) { + m_loop_stack.pop_back(); + inja_throw("render_error", "type must be array"); + } + + // list iterator + level.loop_type = LoopLevel::Type::Array; + level.index = 0; + level.size = level.values.size(); + } + + // provide parent access in nested loop + auto parent_loop_it = level.data.find("loop"); + if (parent_loop_it != level.data.end()) { + json loop_copy = *parent_loop_it; + (*parent_loop_it)["parent"] = std::move(loop_copy); + } + + // set "current" data to loop data + m_data = &level.data; + update_loop_data(); + break; + } + case Bytecode::Op::EndLoop: { + if (m_loop_stack.empty()) { + inja_throw("render_error", "unexpected state in renderer"); + } + LoopLevel& level = m_loop_stack.back(); + + bool done; + if (level.loop_type == LoopLevel::Type::Array) { + level.index += 1; + done = (level.index == level.values.size()); + } else { + level.map_it += 1; + done = (level.map_it == level.map_values.end()); + } + + if (done) { + m_loop_stack.pop_back(); + // set "current" data to outer loop data or main data as appropriate + if (!m_loop_stack.empty()) { + m_data = &m_loop_stack.back().data; + } else { + m_data = &data; + } + break; + } + + update_loop_data(); + + // jump back to start of loop + i = bc.args - 1; // -1 due to ++i in loop + break; + } + default: { + inja_throw("render_error", "unknown op in renderer: " + std::to_string(static_cast(bc.op))); + } + } + } + } +}; + +} // namespace inja + +#endif // PANTOR_INJA_RENDERER_HPP + +// #include "string_view.hpp" + +// #include "template.hpp" + + + +namespace inja { + +using namespace nlohmann; + +class Environment { + class Impl { + public: + std::string input_path; + std::string output_path; + + LexerConfig lexer_config; + ParserConfig parser_config; + + FunctionStorage callbacks; + TemplateStorage included_templates; + }; + + std::unique_ptr m_impl; + + public: + Environment(): Environment("./") { } + + explicit Environment(const std::string& global_path): m_impl(stdinja::make_unique()) { + m_impl->input_path = global_path; + m_impl->output_path = global_path; + } + + explicit Environment(const std::string& input_path, const std::string& output_path): m_impl(stdinja::make_unique()) { + m_impl->input_path = input_path; + m_impl->output_path = output_path; + } + + /// Sets the opener and closer for template statements + void set_statement(const std::string& open, const std::string& close) { + m_impl->lexer_config.statement_open = open; + m_impl->lexer_config.statement_close = close; + m_impl->lexer_config.update_open_chars(); + } + + /// Sets the opener for template line statements + void set_line_statement(const std::string& open) { + m_impl->lexer_config.line_statement = open; + m_impl->lexer_config.update_open_chars(); + } + + /// Sets the opener and closer for template expressions + void set_expression(const std::string& open, const std::string& close) { + m_impl->lexer_config.expression_open = open; + m_impl->lexer_config.expression_close = close; + m_impl->lexer_config.update_open_chars(); + } + + /// Sets the opener and closer for template comments + void set_comment(const std::string& open, const std::string& close) { + m_impl->lexer_config.comment_open = open; + m_impl->lexer_config.comment_close = close; + m_impl->lexer_config.update_open_chars(); + } + + /// Sets the element notation syntax + void set_element_notation(ElementNotation notation) { + m_impl->parser_config.notation = notation; + } + + + Template parse(nonstd::string_view input) { + Parser parser(m_impl->parser_config, m_impl->lexer_config, m_impl->included_templates); + return parser.parse(input); + } + + Template parse_template(const std::string& filename) { + Parser parser(m_impl->parser_config, m_impl->lexer_config, m_impl->included_templates); + return parser.parse_template(m_impl->input_path + static_cast(filename)); + } + + std::string render(nonstd::string_view input, const json& data) { + return render(parse(input), data); + } + + std::string render(const Template& tmpl, const json& data) { + std::stringstream os; + render_to(os, tmpl, data); + return os.str(); + } + + std::string render_file(const std::string& filename, const json& data) { + return render(parse_template(filename), data); + } + + std::string render_file_with_json_file(const std::string& filename, const std::string& filename_data) { + const json data = load_json(filename_data); + return render_file(filename, data); + } + + void write(const std::string& filename, const json& data, const std::string& filename_out) { + std::ofstream file(m_impl->output_path + filename_out); + file << render_file(filename, data); + file.close(); + } + + void write(const Template& temp, const json& data, const std::string& filename_out) { + std::ofstream file(m_impl->output_path + filename_out); + file << render(temp, data); + file.close(); + } + + void write_with_json_file(const std::string& filename, const std::string& filename_data, const std::string& filename_out) { + const json data = load_json(filename_data); + write(filename, data, filename_out); + } + + void write_with_json_file(const Template& temp, const std::string& filename_data, const std::string& filename_out) { + const json data = load_json(filename_data); + write(temp, data, filename_out); + } + + std::ostream& render_to(std::ostream& os, const Template& tmpl, const json& data) { + Renderer(m_impl->included_templates, m_impl->callbacks).render_to(os, tmpl, data); + return os; + } + + std::string load_file(const std::string& filename) { + Parser parser(m_impl->parser_config, m_impl->lexer_config, m_impl->included_templates); + return parser.load_file(m_impl->input_path + filename); + } + + json load_json(const std::string& filename) { + std::ifstream file(m_impl->input_path + filename); + json j; + file >> j; + return j; + } + + void add_callback(const std::string& name, unsigned int numArgs, const CallbackFunction& callback) { + m_impl->callbacks.add_callback(name, numArgs, callback); + } + + /** Includes a template with a given name into the environment. + * Then, a template can be rendered in another template using the + * include "" syntax. + */ + void include_template(const std::string& name, const Template& tmpl) { + m_impl->included_templates[name] = tmpl; + } +}; + +/*! +@brief render with default settings to a string +*/ +inline std::string render(nonstd::string_view input, const json& data) { + return Environment().render(input, data); +} + +/*! +@brief render with default settings to the given output stream +*/ +inline void render_to(std::ostream& os, nonstd::string_view input, const json& data) { + Environment env; + env.render_to(os, env.parse(input), data); +} + +} + +#endif // PANTOR_INJA_ENVIRONMENT_HPP + +// #include "string_view.hpp" + +// #include "template.hpp" + +// #include "parser.hpp" + +// #include "renderer.hpp" + + + +#endif // PANTOR_INJA_HPP diff --git a/python/CMakeLists.txt b/python/CMakeLists.txt index 83e920cc308..15482096a2e 100644 --- a/python/CMakeLists.txt +++ b/python/CMakeLists.txt @@ -255,6 +255,7 @@ IF (WITH_SERVER AND WITH_SERVER_PLUGINS) INCLUDE_DIRECTORIES( ../src/server ${CMAKE_BINARY_DIR}/src/server + ${CMAKE_SOURCE_DIR}/external ) SET(PY_MODULES ${PY_MODULES} server) diff --git a/python/core/auto_generated/qgsjsonutils.sip.in b/python/core/auto_generated/qgsjsonutils.sip.in index b64554d9f6e..3a28ac3149d 100644 --- a/python/core/auto_generated/qgsjsonutils.sip.in +++ b/python/core/auto_generated/qgsjsonutils.sip.in @@ -250,6 +250,7 @@ Returns a GeoJSON string representation of a list of features (feature collectio .. seealso:: :py:func:`exportFeature` %End + }; diff --git a/python/core/auto_generated/qgsproject.sip.in b/python/core/auto_generated/qgsproject.sip.in index 40231b63f8f..bc9e73ce04c 100644 --- a/python/core/auto_generated/qgsproject.sip.in +++ b/python/core/auto_generated/qgsproject.sip.in @@ -749,6 +749,21 @@ Retrieve a list of matching registered layers by layer name. .. seealso:: :py:func:`mapLayers` %End + QList mapLayersByShortName( const QString &shortName ) const; +%Docstring +Retrieves a list of matching registered layers by layer ``shortName``. +If layer's short name is empty a match with layer's name is attempted. + +:return: list of matching layers + +.. seealso:: :py:func:`mapLayer` + +.. seealso:: :py:func:`mapLayers` + +.. versionadded:: 3.10 +%End + + QMap mapLayers( const bool validOnly = false ) const; %Docstring Returns a map of all registered layers by layer ID. diff --git a/python/core/core.sip.in b/python/core/core.sip.in index 3256aa8c53d..171a323c276 100644 --- a/python/core/core.sip.in +++ b/python/core/core.sip.in @@ -103,6 +103,8 @@ done: %Include conversions.sip %Include qgsexception.sip %Include typedefs.sip +%Include std.sip + %Include core_auto.sip diff --git a/python/core/std.sip b/python/core/std.sip new file mode 100644 index 00000000000..c0f63dd3f69 --- /dev/null +++ b/python/core/std.sip @@ -0,0 +1,42 @@ +/* std:: conversions */ + +%MappedType std::string +{ +%TypeHeaderCode +#include +%End + +%ConvertFromTypeCode + // convert an std::string to a Python (unicode) string + PyObject* newstring; + newstring = PyUnicode_DecodeUTF8(sipCpp->c_str(), sipCpp->length(), NULL); + if(newstring == NULL) { + PyErr_Clear(); + newstring = PyUnicode_FromString(sipCpp->c_str()); + } + return newstring; +%End + + +%ConvertToTypeCode + // Allow a Python string (or a unicode string) whenever a string is + // expected. + // If argument is a Unicode string, just decode it to UTF-8 + if (sipIsErr == NULL) + return (PyUnicode_Check(sipPy)); + if (sipPy == Py_None) { + *sipCppPtr = new std::string; + return 1; + } + if (PyUnicode_Check(sipPy)) { + Py_ssize_t size; + char *s = PyUnicode_AsUTF8AndSize(sipPy, &size); + if (!s) { + return NULL; + } + *sipCppPtr = new std::string(s); + return 1; + } +return 0; +%End +}; diff --git a/python/server/auto_additions/qgsserverogcapi.py b/python/server/auto_additions/qgsserverogcapi.py new file mode 100644 index 00000000000..093e839d48e --- /dev/null +++ b/python/server/auto_additions/qgsserverogcapi.py @@ -0,0 +1,3 @@ +# The following has been generated automatically from src/server/qgsserverogcapi.h +QgsServerOgcApi.Rel.baseClass = QgsServerOgcApi +QgsServerOgcApi.ContentType.baseClass = QgsServerOgcApi diff --git a/python/server/auto_additions/qgsserverquerystringparameter.py b/python/server/auto_additions/qgsserverquerystringparameter.py new file mode 100644 index 00000000000..aa58806e439 --- /dev/null +++ b/python/server/auto_additions/qgsserverquerystringparameter.py @@ -0,0 +1,10 @@ +# The following has been generated automatically from src/server/qgsserverquerystringparameter.h +# monkey patching scoped based enum +QgsServerQueryStringParameter.Type.String.__doc__ = "" +QgsServerQueryStringParameter.Type.Integer.__doc__ = "" +QgsServerQueryStringParameter.Type.Double.__doc__ = "" +QgsServerQueryStringParameter.Type.Boolean.__doc__ = "" +QgsServerQueryStringParameter.Type.List.__doc__ = "" +QgsServerQueryStringParameter.Type.__doc__ = 'The Type enum represents the parameter type\n\n' + '* ``String``: ' + QgsServerQueryStringParameter.Type.String.__doc__ + '\n' + '* ``Integer``: ' + QgsServerQueryStringParameter.Type.Integer.__doc__ + '\n' + '* ``Double``: ' + QgsServerQueryStringParameter.Type.Double.__doc__ + '\n' + '* ``Boolean``: ' + QgsServerQueryStringParameter.Type.Boolean.__doc__ + '\n' + '* ``List``: ' + QgsServerQueryStringParameter.Type.List.__doc__ +# -- +QgsServerQueryStringParameter.Type.baseClass = QgsServerQueryStringParameter diff --git a/python/server/auto_generated/qgsserver.sip.in b/python/server/auto_generated/qgsserver.sip.in index 1353c664132..a8e143295fa 100644 --- a/python/server/auto_generated/qgsserver.sip.in +++ b/python/server/auto_generated/qgsserver.sip.in @@ -61,7 +61,10 @@ Returns a pointer to the server interface void initPython(); %Docstring Initialize Python -Note: not in Python bindings + +.. note:: + + not available in Python bindings %End private: diff --git a/python/server/auto_generated/qgsserverapi.sip.in b/python/server/auto_generated/qgsserverapi.sip.in new file mode 100644 index 00000000000..9f491870d1c --- /dev/null +++ b/python/server/auto_generated/qgsserverapi.sip.in @@ -0,0 +1,130 @@ +/************************************************************************ + * This file has been generated automatically from * + * * + * src/server/qgsserverapi.h * + * * + * Do not edit manually ! Edit header and run scripts/sipify.pl again * + ************************************************************************/ + + + + + + + +class QgsServerApi +{ +%Docstring +Server generic API endpoint abstract base class. + +.. seealso:: :py:class:`QgsServerOgcApi` + +An API must have a name and a (possibly empty) version and define a +(possibly empty) root path (e.g. "/wfs3"). + +The server routing logic will check incoming request URLs by passing them +to the API's accept(url) method, the default implementation performs a simple +check for the presence of the API's root path string in the URL. +This simple logic implies that APIs must be registered in reverse order from the +most specific to the most generic: given two APIs with root paths '/wfs' and '/wfs3', +'/wfs3' must be registered first or it will be shadowed by '/wfs'. +APIs developers are encouraged to implement a more robust accept(url) logic by +making sure that their APIs accept only URLs they can actually handle, if they do, +the APIs registration order becomes irrelevant. + +After the API has been registered to the server API registry: + +.. code-block:: python + + class API(QgsServerApi): + + def name(self): + return "Test API" + + def rootPath(self): + return "/testapi" + + def executeRequest(self, request_context): + request_context.response().write(b"\"Test API\"") + + server = QgsServer() + api = API(server.serverInterface()) + server.serverInterface().serviceRegistry().registerApi(api) + +the incoming calls with an URL path starting with the API root path +will be routed to the first matching API and executeRequest() method +of the API will be invoked. + + + +.. versionadded:: 3.10 +%End + +%TypeHeaderCode +#include "qgsserverapi.h" +%End + public: + + QgsServerApi( QgsServerInterface *serverIface ); +%Docstring +Creates a QgsServerApi object +%End + + virtual ~QgsServerApi(); + + virtual const QString name() const = 0; +%Docstring +Returns the API name +%End + + virtual const QString description() const = 0; +%Docstring +Returns the API description +%End + + virtual const QString version() const; +%Docstring +Returns the version of the service + +.. note:: + + the default implementation returns an empty string +%End + + virtual const QString rootPath() const = 0; +%Docstring +Returns the root path for the API +%End + + virtual bool allowMethod( QgsServerRequest::Method ) const; +%Docstring +Returns ``True`` if the given method is supported by the API, default implementation supports all methods. +%End + + virtual bool accept( const QUrl &url ) const; +%Docstring +Returns ``True`` if the given ``url`` is handled by the API, default implementation checks for the presence of rootPath inside the ``url`` path. +%End + + virtual void executeRequest( const QgsServerApiContext &context ) const = 0; +%Docstring +Executes a request by passing the given ``context`` to the API handlers. +%End + + QgsServerInterface *serverIface() const; +%Docstring +Returns the server interface +%End + +}; + + + + +/************************************************************************ + * This file has been generated automatically from * + * * + * src/server/qgsserverapi.h * + * * + * Do not edit manually ! Edit header and run scripts/sipify.pl again * + ************************************************************************/ diff --git a/python/server/auto_generated/qgsserverapicontext.sip.in b/python/server/auto_generated/qgsserverapicontext.sip.in new file mode 100644 index 00000000000..5642ce0e5b9 --- /dev/null +++ b/python/server/auto_generated/qgsserverapicontext.sip.in @@ -0,0 +1,100 @@ +/************************************************************************ + * This file has been generated automatically from * + * * + * src/server/qgsserverapicontext.h * + * * + * Do not edit manually ! Edit header and run scripts/sipify.pl again * + ************************************************************************/ + + + +class QgsServerApiContext +{ +%Docstring +The QgsServerApiContext class encapsulates the resources for a particular client +request: the request and response objects, the project (might be NULL) and +the server interface, the API root path that matched the request is also added. + +QgsServerApiContext is lightweight copyable object meant to be passed along the +request handlers chain. + +.. versionadded:: 3.10 +%End + +%TypeHeaderCode +#include "qgsserverapicontext.h" +%End + public: + + QgsServerApiContext( const QString &apiRootPath, const QgsServerRequest *request, QgsServerResponse *response, + const QgsProject *project, QgsServerInterface *serverInterface ); +%Docstring +QgsServerApiContext constructor + +:param apiRootPath: is the API root path, this information is used by the + handlers to build the href links to the resources and to the HTML templates. +:param request: the incoming request +:param response: the response +:param project: the project (might be NULL) +:param serverInterface: the server interface +%End + + const QgsServerRequest *request() const; +%Docstring +Returns the server request object +%End + + QgsServerResponse *response() const; +%Docstring +Returns the server response object +%End + + const QgsProject *project() const; +%Docstring +Returns the (possibly NULL) project + +.. seealso:: :py:func:`setProject` +%End + + void setProject( const QgsProject *project ); +%Docstring +Sets the project to ``project`` + +.. seealso:: :py:func:`project` +%End + + QgsServerInterface *serverInterface() const; +%Docstring +Returns the server interface +%End + + const QString matchedPath( ) const; +%Docstring +Returns the initial part of the incoming request URL path that matches the +API root path. +If there is no match returns an empty string (it should never happen). + +I.e. for an API with root path "/wfs3" and an incoming request +"https://www.qgis.org/services/wfs3/collections" +this method will return "/resources/wfs3" +%End + + QString apiRootPath() const; +%Docstring +Returns the API root path +%End + + void setRequest( const QgsServerRequest *request ); +%Docstring +Sets context request to ``request`` +%End + +}; + +/************************************************************************ + * This file has been generated automatically from * + * * + * src/server/qgsserverapicontext.h * + * * + * Do not edit manually ! Edit header and run scripts/sipify.pl again * + ************************************************************************/ diff --git a/python/server/auto_generated/qgsserverapiutils.sip.in b/python/server/auto_generated/qgsserverapiutils.sip.in new file mode 100644 index 00000000000..f1786f97403 --- /dev/null +++ b/python/server/auto_generated/qgsserverapiutils.sip.in @@ -0,0 +1,94 @@ +/************************************************************************ + * This file has been generated automatically from * + * * + * src/server/qgsserverapiutils.h * + * * + * Do not edit manually ! Edit header and run scripts/sipify.pl again * + ************************************************************************/ + + + + + + + +class QgsServerApiUtils +{ +%Docstring +The QgsServerApiUtils class contains helper functions to handle common API operations. + +.. versionadded:: 3.10 +%End + +%TypeHeaderCode +#include "qgsserverapiutils.h" +%End + public: + + static QgsRectangle parseBbox( const QString &bbox ); +%Docstring +Parses a comma separated ``bbox`` into a (possibily empty) :py:class:`QgsRectangle`. + +.. note:: + + Z values (i.e. a 6 elements bbox) are silently discarded +%End + + + static QgsCoordinateReferenceSystem parseCrs( const QString &bboxCrs ); +%Docstring +Parses the CRS URI ``bboxCrs`` (example: "http://www.opengis.net/def/crs/OGC/1.3/CRS84") into a QGIS CRS object +%End + + static const QgsFields publishedFields( const QgsVectorLayer *layer ); +%Docstring +Returns the list of fields accessible to the service for a given ``layer``. + +This method takes into account the ACL restrictions provided by QGIS Server Access Control plugins. +TODO: implement ACL +%End + + static const QVector publishedWfsLayers( const QgsProject *project ); +%Docstring +Returns the list of layers accessible to the service for a given ``project``. + +This method takes into account the ACL restrictions provided by QGIS Server Access Control plugins. + +.. note:: + + project must not be NULL + TODO: implement ACL +%End + + + static QString sanitizedFieldValue( const QString &value ); +%Docstring +Sanitizes the input ``value`` by removing URL encoding and checking for malicious content. +In case of failure returns an empty string. +%End + + static QStringList publishedCrsList( const QgsProject *project ); +%Docstring +Returns the list of CRSs (format: http://www.opengis.net/def/crs/OGC/1.3/CRS84) available for this ``project``. +Information is read from project WMS configuration. +%End + + static QString crsToOgcUri( const QgsCoordinateReferenceSystem &crs ); +%Docstring +Returns a ``crs`` as OGC URI (format: http://www.opengis.net/def/crs/OGC/1.3/CRS84) +Returns an empty string on failure. +%End + + static QString appendMapParameter( const QString &path, const QUrl &requestUrl ); +%Docstring +Appends MAP query string parameter from current ``requestUrl`` to the given ``path`` +%End + +}; +/************************************************************************ + * This file has been generated automatically from * + * * + * src/server/qgsserverapiutils.h * + * * + * Do not edit manually ! Edit header and run scripts/sipify.pl again * + ************************************************************************/ diff --git a/python/server/auto_generated/qgsserverexception.sip.in b/python/server/auto_generated/qgsserverexception.sip.in index 77623462f56..14f97ad98b7 100644 --- a/python/server/auto_generated/qgsserverexception.sip.in +++ b/python/server/auto_generated/qgsserverexception.sip.in @@ -11,6 +11,8 @@ + + class QgsServerException { %Docstring @@ -94,6 +96,7 @@ Returns the exception version }; + /************************************************************************ * This file has been generated automatically from * * * diff --git a/python/server/auto_generated/qgsserverogcapi.sip.in b/python/server/auto_generated/qgsserverogcapi.sip.in new file mode 100644 index 00000000000..1894fe26ae1 --- /dev/null +++ b/python/server/auto_generated/qgsserverogcapi.sip.in @@ -0,0 +1,140 @@ +/************************************************************************ + * This file has been generated automatically from * + * * + * src/server/qgsserverogcapi.h * + * * + * Do not edit manually ! Edit header and run scripts/sipify.pl again * + ************************************************************************/ + + + + +class QgsServerOgcApi : QgsServerApi +{ +%Docstring +QGIS Server OGC API endpoint. QgsServerOgcApi provides the foundation for +the new generation of REST-API based OGC services (e.g. WFS3). + +This class can be used directly and configured by registering handlers +as instances of QgsServerOgcApiHandler. + +.. code-block:: python + +.. versionadded:: 3.10 +%End + +%TypeHeaderCode +#include "qgsserverogcapi.h" +%End + public: + static const QMetaObject staticMetaObject; + + public: + + enum Rel + { + // The following registered link relation types are used + alternate, + describedBy, + collection, + item, + self, + service_desc, + service_doc, + prev, + next, + license, + // In addition the following link relation types are used for which no applicable registered link relation type could be identified: + items, + conformance, + data + }; + + enum ContentType + { + GEOJSON, + OPENAPI3, + JSON, + HTML + }; + + QgsServerOgcApi( QgsServerInterface *serverIface, + const QString &rootPath, + const QString &name, + const QString &description = QString(), + const QString &version = QString() ); +%Docstring +QgsServerOgcApi constructor + +:param serverIface: pointer to the server interface +:param rootPath: root path for this API (usually starts with a "/", e.g. "/wfs3") +:param name: API name +:param description: API description +:param version: API version +%End + + virtual const QString name() const; + virtual const QString description() const; + virtual const QString version() const; + virtual const QString rootPath() const; + + ~QgsServerOgcApi(); + + virtual void executeRequest( const QgsServerApiContext &context ) const throw( QgsServerApiBadRequestException ) /VirtualErrorHandler=serverapi_badrequest_exception_handler/; +%Docstring +Executes a request by passing the given ``context`` to the API handlers. +%End + + + + + void registerHandler( QgsServerOgcApiHandler *handler /Transfer/ ); +%Docstring +Registers an OGC API ``handler``, ownership of the handler is transferred to the API +%End + + static QUrl sanitizeUrl( const QUrl &url ); +%Docstring +Returns a sanitized ``url`` with extra slashes removed +%End + + static std::string relToString( const QgsServerOgcApi::Rel &rel ); +%Docstring +Returns the string representation of ``rel`` attribute. +%End + + static QString contentTypeToString( const QgsServerOgcApi::ContentType &ct ); +%Docstring +Returns the string representation of a ``ct`` (Content-Type) attribute. +%End + + static std::string contentTypeToStdString( const QgsServerOgcApi::ContentType &ct ); +%Docstring +Returns the string representation of a ``ct`` (Content-Type) attribute. +%End + + static QString contentTypeToExtension( const QgsServerOgcApi::ContentType &ct ); +%Docstring +Returns the file extension for a ``ct`` (Content-Type). +%End + + static QgsServerOgcApi::ContentType contenTypeFromExtension( const std::string &extension ); +%Docstring +Returns the Content-Type value corresponding to ``extension``. +%End + + static std::string mimeType( const QgsServerOgcApi::ContentType &contentType ); +%Docstring +Returns the mime-type for the ``contentType`` or an empty string if not found +%End + + +}; + +/************************************************************************ + * This file has been generated automatically from * + * * + * src/server/qgsserverogcapi.h * + * * + * Do not edit manually ! Edit header and run scripts/sipify.pl again * + ************************************************************************/ diff --git a/python/server/auto_generated/qgsserverogcapihandler.sip.in b/python/server/auto_generated/qgsserverogcapihandler.sip.in new file mode 100644 index 00000000000..77d8c433d70 --- /dev/null +++ b/python/server/auto_generated/qgsserverogcapihandler.sip.in @@ -0,0 +1,229 @@ +/************************************************************************ + * This file has been generated automatically from * + * * + * src/server/qgsserverogcapihandler.h * + * * + * Do not edit manually ! Edit header and run scripts/sipify.pl again * + ************************************************************************/ + + + + +class QgsServerOgcApiHandler +{ +%Docstring +The QgsServerOgcApiHandler abstract class represents a OGC API handler to be registered +in QgsServerOgcApi class. + +Subclasses must override operational and informative methods and define +the core functionality in handleRequest() method. + +The following methods MUST be implemented: +- path +- operationId +- summary (shorter text) +- description (longer text) +- linkTitle +- linkType +- schema + +Optionally, override: +- tags +- parameters +- contentTypes +- defaultContentType + +.. versionadded:: 3.10 +%End + +%TypeHeaderCode +#include "qgsserverogcapihandler.h" +%End + public: + + virtual ~QgsServerOgcApiHandler(); + + + virtual QRegularExpression path() const = 0; +%Docstring +URL pattern for this handler, named capture group are automatically +extracted and returned by values() + +Example: "/handlername/(?P\d{2})/items" will capture "code1" as a +named parameter. + +.. seealso:: :py:func:`values` +%End + + virtual std::string operationId() const = 0; +%Docstring +Returns the operation id for template file names and other internal references +%End + + virtual QList parameters( const QgsServerApiContext &context ) const; +%Docstring +Returns a list of query string parameters. + +Depending on the handler, it may be dynamic (per-request) or static. + +:param context: the request context +%End + + + virtual std::string summary() const = 0; +%Docstring +Summary +%End + + virtual std::string description() const = 0; +%Docstring +Description +%End + + virtual std::string linkTitle() const = 0; +%Docstring +Title for the handler link +%End + + virtual QgsServerOgcApi::Rel linkType() const = 0; +%Docstring +Main role for the resource link +%End + + virtual QStringList tags() const; +%Docstring +Tags +%End + + virtual QgsServerOgcApi::ContentType defaultContentType() const; +%Docstring +Returns the default response content type in case the client did not specifically +ask for any particular content type. +%End + + virtual QList contentTypes() const; +%Docstring +Returns the list of content types this handler can serve, default to JSON and HTML. +In case a specialized type (such as GEOJSON) is supported, +the generic type (such as JSON) should not be listed. +%End + + virtual void handleRequest( const QgsServerApiContext &context ) const = 0; +%Docstring +Handles the request within its ``context`` + +Subclasses must implement this methods, and call validate() to +extract validated parameters from the request. + +\throws QgsServerApiBadRequestError if the method encounters any error +%End + + virtual QVariantMap values( const QgsServerApiContext &context ) const throw( QgsServerApiBadRequestException ); +%Docstring +Analyzes the incoming request ``context`` and returns the validated +parameter map, throws QgsServerApiBadRequestError in case of errors. + +Path fragments from the named groups in the path() regular expression +are also added to the map. + +Your handleRequest method should call this function to retrieve +the parameters map. + +:return: the validated parameters map by extracting captured + named parameters from the path (no validation is performed on + the type because the regular expression can do it), + and the query string parameters. + +.. seealso:: :py:func:`path` + +.. seealso:: :py:func:`parameters` +\throws QgsServerApiBadRequestError if validation fails +%End + + QString contentTypeForAccept( const QString &accept ) const; +%Docstring +Looks for the first ContentType match in the accept header and returns its mime type, +returns an empty string if there are not matches. +%End + + + + void write( QVariant &data, const QgsServerApiContext &context, const QVariantMap &htmlMetadata = QVariantMap() ) const; +%Docstring +Writes ``data`` to the ``context`` response stream, content-type is calculated from the ``context`` request, +optional ``htmlMetadata`` for the HTML templates can be specified and will be added as "metadata" to +the HTML template variables. + +HTML output uses a template engine. + +Available template functions: +See: https://github.com/pantor/inja#tutorial + +Available custom template functions: +- path_append( path ): appends a directory path to the current url +- path_chomp( n ): removes the specified number "n" of directory components from the current url path +- json_dump(): prints current JSON data passed to the template +- static( path): returns the full URL to the specified static path, for example: +static("/style/black.css") will return something like "/wfs3/static/style/black.css". +- links_filter( links, key, value ): returns filtered links from a link list +- content_type_name( content_type ): returns a short name from a content type for example "text/html" will return "HTML" +%End + + std::string href( const QgsServerApiContext &context, const QString &extraPath = QString(), const QString &extension = QString() ) const; +%Docstring +Returns an URL to self, to be used for links to the current resources and as a base for constructing links to sub-resources + +:param context: the current request context +:param extraPath: an optional extra path that will be appended to the calculated URL +:param extension: optional file extension to add (the dot will be added automatically). +%End + + const QString templatePath( const QgsServerApiContext &context ) const; +%Docstring +Returns the HTML template path for the handler in the given ``context`` + +The template path is calculated from QgsServerSettings's apiResourcesDirectory() as follow: +apiResourcesDirectory() + "/ogc/templates/" + context.apiRootPath + operationId + ".html" +e.g. for an API with root path "/wfs3" and an handler with operationId "collectionItems", the path +will be apiResourcesDirectory() + "/ogc/templates/wfs3/collectionItems.html" +%End + + const QString staticPath( const QgsServerApiContext &context ) const; +%Docstring +Returns the absolute path to the base directory where static resources for +this handler are stored in the given ``context``. +%End + + QgsServerOgcApi::ContentType contentTypeFromRequest( const QgsServerRequest *request ) const; +%Docstring +Returns the content type from the ``request``. + +The path file extension is examined first and checked for known mime types, +the "Accept" HTTP header is examined next. +Fallback to the default content type of the handler if none of the above matches. + +\throws QgsServerApiBadRequestError if the content type of the request is not compatible with the handler (:py:func:`contentTypes` member) +%End + + static QString parentLink( const QUrl &url, int levels = 1 ); +%Docstring +Returns a link to the parent page up to ``levels`` in the HTML hierarchy from the given ``url``, MAP query argument is preserved +%End + + static QgsVectorLayer *layerFromCollection( const QgsServerApiContext &context, const QString &collectionId ); +%Docstring +Returns a vector layer from the ``collectionId`` in the given ``context`` +%End + + + + +}; + +/************************************************************************ + * This file has been generated automatically from * + * * + * src/server/qgsserverogcapihandler.h * + * * + * Do not edit manually ! Edit header and run scripts/sipify.pl again * + ************************************************************************/ diff --git a/python/server/auto_generated/qgsserverquerystringparameter.sip.in b/python/server/auto_generated/qgsserverquerystringparameter.sip.in new file mode 100644 index 00000000000..b3443c3212c --- /dev/null +++ b/python/server/auto_generated/qgsserverquerystringparameter.sip.in @@ -0,0 +1,109 @@ +/************************************************************************ + * This file has been generated automatically from * + * * + * src/server/qgsserverquerystringparameter.h * + * * + * Do not edit manually ! Edit header and run scripts/sipify.pl again * + ************************************************************************/ + + + + + + + + +class QgsServerQueryStringParameter +{ +%Docstring +The QgsServerQueryStringParameter class holds the information regarding +a query string input parameter and its validation. + +The class is extendable through custom validators (C++ only) and/or by +subclassing and overriding the value() method. + +.. versionadded:: 3.10 +%End + +%TypeHeaderCode +#include "qgsserverquerystringparameter.h" +%End + public: + static const QMetaObject staticMetaObject; + + public: + + enum class Type + { + String, + Integer, + Double, + Boolean, + List, + }; + + + QgsServerQueryStringParameter( const QString name, + bool required = false, + Type type = QgsServerQueryStringParameter::Type::String, + const QString &description = QString(), + const QVariant &defaultValue = QVariant() ); +%Docstring +Constructs a QgsServerQueryStringParameter object. + +:param name: parameter name +:param required: +:param type: the parameter type +:param description: parameter description +:param defaultValue: default value, it is ignored if the parameter is required +%End + + virtual ~QgsServerQueryStringParameter(); + + virtual QVariant value( const QgsServerApiContext &context ) const; +%Docstring +Extracts the value from the request ``context`` by validating the parameter +value and converting it to its proper Type. +If the value is not set and a default was not provided an invalid QVariant is returned. + +Validation steps: +- required +- can convert to proper Type +- custom validator (if set - not available in Python bindings) + +.. seealso:: :py:func:`setCustomValidator` + +:return: the parameter value or an invalid QVariant if not found (and not required) + \throws QgsServerApiBadRequestError if validation fails +%End + + + QString description() const; +%Docstring +Returns parameter description +%End + + static QString typeName( const Type type ); +%Docstring +Returns the name of the ``type`` +%End + + QString name() const; +%Docstring +Returns the name of the parameter +%End + + void setDescription( const QString &description ); +%Docstring +Sets validator ``description`` +%End + +}; + +/************************************************************************ + * This file has been generated automatically from * + * * + * src/server/qgsserverquerystringparameter.h * + * * + * Do not edit manually ! Edit header and run scripts/sipify.pl again * + ************************************************************************/ diff --git a/python/server/auto_generated/qgsserverrequest.sip.in b/python/server/auto_generated/qgsserverrequest.sip.in index bd68d6c9fa7..4d5ab4b39ff 100644 --- a/python/server/auto_generated/qgsserverrequest.sip.in +++ b/python/server/auto_generated/qgsserverrequest.sip.in @@ -87,7 +87,7 @@ Returns parameters Set a parameter %End - QString parameter( const QString &key ) const; + QString parameter( const QString &key, const QString &defaultValue = QString() ) const; %Docstring Gets a parameter value %End @@ -153,6 +153,13 @@ by default this is equal to the url seen by QGIS server void setMethod( QgsServerRequest::Method method ); %Docstring Set the request method +%End + + const QString queryParameter( const QString &name, const QString &defaultValue = QString( ) ) const; +%Docstring +Returns the query string parameter with the given ``name`` from the request URL, a ``defaultValue`` can be specified. + +.. versionadded:: 3.10 %End protected: diff --git a/python/server/auto_generated/qgsserverresponse.sip.in b/python/server/auto_generated/qgsserverresponse.sip.in index 2148102ab45..69f158966ed 100644 --- a/python/server/auto_generated/qgsserverresponse.sip.in +++ b/python/server/auto_generated/qgsserverresponse.sip.in @@ -98,6 +98,7 @@ to the underlying I/O device + virtual void write( const QgsServerException &ex ); %Docstring Write server exception diff --git a/python/server/auto_generated/qgsserversettings.sip.in b/python/server/auto_generated/qgsserversettings.sip.in index 7f493768789..51ea84bcb05 100644 --- a/python/server/auto_generated/qgsserversettings.sip.in +++ b/python/server/auto_generated/qgsserversettings.sip.in @@ -153,6 +153,26 @@ Returns the server-wide max width of a WMS GetMap request. The lower one of this :return: the max width of a WMS GetMap request. .. versionadded:: 3.8 +%End + + QString apiResourcesDirectory() const; +%Docstring +Returns the server-wide base directory where HTML templates and static assets (e.g. images, js and css files) are searched for. + +The default path is calculated by joining QgsApplication.pkgDataPath() with "resources/server/api", this path +can be changed by setting the environment variable QGIS_SERVER_API_RESOURCES_DIRECTORY. + +.. versionadded:: 3.10 +%End + + qlonglong apiWfs3MaxLimit() const; +%Docstring +Returns the server-wide maximum allowed value for \"limit\" in a features request. + +The default value is 10000, this value can be changed by setting the environment +variable QGIS_SERVER_API_WFS3_MAX_LIMIT. + +.. versionadded:: 3.10 %End }; diff --git a/python/server/auto_generated/qgsserviceregistry.sip.in b/python/server/auto_generated/qgsserviceregistry.sip.in index 3df1211ecec..55bfbd6e6a0 100644 --- a/python/server/auto_generated/qgsserviceregistry.sip.in +++ b/python/server/auto_generated/qgsserviceregistry.sip.in @@ -57,16 +57,55 @@ Register a service by its name and version This method is intended to be called by modules for registering services. A module may register multiple services. -The registry gain ownership of services and will call 'delete' on cleanup +The registry takes ownership of services and will call 'delete' on cleanup :param service: a QgsService to be registered +%End + + bool registerApi( QgsServerApi *api /Transfer/ ); +%Docstring +Registers the :py:class:`QgsServerApi` ``api`` + +The registry takes ownership of services and will call 'delete' on cleanup + +.. versionadded:: 3.10 +%End + + int unregisterApi( const QString &name, const QString &version = QString() ); +%Docstring +Unregisters API from its name and version + +:param name: the name of the service +:param version: (optional) the specific version to unload + +:return: the number of APIs unregistered + +If the version is not specified then all versions from the specified API +are unloaded + +.. versionadded:: 3.10 +%End + + + QgsServerApi *getApi( const QString &name, const QString &version = QString() ); +%Docstring +Retrieves an API from its name + +If the version is not provided the higher version of the service is returned + +:param name: the name of the API +:param version: the version string (optional) + +:return: :py:class:`QgsServerApi` + +.. versionadded:: 3.10 %End int unregisterService( const QString &name, const QString &version = QString() ); %Docstring Unregister service from its name and version -:param name: the tame of the service +:param name: the name of the service :param version: (optional) the specific version to unload :return: the number of services unregistered diff --git a/python/server/qgsserverexception.sip b/python/server/qgsserverexception.sip new file mode 100644 index 00000000000..a1f7831a801 --- /dev/null +++ b/python/server/qgsserverexception.sip @@ -0,0 +1,23 @@ +%Exception QgsServerApiBadRequestException(SIP_Exception) /PyName=QgsServerApiBadRequestException/ +{ +%TypeHeaderCode +#include +%End +%RaiseCode + SIP_BLOCK_THREADS + PyErr_SetString(sipException_QgsServerApiBadRequestException, sipExceptionRef.what().toUtf8().constData() ); + SIP_UNBLOCK_THREADS +%End +}; + +%Exception QgsServerApiInternalServerError(SIP_Exception) /PyName=QgsServerApiInternalServerError/ +{ +%TypeHeaderCode +#include +%End +%RaiseCode + SIP_BLOCK_THREADS + PyErr_SetString(sipException_QgsServerApiInternalServerError, sipExceptionRef.what().toUtf8().constData() ); + SIP_UNBLOCK_THREADS +%End +}; diff --git a/python/server/server.sip.in b/python/server/server.sip.in index 5267c0dce38..e4d2c7710a4 100644 --- a/python/server/server.sip.in +++ b/python/server/server.sip.in @@ -8,7 +8,22 @@ ${DEFAULTDOCSTRINGSIGNATURE} %Import QtXml/QtXmlmod.sip %Import core/core.sip +%Include qgsserverexception.sip %Feature HAVE_SERVER_PYTHON_PLUGINS %Include server_auto.sip + + +%VirtualErrorHandler serverapi_badrequest_exception_handler + PyObject *exception, *value, *traceback; + PyErr_Fetch(&exception, &value, &traceback); + SIP_RELEASE_GIL( sipGILState ); + QString strVal = "API bad request error"; + if ( value && PyUnicode_Check(value) ) + { + Py_ssize_t size; + strVal = QString::fromUtf8( PyUnicode_AsUTF8AndSize(value, &size) ); + } + throw QgsServerApiBadRequestException( strVal ); +%End diff --git a/python/server/server_auto.sip b/python/server/server_auto.sip index 899c55b5beb..cdf276de62d 100644 --- a/python/server/server_auto.sip +++ b/python/server/server_auto.sip @@ -1,6 +1,10 @@ // Include auto-generated SIP files %Include auto_generated/qgsservicemodule.sip -%Include auto_generated/qgsmapserviceexception.sip +%Include auto_generated/qgsserverapi.sip +%Include auto_generated/qgsserverogcapi.sip +%Include auto_generated/qgsserverogcapihandler.sip +%Include auto_generated/qgsserverapicontext.sip +%Include auto_generated/qgsserverquerystringparameter.sip %Include auto_generated/qgscapabilitiescache.sip %Include auto_generated/qgsconfigcache.sip %Include auto_generated/qgsserverlogger.sip @@ -11,6 +15,7 @@ %Include auto_generated/qgsfcgiserverrequest.sip %Include auto_generated/qgsrequesthandler.sip %Include auto_generated/qgsserver.sip +%Include auto_generated/qgsserverapiutils.sip %Include auto_generated/qgsserverexception.sip %If ( HAVE_SERVER_PYTHON_PLUGINS ) %Include auto_generated/qgsserverinterface.sip diff --git a/resources/server/api/ogc/schema.json b/resources/server/api/ogc/schema.json new file mode 100644 index 00000000000..a5e3cca80b9 --- /dev/null +++ b/resources/server/api/ogc/schema.json @@ -0,0 +1,385 @@ +{ "components" : { + "schemas" : { + "exception" : { + "required" : [ "code" ], + "type" : "object", + "properties" : { + "code" : { + "type" : "string" + }, + "description" : { + "type" : "string" + } + } + }, + "root" : { + "required" : [ "links" ], + "type" : "object", + "properties" : { + "links" : { + "type" : "array", + "example" : [ { + "href" : "http://data.example.org/", + "rel" : "self", + "type" : "application/json", + "title" : "this document" + }, { + "href" : "http://data.example.org/api", + "rel" : "service", + "type" : "application/openapi+json;version=3.0", + "title" : "the API definition" + }, { + "href" : "http://data.example.org/conformance", + "rel" : "conformance", + "type" : "application/json", + "title" : "WFS 3.0 conformance classes implemented by this server" + }, { + "href" : "http://data.example.org/collections", + "rel" : "data", + "type" : "application/json", + "title" : "Metadata about the feature collections" + } ], + "items" : { + "$ref" : "#/components/schemas/link" + } + } + } + }, + "req-classes" : { + "required" : [ "conformsTo" ], + "type" : "object", + "properties" : { + "conformsTo" : { + "type" : "array", + "example" : [ "http://www.opengis.net/spec/wfs-1/3.0/req/core", "http://www.opengis.net/spec/wfs-1/3.0/req/oas30", "http://www.opengis.net/spec/wfs-1/3.0/req/html", "http://www.opengis.net/spec/wfs-1/3.0/req/geojson" ], + "items" : { + "type" : "string" + } + } + } + }, + "link" : { + "required" : [ "href" ], + "type" : "object", + "properties" : { + "href" : { + "type" : "string", + "example" : "http://data.example.com/buildings/123" + }, + "rel" : { + "type" : "string", + "example" : "prev" + }, + "type" : { + "type" : "string", + "example" : "application/geo+json" + }, + "hreflang" : { + "type" : "string", + "example" : "en" + } + } + }, + "content" : { + "required" : [ "collections", "links" ], + "type" : "object", + "properties" : { + "links" : { + "type" : "array", + "example" : [ { + "href" : "http://data.example.org/collections.json", + "rel" : "self", + "type" : "application/json", + "title" : "this document" + }, { + "href" : "http://data.example.org/collections.html", + "rel" : "alternate", + "type" : "text/html", + "title" : "this document as HTML" + }, { + "href" : "http://schemas.example.org/1.0/foobar.xsd", + "rel" : "describedBy", + "type" : "application/xml", + "title" : "XML schema for Acme Corporation data" + } ], + "items" : { + "$ref" : "#/components/schemas/link" + } + }, + "collections" : { + "type" : "array", + "items" : { + "$ref" : "#/components/schemas/collectionInfo" + } + } + } + }, + "collectionInfo" : { + "required" : [ "links", "name" ], + "type" : "object", + "properties" : { + "name" : { + "type" : "string", + "description" : "identifier of the collection used, for example, in URIs", + "example" : "buildings" + }, + "title" : { + "type" : "string", + "description" : "human readable title of the collection", + "example" : "Buildings" + }, + "description" : { + "type" : "string", + "description" : "a description of the features in the collection", + "example" : "Buildings in the city of Bonn." + }, + "links" : { + "type" : "array", + "example" : [ { + "href" : "http://data.example.org/collections/buildings/items", + "rel" : "item", + "type" : "application/geo+json", + "title" : "Buildings" + }, { + "href" : "http://example.com/concepts/buildings.html", + "rel" : "describedBy", + "type" : "text/html", + "title" : "Feature catalogue for buildings" + } ], + "items" : { + "$ref" : "#/components/schemas/link" + } + }, + "extent" : { + "$ref" : "#/components/schemas/extent" + }, + "crs" : { + "type" : "array", + "description" : "The coordinate reference systems in which geometries may be retrieved. Coordinate reference systems are identified by a URI. The first coordinate reference system is the coordinate reference system that is used by default. This is always \"http://www.opengis.net/def/crs/OGC/1.3/CRS84\", i.e. WGS84 longitude/latitude.", + "example" : [ "http://www.opengis.net/def/crs/OGC/1.3/CRS84", "http://www.opengis.net/def/crs/EPSG/0/4326" ], + "items" : { + "type" : "string" + }, + "default" : [ "http://www.opengis.net/def/crs/OGC/1.3/CRS84" ] + }, + "relations" : { + "type" : "object", + "description" : "Related collections that may be retrieved for this collection", + "example" : "{\"id\": \"label\"}" + } + } + }, + "extent" : { + "required" : [ "spatial" ], + "type" : "object", + "properties" : { + "crs" : { + "type" : "string", + "description" : "Coordinate reference system of the coordinates in the spatial extent (property `spatial`). In the Core, only WGS84 longitude/latitude is supported. Extensions may support additional coordinate reference systems.", + "enum" : [ "http://www.opengis.net/def/crs/OGC/1.3/CRS84" ], + "default" : "http://www.opengis.net/def/crs/OGC/1.3/CRS84" + }, + "spatial" : { + "maxItems" : 6, + "minItems" : 4, + "type" : "array", + "description" : "West, north, east, south edges of the spatial extent. The minimum and maximum values apply to the coordinate reference system WGS84 longitude/latitude that is supported in the Core. If, for example, a projected coordinate reference system is used, the minimum and maximum values need to be adjusted.", + "example" : [ -180, -90, 180, 90 ], + "items" : { + "type" : "number" + } + } + } + }, + "featureCollectionGeoJSON" : { + "required" : [ "features", "type" ], + "type" : "object", + "properties" : { + "type" : { + "type" : "string", + "enum" : [ "FeatureCollection" ] + }, + "features" : { + "type" : "array", + "items" : { + "$ref" : "#/components/schemas/featureGeoJSON" + } + }, + "links" : { + "type" : "array", + "items" : { + "$ref" : "#/components/schemas/link" + } + }, + "timeStamp" : { + "type" : "string", + "format" : "dateTime" + }, + "numberMatched" : { + "minimum" : 0, + "type" : "integer" + }, + "numberReturned" : { + "minimum" : 0, + "type" : "integer" + } + } + }, + "featureGeoJSON" : { + "required" : [ "geometry", "properties", "type" ], + "type" : "object", + "properties" : { + "type" : { + "type" : "string", + "enum" : [ "Feature" ] + }, + "geometry" : { + "$ref" : "#/components/schemas/geometryGeoJSON" + }, + "properties" : { + "type" : "object", + "nullable" : true + }, + "id" : { + "oneOf" : [ { + "type" : "string" + }, { + "type" : "integer" + } ] + } + } + }, + "geometryGeoJSON" : { + "required" : [ "type" ], + "type" : "object", + "properties" : { + "type" : { + "type" : "string", + "enum" : [ "Point", "MultiPoint", "LineString", "MultiLineString", "Polygon", "MultiPolygon", "GeometryCollection" ] + } + } + } + }, + "parameters" : { + "limit" : { + "name" : "limit", + "in" : "query", + "description" : "The optional limit parameter limits the number of items that are presented in the response document.\\\nOnly items are counted that are on the first level of the collection in the response document. Nested objects contained within the explicitly requested items shall not be counted.\\\nMinimum = 1.\\\nMaximum = 10000.\\\nDefault = 10.", + "required" : false, + "style" : "form", + "explode" : false, + "schema" : { + "maximum" : 10000, + "minimum" : 1, + "type" : "integer", + "default" : 10 + }, + "example" : 10 + }, + "offset" : { + "name" : "offset", + "in" : "query", + "description" : "The optional offset parameter indicates the index within the result set from which the server shall begin presenting results in the response document. The first element has an index of 0.\\\nMinimum = 0.\\\nDefault = 0.", + "required" : false, + "style" : "form", + "explode" : false, + "schema" : { + "minimum" : 0, + "type" : "integer", + "default" : 0 + }, + "example" : 0 + }, + "bbox" : { + "name" : "bbox", + "in" : "query", + "description" : "Only features that have a geometry that intersects the bounding box are selected. The bounding box is provided as four or six numbers, depending on whether the coordinate reference system includes a vertical axis (elevation or depth):\n \n* Lower left corner, coordinate axis 1\n* Lower left corner, coordinate axis 2\n* Lower left corner, coordinate axis 3 (optional)\n* Upper right corner, coordinate axis 1\n* Upper right corner, coordinate axis 2\n* Upper right corner, coordinate axis 3 (optional)\n\nThe coordinate reference system of the values is WGS84 longitude/latitude (http://www.opengis.net/def/crs/OGC/1.3/CRS84) unless a different coordinate reference system is specified in the parameter `bbox-crs`.\n\nFor WGS84 longitude/latitude the values are in most cases the sequence of minimum longitude, minimum latitude, maximum longitude and maximum latitude. However, in cases where the box spans the antimeridian the first value (west-most box edge) is larger than the third value (east-most box edge).\n\nIf a feature has multiple spatial geometry properties, it is the decision of the server whether only a single spatial geometry property is used to determine the extent or all relevant geometries.", + "required" : false, + "style" : "form", + "explode" : false, + "schema" : { + "maxItems" : 6, + "minItems" : 4, + "type" : "array", + "items" : { + "type" : "number" + } + } + }, + "time" : { + "name" : "time", + "in" : "query", + "description" : "Either a date-time or a period string that adheres to RFC 3339. Examples:\n\n* A date-time: \"2018-02-12T23:20:50Z\"\n* A period: \"2018-02-12T00:00:00Z/2018-03-18T12:31:12Z\" or \"2018-02-12T00:00:00Z/P1M6DT12H31M12S\"\n\nOnly features that have a temporal property that intersects the value of\n`time` are selected.\n\nIf a feature has multiple temporal properties, it is the decision of the\nserver whether only a single temporal property is used to determine\nthe extent or all relevant temporal properties.", + "required" : false, + "style" : "form", + "explode" : false, + "schema" : { + "type" : "string" + } + }, + "resultType" : { + "name" : "resultType", + "in" : "query", + "description" : "This service will respond to a query in one of two ways (excluding an exception response). It may either generate a complete response document containing resources that satisfy the operation or it may simply generate an empty response container that indicates the count of the total number of resources that the operation would return. Which of these two responses is generated is determined by the value of the optional resultType parameter.\\\nThe allowed values for this parameter are \"results\" and \"hits\".\\\nIf the value of the resultType parameter is set to \"results\", the server will generate a complete response document containing resources that satisfy the operation.\\\nIf the value of the resultType attribute is set to \"hits\", the server will generate an empty response document containing no resource instances.\\\nDefault = \"results\".", + "required" : false, + "style" : "form", + "explode" : false, + "schema" : { + "type" : "string", + "enum" : [ "hits", "results" ], + "default" : "results" + }, + "example" : "results" + }, + "featureId" : { + "name" : "featureId", + "in" : "path", + "description" : "Local identifier of a specific feature", + "required" : true, + "schema" : { + "type" : "string" + } + }, + "relations" : { + "name" : "relations", + "in" : "query", + "description" : "Comma-separated list of related collections that should be shown for this feature", + "required" : false, + "style" : "form", + "explode" : false, + "schema" : { + "type" : "array", + "items" : { + "type" : "string" + } + } + }, + "crs" : { + "name" : "crs", + "in" : "query", + "description" : "The coordinate reference system of the response geometries. Default is WGS84 longitude/latitude (http://www.opengis.net/def/crs/OGC/1.3/CRS84).", + "required" : false, + "style" : "form", + "explode" : false, + "schema" : { + "type" : "string", + "enum" : [ "http://www.opengis.net/def/crs/EPSG/0/25832", "http://www.opengis.net/def/crs/OGC/1.3/CRS84", "http://www.opengis.net/def/crs/EPSG/0/3034", "http://www.opengis.net/def/crs/EPSG/0/3035", "http://www.opengis.net/def/crs/EPSG/0/3043", "http://www.opengis.net/def/crs/EPSG/0/3044", "http://www.opengis.net/def/crs/EPSG/0/3045", "http://www.opengis.net/def/crs/EPSG/0/3857", "http://www.opengis.net/def/crs/EPSG/0/4258", "http://www.opengis.net/def/crs/EPSG/0/4326", "http://www.opengis.net/def/crs/EPSG/0/4647", "http://www.opengis.net/def/crs/EPSG/0/5649", "http://www.opengis.net/def/crs/EPSG/0/5650", "http://www.opengis.net/def/crs/EPSG/0/5651", "http://www.opengis.net/def/crs/EPSG/0/5652", "http://www.opengis.net/def/crs/EPSG/0/5653", "http://www.opengis.net/def/crs/EPSG/0/28992", "http://www.opengis.net/def/crs/EPSG/0/25831", "http://www.opengis.net/def/crs/EPSG/0/25833" ], + "default" : "http://www.opengis.net/def/crs/OGC/1.3/CRS84" + } + }, + "bbox-crs" : { + "name" : "bbox-crs", + "in" : "query", + "description" : "The coordinate reference system of the bbox parameter. Default is WGS84 longitude/latitude (http://www.opengis.net/def/crs/OGC/1.3/CRS84).", + "required" : false, + "style" : "form", + "explode" : false, + "schema" : { + "type" : "string", + "enum" : [ "http://www.opengis.net/def/crs/EPSG/0/25832", "http://www.opengis.net/def/crs/OGC/1.3/CRS84", "http://www.opengis.net/def/crs/EPSG/0/3034", "http://www.opengis.net/def/crs/EPSG/0/3035", "http://www.opengis.net/def/crs/EPSG/0/3043", "http://www.opengis.net/def/crs/EPSG/0/3044", "http://www.opengis.net/def/crs/EPSG/0/3045", "http://www.opengis.net/def/crs/EPSG/0/3857", "http://www.opengis.net/def/crs/EPSG/0/4258", "http://www.opengis.net/def/crs/EPSG/0/4326", "http://www.opengis.net/def/crs/EPSG/0/4647", "http://www.opengis.net/def/crs/EPSG/0/5649", "http://www.opengis.net/def/crs/EPSG/0/5650", "http://www.opengis.net/def/crs/EPSG/0/5651", "http://www.opengis.net/def/crs/EPSG/0/5652", "http://www.opengis.net/def/crs/EPSG/0/5653", "http://www.opengis.net/def/crs/EPSG/0/28992", "http://www.opengis.net/def/crs/EPSG/0/25831", "http://www.opengis.net/def/crs/EPSG/0/25833" ], + "default" : "http://www.opengis.net/def/crs/OGC/1.3/CRS84" + } + } + } + } +} diff --git a/resources/server/api/ogc/static/jsonFormatter.min.css b/resources/server/api/ogc/static/jsonFormatter.min.css new file mode 100644 index 00000000000..37761dbbe74 --- /dev/null +++ b/resources/server/api/ogc/static/jsonFormatter.min.css @@ -0,0 +1 @@ +PRE.jsonFormatter-codeContainer{margin-top:0;margin-bottom:0}PRE.jsonFormatter-codeContainer .jsonFormatter-objectBrace{color:#0a0;font-weight:bold}PRE.jsonFormatter-codeContainer .jsonFormatter-arrayBrace{color:#03f;font-weight:bold}PRE.jsonFormatter-codeContainer .jsonFormatter-propertyName{color:#c00;font-weight:bold}PRE.jsonFormatter-codeContainer .jsonFormatter-string{color:#077}PRE.jsonFormatter-codeContainer .jsonFormatter-number{color:#a0a}PRE.jsonFormatter-codeContainer .jsonFormatter-boolean{color:#00f}PRE.jsonFormatter-codeContainer .jsonFormatter-function{color:#a63;font-style:italic}PRE.jsonFormatter-codeContainer .jsonFormatter-null{color:#00f}PRE.jsonFormatter-codeContainer .jsonFormatter-coma{color:#000;font-weight:bold}PRE.jsonFormatter-codeContainer .jsonFormatter-expander{display:inline-block;width:28px;height:11px;cursor:pointer}PRE.jsonFormatter-codeContainer .jsonFormatter-expanded{background:url('data:image/gif;base64,R0lGODlhHAALAMQAAP////7++/z8/Pb29fb18PHx7e/w6/Hw6e3s5unp4+Dg3t3a0djY0dnVy9fTxNbQxtLMv8zJurDC1L+9sMK4p32buAAAAP///wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACH5BAEHABcALAAAAAAcAAsAAAVL4CWOZGmel1StbCWhsFgBdA1UMVwJQd8TuNypMigWD4qgsFQhWJ7PhXI5qhQKCERC0ZhSLxUFo+FwQCJeagUyobjd6aWqtXp979QQADs=') /*Expanded.gif*/ no-repeat}PRE.jsonFormatter-codeContainer .jsonFormatter-collapsed{background:url('data:image/gif;base64,R0lGODlhHAALAMQAAP////7++/z8/Pb29fb18PHx7e/w6/Hw6e3s5unp4+jm2ODg3t3a0dnVy9bQxtLMv8zJurDC1L+9sMK4p32buDMzMwAAAP///wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACH5BAEHABcALAAAAAAcAAsAAAVU4CWOZGmeV0StLBWhsEgBdA1QMUwJvMUTuNyJMihaBodFUFiiECxQKGMpqlSq14uVRCkUEJbEokHVZrdmrqLRsDgekDLzQoFIJni8nKlqrV5zgYIhADs=') /*Collapsed.gif*/ no-repeat} \ No newline at end of file diff --git a/resources/server/api/ogc/static/jsonFormatter.min.js b/resources/server/api/ogc/static/jsonFormatter.min.js new file mode 100644 index 00000000000..f2ffcecd7ff --- /dev/null +++ b/resources/server/api/ogc/static/jsonFormatter.min.js @@ -0,0 +1,2 @@ +(function($){$.fn.jsonFormatter=function(n){var _settings,u=new Date,r=new RegExp,i=function(n,t,i){for(var r="",u=0;u0&&t.charAt(t.length-1)!="\n"&&(t=t+"\n"),r+t},f=function(n,t){for(var r,u,f="",i=0;i").join(">"));var o=""+t+n+t+r+"<\/span>";return f&&(o=i(u,o)),o},_processObject=function(n,e,o,s,h){var c="",l=o?",<\/span> ":"",v=typeof n,a="",y,p,k,w,b;if($.isArray(n))if(n.length==0)c+=i(e,"[ ]<\/span>"+l,h);else{for(a=_settings.collapsible?"<\/span>":"",c+=i(e,"[<\/span>"+a,h),y=0;y":"";c+=i(e,a+"]<\/span>"+l)}else if(v=="object")if(n==null)c+=t("null","",l,e,s,"jsonFormatter-null");else if(n.constructor==u.constructor)c+=t("new Date("+n.getTime()+") /*"+n.toLocaleString()+"*/","",l,e,s,"Date");else if(n.constructor==r.constructor)c+=t("new RegExp("+n+")","",l,e,s,"RegExp");else{p=0;for(w in n)p++;if(p==0)c+=i(e,"{ }<\/span>"+l,h);else{a=_settings.collapsible?"<\/span>":"";c+=i(e,"{<\/span>"+a,h);k=0;for(w in n)b=_settings.quoteKeys?'"':"",c+=i(e+1,""+b+w+b+"<\/span>: "+_processObject(n[w],e+1,++k":"";c+=i(e,a+"}<\/span>"+l)}}else v=="number"?c+=t(n,"",l,e,s,"jsonFormatter-number"):v=="boolean"?c+=t(n,"",l,e,s,"jsonFormatter-boolean"):v=="function"?n.constructor==r.constructor?c+=t("new RegExp("+n+")","",l,e,s,"RegExp"):(n=f(e,n),c+=t(n,"",l,e,s,"jsonFormatter-function")):c+=v=="undefined"?t("undefined","",l,e,s,"jsonFormatter-null"):t(n.toString().split("\\").join("\\\\").split('"').join('\\"'),'"',l,e,s,"jsonFormatter-string");return c},e=function(element){var json=$(element).html(),obj,original;json.trim()==""&&(json='""');try{obj=eval("["+json+"]")}catch(exception){return}html=_processObject(obj[0],0,!1,!1,!1);original=$(element).wrapInner("
<\/div>");_settings.hideOriginal===!0&&$(".jsonFormatter-original",original).hide();original.append("
"+html+"<\/PRE>")},o=function(){var n=$(this).next();n.length<1||($(this).hasClass("jsonFormatter-expanded")==!0?(n.hide(),$(this).removeClass("jsonFormatter-expanded").addClass("jsonFormatter-collapsed")):(n.show(),$(this).removeClass("jsonFormatter-collapsed").addClass("jsonFormatter-expanded")))};return _settings=$.extend({tab:"  ",quoteKeys:!0,collapsible:!0,hideOriginal:!0},n),this.each(function(n,t){e(t);$(t).on("click",".jsonFormatter-expander",o)})}})(jQuery);
+//# sourceMappingURL=jsonFormatter.min.js.map
diff --git a/resources/server/api/ogc/static/style.css b/resources/server/api/ogc/static/style.css
new file mode 100644
index 00000000000..4f551095b3d
--- /dev/null
+++ b/resources/server/api/ogc/static/style.css
@@ -0,0 +1,11 @@
+a { color: green; }
+
+#mapid.small {
+    width: 100%;
+    height: 400px;
+}
+
+
+.card-header span.small {
+   font-size: 70%;
+}
diff --git a/resources/server/api/ogc/templates/wfs3/describeCollection.html b/resources/server/api/ogc/templates/wfs3/describeCollection.html
new file mode 100644
index 00000000000..2bfb000901a
--- /dev/null
+++ b/resources/server/api/ogc/templates/wfs3/describeCollection.html
@@ -0,0 +1,65 @@
+
+{% include "header.html" %}
+
+
+
+

{{ title }}

+

Available CRSs

+
    + {% for c in crs %} +
  • {{ c }}
  • + {% endfor %} +
+ +

Extent

+
+
West
+
{{ extent.spatial.0.0 }}
+
South
+
{{ extent.spatial.0.1 }}
+
East
+
{{ extent.spatial.0.2 }}
+
North
+
{{ extent.spatial.0.3 }}
+
+ +
+ +
+
+ + +
+ +
+ +{% include "footer.html" %} diff --git a/resources/server/api/ogc/templates/wfs3/describeCollections.html b/resources/server/api/ogc/templates/wfs3/describeCollections.html new file mode 100644 index 00000000000..b205a6ac206 --- /dev/null +++ b/resources/server/api/ogc/templates/wfs3/describeCollections.html @@ -0,0 +1,14 @@ + +{% include "header.html" %} + +

Collections

+ + {% for collection in collections %} +

{{ collection.title }}

+ + {% endfor %} + + +{% include "footer.html" %} diff --git a/resources/server/api/ogc/templates/wfs3/footer.html b/resources/server/api/ogc/templates/wfs3/footer.html new file mode 100644 index 00000000000..f029aa5f1e8 --- /dev/null +++ b/resources/server/api/ogc/templates/wfs3/footer.html @@ -0,0 +1,22 @@ + +
+ + + + + + + + + + + + diff --git a/resources/server/api/ogc/templates/wfs3/getApiDescription.html b/resources/server/api/ogc/templates/wfs3/getApiDescription.html new file mode 100644 index 00000000000..7a2f0fcbef5 --- /dev/null +++ b/resources/server/api/ogc/templates/wfs3/getApiDescription.html @@ -0,0 +1,170 @@ + +{% include "header.html" %} + +

API description

+ +

Info

+
+
Description
+
{{ info.description }}
+
Title
+
{{ info.title }}
+
Contact email
+
{{ info.contact.email }}
+
Contact name
+
{{ info.contact.name }}
+
+ +

Paths

+
+
+ {% for path, path_info in paths %} + {% for method, method_data in path_info %} +
+
+ {{ method }} + + {{ method_data.summary }} +
+
+
+
+
+
OperationId
+
{{ method_data.operationId }} +
Tags
+
{{ method_data.tags }} +
Description
+
{{ method_data.description }}
+ {% if existsIn(method_data, "parameters") %} +
Parameters
+
+ + + + + + + + + + {% for param in method_data.parameters %} + {% if existsIn(param, "name") %} + + + + + + {% else %} + + + {% endif %} + {% endfor %} + +
NameDescriptionType
{{ param.name }}{{ param.description }}{{ param.schema.type }}
+ {{ param }} +
+ + {% endif %} +
Responses
+
{{ method_data.responses}}
+
+ +
+
+ {% endfor %} + {% endfor %} +
+
+ +

Models

+ + + +{% include "footer.html" %} diff --git a/resources/server/api/ogc/templates/wfs3/getFeature.html b/resources/server/api/ogc/templates/wfs3/getFeature.html new file mode 100644 index 00000000000..3ae6aa24bf1 --- /dev/null +++ b/resources/server/api/ogc/templates/wfs3/getFeature.html @@ -0,0 +1,20 @@ + +{% include "header.html" %} + +
+
+

{{ metadata.pageTitle }}

+ +
+ {% for name, value in properties %} +
{{ name }}
+
{{ value }}
+ {% endfor %} +
+
+ + {% include "leaflet_map.html" %} + +
+ +{% include "footer.html" %} diff --git a/resources/server/api/ogc/templates/wfs3/getFeatures.html b/resources/server/api/ogc/templates/wfs3/getFeatures.html new file mode 100644 index 00000000000..d654f546269 --- /dev/null +++ b/resources/server/api/ogc/templates/wfs3/getFeatures.html @@ -0,0 +1,39 @@ + +{% include "header.html" %} + +
+ +
+ +
+
+

{{ metadata.pageTitle }}

+ + {% for feature in features %} +

{{ metadata.layerTitle }} {{ feature.id }}

+
+ {% for name, value in feature.properties %} +
{{ name }}
+
{{ value }}
+ {% endfor %} +
+ {% endfor %} +
+ + {% include "leaflet_map.html" %} + +
+ +{% include "footer.html" %} diff --git a/resources/server/api/ogc/templates/wfs3/getLandingPage.html b/resources/server/api/ogc/templates/wfs3/getLandingPage.html new file mode 100644 index 00000000000..5d9126407ae --- /dev/null +++ b/resources/server/api/ogc/templates/wfs3/getLandingPage.html @@ -0,0 +1,17 @@ + +{% include "header.html" %} + +

QGIS Server

+ +

Available services

+
    + {% for link in links %} + {% if link.rel != "alternate" %} + {% if link.rel != "self" %} +
  • {{ link.title }}
  • + {% endif %} + {% endif %} + {% endfor %} +
+ +{% include "footer.html" %} diff --git a/resources/server/api/ogc/templates/wfs3/getRequirementClasses.html b/resources/server/api/ogc/templates/wfs3/getRequirementClasses.html new file mode 100644 index 00000000000..134ca563c01 --- /dev/null +++ b/resources/server/api/ogc/templates/wfs3/getRequirementClasses.html @@ -0,0 +1,15 @@ + +{% include "header.html" %} + +

QGIS Server

+ +

Conformance Classes

+ +
    + {% for link in conformsTo %} +
  • {{ link }}
  • + {% endfor %} +
+ + +{% include "footer.html" %} diff --git a/resources/server/api/ogc/templates/wfs3/header.html b/resources/server/api/ogc/templates/wfs3/header.html new file mode 100644 index 00000000000..a0deb614432 --- /dev/null +++ b/resources/server/api/ogc/templates/wfs3/header.html @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + {{ metadata.pageTitle }} + + + +
+ diff --git a/resources/server/api/ogc/templates/wfs3/leaflet_map.html b/resources/server/api/ogc/templates/wfs3/leaflet_map.html new file mode 100644 index 00000000000..e2fbecefd1a --- /dev/null +++ b/resources/server/api/ogc/templates/wfs3/leaflet_map.html @@ -0,0 +1,30 @@ + +
+
+ + +
diff --git a/resources/server/api/ogc/templates/wfs3/links.html b/resources/server/api/ogc/templates/wfs3/links.html new file mode 100644 index 00000000000..d2c44accce9 --- /dev/null +++ b/resources/server/api/ogc/templates/wfs3/links.html @@ -0,0 +1,6 @@ + + diff --git a/scripts/sipify.pl b/scripts/sipify.pl index ccf5494adae..7577fd77880 100755 --- a/scripts/sipify.pl +++ b/scripts/sipify.pl @@ -405,6 +405,7 @@ sub fix_annotations { $line =~ s/\bSIP_TRANSFER\b/\/Transfer\//g; $line =~ s/\bSIP_TRANSFERBACK\b/\/TransferBack\//; $line =~ s/\bSIP_TRANSFERTHIS\b/\/TransferThis\//; + $line =~ s/\bSIP_GETWRAPPER\b/\/GetWrapper\//; $line =~ s/SIP_PYNAME\(\s*(\w+)\s*\)/\/PyName=$1\//; $line =~ s/SIP_TYPEHINT\(\s*(\w+)\s*\)/\/TypeHint="$1"\//; diff --git a/scripts/spell_check/.agignore b/scripts/spell_check/.agignore index 1f3209179f1..21484136536 100644 --- a/scripts/spell_check/.agignore +++ b/scripts/spell_check/.agignore @@ -23,7 +23,7 @@ src/plugins/grass/qtermwidget/ *.*.prepare *.sld .agignore - +*.json #Specific files diff --git a/src/core/qgis_sip.h b/src/core/qgis_sip.h index a36b65828ab..3f8f92f01aa 100644 --- a/src/core/qgis_sip.h +++ b/src/core/qgis_sip.h @@ -35,6 +35,13 @@ */ #define SIP_TRANSFER + +/* + * https://www.riverbankcomputing.com/static/Docs/sip/annotations.html#argument-annotation-GetWrapper + * + */ +#define SIP_GETWRAPPER + /* * http://pyqt.sourceforge.net/Docs/sip4/annotations.html?highlight=keepreference#function-annotation-TransferBack */ diff --git a/src/core/qgsjsonutils.cpp b/src/core/qgsjsonutils.cpp index 2ccbe1126e3..ea2ed6f2908 100644 --- a/src/core/qgsjsonutils.cpp +++ b/src/core/qgsjsonutils.cpp @@ -224,6 +224,11 @@ json QgsJsonExporter::exportFeatureToJsonObject( const QgsFeature &feature, cons } QString QgsJsonExporter::exportFeatures( const QgsFeatureList &features, int indent ) const +{ + return QString::fromStdString( exportFeaturesToJsonObject( features ).dump( indent ) ); +} + +json QgsJsonExporter::exportFeaturesToJsonObject( const QgsFeatureList &features ) const { json data { @@ -235,7 +240,7 @@ QString QgsJsonExporter::exportFeatures( const QgsFeatureList &features, int ind { data["features"].push_back( exportFeatureToJsonObject( feature ) ); } - return QString::fromStdString( data.dump( indent ) ); + return data; } // diff --git a/src/core/qgsjsonutils.h b/src/core/qgsjsonutils.h index ddf949b2380..077c07399f2 100644 --- a/src/core/qgsjsonutils.h +++ b/src/core/qgsjsonutils.h @@ -213,7 +213,7 @@ class CORE_EXPORT QgsJsonExporter * \param extraProperties map of extra attributes to include in feature's properties * \param id optional ID to use as GeoJSON feature's ID instead of input feature's ID. If omitted, feature's * ID is used. - * \returns QJsonObject + * \returns json object * \see exportFeatures() */ json exportFeatureToJsonObject( const QgsFeature &feature, @@ -230,6 +230,15 @@ class CORE_EXPORT QgsJsonExporter */ QString exportFeatures( const QgsFeatureList &features, int indent = -1 ) const; + /** + * Returns a JSON object representation of a list of features (feature collection). + * \param features features to convert + * \returns json object + * \see exportFeatures() + * \since QGIS 3.10 + */ + json exportFeaturesToJsonObject( const QgsFeatureList &features ) const SIP_SKIP; + private: //! Maximum number of decimal places for geometry coordinates diff --git a/src/core/qgsmaplayer.cpp b/src/core/qgsmaplayer.cpp index 396ab903c07..2ae0e9c445a 100644 --- a/src/core/qgsmaplayer.cpp +++ b/src/core/qgsmaplayer.cpp @@ -174,6 +174,11 @@ const QgsDataProvider *QgsMapLayer::dataProvider() const return nullptr; } +QString QgsMapLayer::shortName() const +{ + return mShortName; +} + QString QgsMapLayer::publicSource() const { // Redo this every time we're asked for it, as we don't know if diff --git a/src/core/qgsmaplayer.h b/src/core/qgsmaplayer.h index 36daf6da422..19efa4d5bae 100644 --- a/src/core/qgsmaplayer.h +++ b/src/core/qgsmaplayer.h @@ -261,7 +261,7 @@ class CORE_EXPORT QgsMapLayer : public QObject * used by QGIS Server to identify the layer. * \see setShortName() */ - QString shortName() const { return mShortName; } + QString shortName() const; /** * Sets the title of the layer diff --git a/src/core/qgsproject.cpp b/src/core/qgsproject.cpp index 1994a82bcd2..39c76d27aa5 100644 --- a/src/core/qgsproject.cpp +++ b/src/core/qgsproject.cpp @@ -2715,6 +2715,25 @@ QList QgsProject::mapLayersByName( const QString &layerName ) con return mLayerStore->mapLayersByName( layerName ); } +QList QgsProject::mapLayersByShortName( const QString &shortName ) const +{ + QList layers; + const auto constMapLayers { mLayerStore->mapLayers() }; + for ( const auto &l : constMapLayers ) + { + if ( ! l->shortName().isEmpty() ) + { + if ( l->shortName() == shortName ) + layers << l; + } + else if ( l->name() == shortName ) + { + layers << l; + } + } + return layers; +} + bool QgsProject::unzip( const QString &filename, QgsProject::ReadFlags flags ) { clearError(); diff --git a/src/core/qgsproject.h b/src/core/qgsproject.h index 963c2e18a58..32f467309fa 100644 --- a/src/core/qgsproject.h +++ b/src/core/qgsproject.h @@ -759,6 +759,18 @@ class CORE_EXPORT QgsProject : public QObject, public QgsExpressionContextGenera */ QList mapLayersByName( const QString &layerName ) const; + /** + * Retrieves a list of matching registered layers by layer \a shortName. + * If layer's short name is empty a match with layer's name is attempted. + * + * \returns list of matching layers + * \see mapLayer() + * \see mapLayers() + * \since QGIS 3.10 + */ + QList mapLayersByShortName( const QString &shortName ) const; + + /** * Returns a map of all registered layers by layer ID. * @@ -792,6 +804,38 @@ class CORE_EXPORT QgsProject : public QObject, public QgsExpressionContextGenera { return mLayerStore->layers(); } + + /** + * Retrieves a list of matching registered layers by layer \a shortName with a specified layer type, + * if layer's short name is empty a match with layer's name is attempted. + * + * \param shortName short name of layers to match + * \returns list of matching layers + * \see mapLayer() + * \see mapLayers() + * \note not available in Python bindings + * \since QGIS 3.10 + */ + template + QVector mapLayersByShortName( const QString &shortName ) const + { + QVector layers; + const auto constMapLayers { mLayerStore->layers() }; + for ( const auto l : constMapLayers ) + { + if ( ! l->shortName().isEmpty() ) + { + if ( l->shortName() == shortName ) + layers << l; + } + else if ( l->name() == shortName ) + { + layers << l; + } + } + return layers; + } + #endif /** diff --git a/src/gui/ogr/qgsogrhelperfunctions.cpp b/src/gui/ogr/qgsogrhelperfunctions.cpp index eed88c2f53e..bfc49163d2d 100644 --- a/src/gui/ogr/qgsogrhelperfunctions.cpp +++ b/src/gui/ogr/qgsogrhelperfunctions.cpp @@ -218,7 +218,7 @@ QString createDatabaseURI( const QString &connectionType, const QString &host, c } -QString createProtocolURI( const QString &type, const QString &url, const QString &configId, const QString &username, const QString &password, bool expandAuthConfig ) +QString createProtocolURI( const QString &type, const QString &url, const QString &configId, const QString &username, const QString &password, bool expandAuthConfig ) { QString uri; if ( type == QLatin1String( "HTTP/HTTPS/FTP" ) ) diff --git a/src/server/CMakeLists.txt b/src/server/CMakeLists.txt index 6e0bcd640f7..be3d1af619c 100644 --- a/src/server/CMakeLists.txt +++ b/src/server/CMakeLists.txt @@ -26,6 +26,11 @@ SET(QGIS_SERVER_SRCS qgsfilterrestorer.cpp qgsrequesthandler.cpp qgsserver.cpp + qgsserverapi.cpp + qgsserverogcapi.cpp + qgsserverogcapihandler.cpp + qgsserverapiutils.cpp + qgsserverapicontext.cpp qgsserverparameters.cpp qgsserverexception.cpp qgsserverinterface.cpp @@ -42,11 +47,17 @@ SET(QGIS_SERVER_SRCS qgsfeaturefilterprovidergroup.cpp qgsfeaturefilter.cpp qgsstorebadlayerinfo.cpp + qgsserverquerystringparameter.cpp ) SET (QGIS_SERVER_HDRS qgsservicemodule.h qgsmapserviceexception.h + qgsserverapi.h + qgsserverogcapi.h + qgsserverogcapihandler.h + qgsserverapicontext.h + qgsserverquerystringparameter.h ) @@ -54,8 +65,10 @@ SET (QGIS_SERVER_MOC_HDRS qgscapabilitiescache.h qgsconfigcache.h qgsserverlogger.h + qgsserverogcapi.h qgsserversettings.h qgsserverparameters.h + qgsserverquerystringparameter.h ) @@ -99,6 +112,7 @@ INCLUDE_DIRECTORIES(SYSTEM ${QTKEYCHAIN_INCLUDE_DIR} ) INCLUDE_DIRECTORIES( + ${CMAKE_SOURCE_DIR}/external ${CMAKE_SOURCE_DIR}/src/core ${CMAKE_SOURCE_DIR}/src/core/auth ${CMAKE_SOURCE_DIR}/src/core/dxf diff --git a/src/server/qgsfcgiserverrequest.cpp b/src/server/qgsfcgiserverrequest.cpp index 5b4e33cabe4..289fd8aaf21 100644 --- a/src/server/qgsfcgiserverrequest.cpp +++ b/src/server/qgsfcgiserverrequest.cpp @@ -117,11 +117,18 @@ QgsFcgiServerRequest::QgsFcgiServerRequest() setUrl( url ); setMethod( method ); + // Get accept header for content-type negotiation + const char *accept = getenv( "HTTP_ACCEPT" ); + if ( accept ) + { + setHeader( QStringLiteral( "Accept" ), accept ); + } + // Output debug infos Qgis::MessageLevel logLevel = QgsServerLogger::instance()->logLevel(); if ( logLevel <= Qgis::Info ) { - printRequestInfos(); + printRequestInfos( url ); } } @@ -175,7 +182,7 @@ void QgsFcgiServerRequest::readData() } } -void QgsFcgiServerRequest::printRequestInfos() +void QgsFcgiServerRequest::printRequestInfos( const QUrl &url ) { QgsMessageLog::logMessage( QStringLiteral( "******************** New request ***************" ), QStringLiteral( "Server" ), Qgis::Info ); @@ -183,18 +190,29 @@ void QgsFcgiServerRequest::printRequestInfos() { QStringLiteral( "SERVER_NAME" ), QStringLiteral( "REQUEST_URI" ), + QStringLiteral( "SCRIPT_NAME" ), + QStringLiteral( "HTTPS" ), QStringLiteral( "REMOTE_ADDR" ), QStringLiteral( "REMOTE_HOST" ), + QStringLiteral( "SERVER_PORT" ), + QStringLiteral( "QUERY_STRING" ), QStringLiteral( "REMOTE_USER" ), QStringLiteral( "REMOTE_IDENT" ), QStringLiteral( "CONTENT_TYPE" ), + QStringLiteral( "REQUEST_METHOD" ), QStringLiteral( "AUTH_TYPE" ), + QStringLiteral( "HTTP_ACCEPT" ), QStringLiteral( "HTTP_USER_AGENT" ), QStringLiteral( "HTTP_PROXY" ), QStringLiteral( "NO_PROXY" ), - QStringLiteral( "HTTP_AUTHORIZATION" ) + QStringLiteral( "HTTP_AUTHORIZATION" ), + QStringLiteral( "QGIS_PROJECT_FILE" ) }; + QgsMessageLog::logMessage( QStringLiteral( "Request URL: %2" ).arg( url.url() ), QStringLiteral( "Server" ), Qgis::Info ); + QgsMessageLog::logMessage( QStringLiteral( "Environment:" ), QStringLiteral( "Server" ), Qgis::Info ); + QgsMessageLog::logMessage( QStringLiteral( "------------------------------------------------" ), QStringLiteral( "Server" ), Qgis::Info ); + for ( const auto &envVar : envVars ) { if ( getenv( envVar.toStdString().c_str() ) ) diff --git a/src/server/qgsfcgiserverrequest.h b/src/server/qgsfcgiserverrequest.h index b16c7ab43f1..dc5cf155094 100644 --- a/src/server/qgsfcgiserverrequest.h +++ b/src/server/qgsfcgiserverrequest.h @@ -46,7 +46,7 @@ class SERVER_EXPORT QgsFcgiServerRequest: public QgsServerRequest // Log request info: print debug infos // about the request - void printRequestInfos(); + void printRequestInfos( const QUrl &url ); QByteArray mData; diff --git a/src/server/qgsmapserviceexception.h b/src/server/qgsmapserviceexception.h index 758e10675db..89fec2baca4 100644 --- a/src/server/qgsmapserviceexception.h +++ b/src/server/qgsmapserviceexception.h @@ -18,6 +18,8 @@ #ifndef QGSMAPSERVICEEXCEPTION #define QGSMAPSERVICEEXCEPTION +#define SIP_NO_FILE + #include #include "qgsserverexception.h" @@ -36,7 +38,6 @@ * * "OperationNotSupported" * \deprecated Use QsgServerException */ - class SERVER_EXPORT QgsMapServiceException : public QgsOgcServiceException { public: diff --git a/src/server/qgsserver.cpp b/src/server/qgsserver.cpp index 1c576f2c183..5bee48b2e25 100644 --- a/src/server/qgsserver.cpp +++ b/src/server/qgsserver.cpp @@ -34,6 +34,8 @@ #include "qgsserverrequest.h" #include "qgsfilterresponsedecorator.h" #include "qgsservice.h" +#include "qgsserverapi.h" +#include "qgsserverapicontext.h" #include "qgsserverparameters.h" #include "qgsapplication.h" @@ -145,7 +147,16 @@ QString QgsServer::configPath( const QString &defaultConfigPath, const QString & { if ( configPath.isEmpty() ) { - QgsMessageLog::logMessage( QStringLiteral( "Using default configuration file path: %1" ).arg( defaultConfigPath ), QStringLiteral( "Server" ), Qgis::Info ); + // Read it from the environment, because a rewrite rule may have rewritten it + if ( getenv( "QGIS_PROJECT_FILE" ) ) + { + cfPath = getenv( "QGIS_PROJECT_FILE" ); + QgsMessageLog::logMessage( QStringLiteral( "Using configuration file path from environment: %1" ).arg( cfPath ), QStringLiteral( "Server" ), Qgis::Info ); + } + else if ( ! defaultConfigPath.isEmpty() ) + { + QgsMessageLog::logMessage( QStringLiteral( "Using default configuration file path: %1" ).arg( defaultConfigPath ), QStringLiteral( "Server" ), Qgis::Info ); + } } else { @@ -344,33 +355,54 @@ void QgsServer::handleRequest( QgsServerRequest &request, QgsServerResponse &res const QgsServerParameters params = request.serverParameters(); printRequestParameters( params.toMap(), logLevel ); - //Config file path + // Setup project (config file path) if ( ! project ) { + QString configFilePath = configPath( *sConfigFilePath, params.map() ); + // load the project if needed and not empty - project = mConfigCache->project( sServerInterface->configFilePath() ); + project = mConfigCache->project( configFilePath ); + } + + if ( project ) + { + sServerInterface->setConfigFilePath( project->fileName() ); + } + + // Dispatcher: if SERVICE is set, we assume a OWS service, if not, let's try an API + // TODO: QGIS 4 fix the OWS services and treat them as APIs + QgsServerApi *api = nullptr; + if ( params.service().isEmpty() && ( api = sServiceRegistry->apiForRequest( request ) ) ) + { + QgsServerApiContext context { api->rootPath(), &request, &responseDecorator, project, sServerInterface }; + api->executeRequest( context ); + } + else + { + + // Project is mandatory for OWS at this point if ( ! project ) { throw QgsServerException( QStringLiteral( "Project file error" ) ); } - } - if ( ! params.fileName().isEmpty() ) - { - const QString value = QString( "attachment; filename=\"%1\"" ).arg( params.fileName() ); - requestHandler.setResponseHeader( QStringLiteral( "Content-Disposition" ), value ); - } + if ( ! params.fileName().isEmpty() ) + { + const QString value = QString( "attachment; filename=\"%1\"" ).arg( params.fileName() ); + requestHandler.setResponseHeader( QStringLiteral( "Content-Disposition" ), value ); + } - // Lookup for service - QgsService *service = sServiceRegistry->getService( params.service(), params.version() ); - if ( service ) - { - service->executeRequest( request, responseDecorator, project ); - } - else - { - throw QgsOgcServiceException( QStringLiteral( "Service configuration error" ), - QStringLiteral( "Service unknown or unsupported" ) ); + // Lookup for service + QgsService *service = sServiceRegistry->getService( params.service(), params.version() ); + if ( service ) + { + service->executeRequest( request, responseDecorator, project ); + } + else + { + throw QgsOgcServiceException( QStringLiteral( "Service configuration error" ), + QStringLiteral( "Service unknown or unsupported" ) ); + } } } catch ( QgsServerException &ex ) diff --git a/src/server/qgsserver.h b/src/server/qgsserver.h index 636194513e7..5dbd5634cfe 100644 --- a/src/server/qgsserver.h +++ b/src/server/qgsserver.h @@ -84,7 +84,7 @@ class SERVER_EXPORT QgsServer /** * Initialize Python - * Note: not in Python bindings + * \note not available in Python bindings */ void initPython(); #endif diff --git a/src/server/qgsserverapi.cpp b/src/server/qgsserverapi.cpp new file mode 100644 index 00000000000..88c659ea64c --- /dev/null +++ b/src/server/qgsserverapi.cpp @@ -0,0 +1,37 @@ +/*************************************************************************** + qgsserverapi.cpp + + Class defining the service interface for QGIS server APIs. + ------------------- + begin : 2019-04-16 + copyright : (C) 2019 by Alessandro Pasotti + email : elpaso at itopen dot it + ***************************************************************************/ + +/*************************************************************************** + * * + * 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 "qgsserverapi.h" + +QgsServerApi::QgsServerApi( QgsServerInterface *serverIface ) + : mServerIface( serverIface ) +{ +} + +bool QgsServerApi::accept( const QUrl &url ) const +{ + return url.path().contains( rootPath() ); +} + +QgsServerInterface *QgsServerApi::serverIface() const +{ + return mServerIface; +} diff --git a/src/server/qgsserverapi.h b/src/server/qgsserverapi.h new file mode 100644 index 00000000000..c5bf228941e --- /dev/null +++ b/src/server/qgsserverapi.h @@ -0,0 +1,141 @@ +/*************************************************************************** + qgsserverapi.h + + Class defining the service interface for QGIS server APIs. + ------------------- + begin : 2019-04-16 + copyright : (C) 2019 by Alessandro Pasotti + email : elpaso at itopen dot it + ***************************************************************************/ + +/*************************************************************************** + * * + * 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 QGSSERVERAPI_H +#define QGSSERVERAPI_H + +#include "qgis_server.h" +#include +#include "qgsserverexception.h" +#include "qgsserverrequest.h" + +class QgsServerResponse; +class QgsProject; +class QgsServerApiContext; +class QgsServerInterface; + +/** + * \ingroup server + * Server generic API endpoint abstract base class. + * + * \see QgsServerOgcApi for an OGC API (aka WFS3) implementation. + * + * An API must have a name and a (possibly empty) version and define a + * (possibly empty) root path (e.g. "/wfs3"). + * + * The server routing logic will check incoming request URLs by passing them + * to the API's accept(url) method, the default implementation performs a simple + * check for the presence of the API's root path string in the URL. + * This simple logic implies that APIs must be registered in reverse order from the + * most specific to the most generic: given two APIs with root paths '/wfs' and '/wfs3', + * '/wfs3' must be registered first or it will be shadowed by '/wfs'. + * APIs developers are encouraged to implement a more robust accept(url) logic by + * making sure that their APIs accept only URLs they can actually handle, if they do, + * the APIs registration order becomes irrelevant. + * + * After the API has been registered to the server API registry: + * + * \code{.py} + * class API(QgsServerApi): + * + * def name(self): + * return "Test API" + * + * def rootPath(self): + * return "/testapi" + * + * def executeRequest(self, request_context): + * request_context.response().write(b"\"Test API\"") + * + * server = QgsServer() + * api = API(server.serverInterface()) + * server.serverInterface().serviceRegistry().registerApi(api) + * \endcode + * + * the incoming calls with an URL path starting with the API root path + * will be routed to the first matching API and executeRequest() method + * of the API will be invoked. + * + * + * \since QGIS 3.10 + */ +class SERVER_EXPORT QgsServerApi +{ + + public: + + /** + * Creates a QgsServerApi object + */ + QgsServerApi( QgsServerInterface *serverIface ); + + virtual ~QgsServerApi() = default; + + /** + * Returns the API name + */ + virtual const QString name() const = 0; + + /** + * Returns the API description + */ + virtual const QString description() const = 0; + + /** + * Returns the version of the service + * \note the default implementation returns an empty string + */ + virtual const QString version() const { return QString(); } + + /** + * Returns the root path for the API + */ + virtual const QString rootPath() const = 0; + + /** + * Returns TRUE if the given method is supported by the API, default implementation supports all methods. + */ + virtual bool allowMethod( QgsServerRequest::Method ) const { return true; } + + /** + * Returns TRUE if the given \a url is handled by the API, default implementation checks for the presence of rootPath inside the \a url path. + */ + virtual bool accept( const QUrl &url ) const; + + /** + * Executes a request by passing the given \a context to the API handlers. + */ + virtual void executeRequest( const QgsServerApiContext &context ) const = 0; + + /** + * Returns the server interface + */ + QgsServerInterface *serverIface() const; + + private: + + QgsServerInterface *mServerIface = nullptr; +}; + + +#endif // QGSSERVERAPI_H + + diff --git a/src/server/qgsserverapicontext.cpp b/src/server/qgsserverapicontext.cpp new file mode 100644 index 00000000000..5bc3d45b642 --- /dev/null +++ b/src/server/qgsserverapicontext.cpp @@ -0,0 +1,83 @@ +/*************************************************************************** + qgsserverapicontext.cpp - QgsServerApiContext + + --------------------- + begin : 13.5.2019 + copyright : (C) 2019 by Alessandro Pasotti + email : elpaso at itopen dot it + *************************************************************************** + * * + * 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 "qgsserverapicontext.h" + +#include "qgsserverrequest.h" +#include "qgsserverresponse.h" +#include "qgsproject.h" +#include "qgsserverinterface.h" + +QgsServerApiContext::QgsServerApiContext( const QString &apiRootPath, const QgsServerRequest *request, QgsServerResponse *response, const QgsProject *project, QgsServerInterface *serverInterface ): + mApiRootPath( apiRootPath ), + mRequest( request ), + mResponse( response ), + mProject( project ), + mServerInterface( serverInterface ) +{ + +} + +const QgsServerRequest *QgsServerApiContext::request() const +{ + return mRequest; +} + + +QgsServerResponse *QgsServerApiContext::response() const +{ + return mResponse; +} + + +const QgsProject *QgsServerApiContext::project() const +{ + return mProject; +} + +void QgsServerApiContext::setProject( const QgsProject *project ) +{ + mProject = project; +} + +QgsServerInterface *QgsServerApiContext::serverInterface() const +{ + return mServerInterface; +} + +const QString QgsServerApiContext::matchedPath() const +{ + auto path { mRequest->url().path( )}; + const int idx { path.indexOf( mApiRootPath )}; + if ( idx != -1 ) + { + path.truncate( idx + mApiRootPath.length() ); + return path; + } + else + { + return QString(); + } +} + +QString QgsServerApiContext::apiRootPath() const +{ + return mApiRootPath; +} + +void QgsServerApiContext::setRequest( const QgsServerRequest *request ) +{ + mRequest = request; +} diff --git a/src/server/qgsserverapicontext.h b/src/server/qgsserverapicontext.h new file mode 100644 index 00000000000..2ae1cb6b610 --- /dev/null +++ b/src/server/qgsserverapicontext.h @@ -0,0 +1,113 @@ +/*************************************************************************** + qgsserverapicontext.h - QgsServerApiContext + + --------------------- + begin : 13.5.2019 + copyright : (C) 2019 by Alessandro Pasotti + email : elpaso at itopen dot it + *************************************************************************** + * * + * 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 QGSSERVERAPICONTEXT_H +#define QGSSERVERAPICONTEXT_H + +#include "qgis_server.h" +#include + +class QgsServerResponse; +class QgsServerRequest; +class QgsServerInterface; +class QgsProject; + +/** + * \ingroup server + * The QgsServerApiContext class encapsulates the resources for a particular client + * request: the request and response objects, the project (might be NULL) and + * the server interface, the API root path that matched the request is also added. + * + * QgsServerApiContext is lightweight copyable object meant to be passed along the + * request handlers chain. + * + * \since QGIS 3.10 + */ +class SERVER_EXPORT QgsServerApiContext +{ + public: + + /** + * QgsServerApiContext constructor + * + * \param apiRootPath is the API root path, this information is used by the + * handlers to build the href links to the resources and to the HTML templates. + * \param request the incoming request + * \param response the response + * \param project the project (might be NULL) + * \param serverInterface the server interface + */ + QgsServerApiContext( const QString &apiRootPath, const QgsServerRequest *request, QgsServerResponse *response, + const QgsProject *project, QgsServerInterface *serverInterface ); + + /** + * Returns the server request object + */ + const QgsServerRequest *request() const; + + /** + * Returns the server response object + */ + QgsServerResponse *response() const; + + /** + * Returns the (possibly NULL) project + * \see setProject() + */ + const QgsProject *project() const; + + /** + * Sets the project to \a project + * \see project() + */ + void setProject( const QgsProject *project ); + + /** + * Returns the server interface + */ + QgsServerInterface *serverInterface() const; + + /** + * Returns the initial part of the incoming request URL path that matches the + * API root path. + * If there is no match returns an empty string (it should never happen). + * + * I.e. for an API with root path "/wfs3" and an incoming request + * "https://www.qgis.org/services/wfs3/collections" + * this method will return "/resources/wfs3" + * + */ + const QString matchedPath( ) const; + + /** + * Returns the API root path + */ + QString apiRootPath() const; + + /** + * Sets context request to \a request + */ + void setRequest( const QgsServerRequest *request ); + + private: + + QString mApiRootPath; + const QgsServerRequest *mRequest = nullptr; + QgsServerResponse *mResponse = nullptr; + const QgsProject *mProject = nullptr; + QgsServerInterface *mServerInterface = nullptr; +}; + +#endif // QGSSERVERAPICONTEXT_H diff --git a/src/server/qgsserverapiutils.cpp b/src/server/qgsserverapiutils.cpp new file mode 100644 index 00000000000..3acfab31ae0 --- /dev/null +++ b/src/server/qgsserverapiutils.cpp @@ -0,0 +1,196 @@ +/*************************************************************************** + qgsserverapiutils.cpp + + Class defining utilities for QGIS server APIs. + ------------------- + begin : 2019-04-16 + copyright : (C) 2019 by Alessandro Pasotti + email : elpaso at itopen dot it + ***************************************************************************/ + +/*************************************************************************** + * * + * 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 "qgsserverapiutils.h" +#include "qgsrectangle.h" +#include "qgsvectorlayer.h" +#include "qgscoordinatereferencesystem.h" +#include "qgsserverprojectutils.h" +#include "qgsmessagelog.h" + +#include "nlohmann/json.hpp" + +#include + +QgsRectangle QgsServerApiUtils::parseBbox( const QString &bbox ) +{ + const auto parts { bbox.split( ',', QString::SplitBehavior::SkipEmptyParts ) }; + // Note: Z is ignored + auto ok { true }; + if ( parts.count() == 4 || parts.count() == 6 ) + { + const auto hasZ { parts.count() == 6 }; + auto toDouble = [ & ]( const int i ) -> double + { + if ( ! ok ) + return 0; + return parts[i].toDouble( &ok ); + }; + QgsRectangle rect; + if ( hasZ ) + { + rect = QgsRectangle( toDouble( 0 ), toDouble( 1 ), + toDouble( 3 ), toDouble( 4 ) ); + } + else + { + rect = QgsRectangle( toDouble( 0 ), toDouble( 1 ), + toDouble( 2 ), toDouble( 3 ) ); + } + if ( ok ) + { + return rect; + } + } + return QgsRectangle(); +} + +json QgsServerApiUtils::layerExtent( const QgsVectorLayer *layer ) +{ + auto extent { layer->extent() }; + if ( layer->crs().postgisSrid() != 4326 ) + { + static const QgsCoordinateReferenceSystem targetCrs { 4326 }; + const QgsCoordinateTransform ct( layer->crs(), targetCrs, layer->transformContext() ); + extent = ct.transform( extent ); + } + return {{ extent.xMinimum(), extent.yMinimum(), extent.xMaximum(), extent.yMaximum() }}; +} + +QgsCoordinateReferenceSystem QgsServerApiUtils::parseCrs( const QString &bboxCrs ) +{ + QgsCoordinateReferenceSystem crs; + // We get this: + // http://www.opengis.net/def/crs/OGC/1.3/CRS84 + // We want this: + // "urn:ogc:def:crs::[]:" + const auto parts { QUrl( bboxCrs ).path().split( '/' ) }; + if ( parts.count() == 6 ) + { + return crs.fromOgcWmsCrs( QStringLiteral( "urn:ogc:def:crs:%1:%2:%3" ).arg( parts[3], parts[4], parts[5] ) ); + } + else + { + return crs; + } +} + +const QgsFields QgsServerApiUtils::publishedFields( const QgsVectorLayer *layer ) +{ + // TODO: implement plugin's ACL filtering + return layer->fields(); +} + +const QVector QgsServerApiUtils::publishedWfsLayers( const QgsProject *project ) +{ + const QStringList wfsLayerIds = QgsServerProjectUtils::wfsLayerIds( *project ); + const QStringList wfstUpdateLayersId = QgsServerProjectUtils::wfstUpdateLayerIds( *project ); + const QStringList wfstInsertLayersId = QgsServerProjectUtils::wfstInsertLayerIds( *project ); + const QStringList wfstDeleteLayersId = QgsServerProjectUtils::wfstDeleteLayerIds( *project ); + QVector result; + const auto constLayers { project->mapLayers() }; + for ( auto it = project->mapLayers().constBegin(); it != project->mapLayers().constEnd(); it++ ) + { + if ( wfstUpdateLayersId.contains( it.value()->id() ) || + wfstInsertLayersId.contains( it.value()->id() ) || + wfstDeleteLayersId.contains( it.value()->id() ) ) + { + result.push_back( it.value() ); + } + + } + return result; +} + +QString QgsServerApiUtils::sanitizedFieldValue( const QString &value ) +{ + QString result { QUrl( value ).toString() }; + static const QRegularExpression re( R"raw(;.*(DROP|DELETE|INSERT|UPDATE|CREATE|INTO))raw" ); + if ( re.match( result.toUpper() ).hasMatch() ) + { + result = QString(); + } + return result.replace( '\'', QStringLiteral( "\'" ) ); +} + +QStringList QgsServerApiUtils::publishedCrsList( const QgsProject *project ) +{ + // This must be always available in OGC APIs + QStringList result { { QStringLiteral( "http://www.opengis.net/def/crs/OGC/1.3/CRS84" )}}; + if ( project ) + { + const QStringList outputCrsList = QgsServerProjectUtils::wmsOutputCrsList( *project ); + for ( const QString &crsId : outputCrsList ) + { + const auto crsUri { crsToOgcUri( QgsCoordinateReferenceSystem::fromOgcWmsCrs( crsId ) ) }; + if ( ! crsUri.isEmpty() ) + { + result.push_back( crsUri ); + } + } + } + return result; +} + +QString QgsServerApiUtils::crsToOgcUri( const QgsCoordinateReferenceSystem &crs ) +{ + const auto parts { crs.authid().split( ':' ) }; + if ( parts.length() == 2 ) + { + if ( parts[0] == QStringLiteral( "EPSG" ) ) + return QStringLiteral( "http://www.opengis.net/def/crs/EPSG/9.6.2/%1" ).arg( parts[1] ) ; + else if ( parts[0] == QStringLiteral( "OGC" ) ) + { + return QStringLiteral( "http://www.opengis.net/def/crs/OGC/1.3/%1" ).arg( parts[1] ) ; + } + else + { + QgsMessageLog::logMessage( QStringLiteral( "Error converting published CRS to URI %1: (not OGC or EPSG)" ).arg( crs.authid() ), QStringLiteral( "Server" ), Qgis::Critical ); + } + } + else + { + QgsMessageLog::logMessage( QStringLiteral( "Error converting published CRS to URI: %1" ).arg( crs.authid() ), QStringLiteral( "Server" ), Qgis::Critical ); + } + return QString(); +} + +QString QgsServerApiUtils::appendMapParameter( const QString &path, const QUrl &requestUrl ) +{ + QList > qi; + QString result { path }; + const auto constItems { requestUrl.queryItems( ) }; + for ( const auto &i : constItems ) + { + if ( i.first.compare( QStringLiteral( "MAP" ), Qt::CaseSensitivity::CaseInsensitive ) == 0 ) + { + qi.push_back( i ); + } + } + if ( ! qi.empty() ) + { + if ( ! path.endsWith( '?' ) ) + { + result += '?'; + } + result.append( QStringLiteral( "MAP=%1" ).arg( qi.first().second ) ); + } + return result; +} + diff --git a/src/server/qgsserverapiutils.h b/src/server/qgsserverapiutils.h new file mode 100644 index 00000000000..90f6f218e95 --- /dev/null +++ b/src/server/qgsserverapiutils.h @@ -0,0 +1,147 @@ +/*************************************************************************** + qgsserverapiutils.h + + Class defining utilities for QGIS server APIs. + ------------------- + begin : 2019-04-16 + copyright : (C) 2019 by Alessandro Pasotti + email : elpaso at itopen dot it + ***************************************************************************/ + +/*************************************************************************** + * * + * 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 QGSSERVERAPIUTILS_H +#define QGSSERVERAPIUTILS_H + +#include "qgis_server.h" +#include +#include "qgsproject.h" +#include "qgsserverprojectutils.h" + +class QgsRectangle; +class QgsCoordinateReferenceSystem; +class QgsVectorLayer; + +#ifndef SIP_RUN +#include "nlohmann/json_fwd.hpp" +using json = nlohmann::json; +#endif + +/** + * \ingroup server + * The QgsServerApiUtils class contains helper functions to handle common API operations. + * \since QGIS 3.10 + */ +class SERVER_EXPORT QgsServerApiUtils +{ + + public: + + /** + * Parses a comma separated \a bbox into a (possibily empty) QgsRectangle. + * + * \note Z values (i.e. a 6 elements bbox) are silently discarded + */ + static QgsRectangle parseBbox( const QString &bbox ); + + /** + * layerExtent returns json array with [xMin,yMin,xMax,yMax] CRS84 extent for the given \a layer + * FIXME: the OpenAPI swagger docs say that it is inverted axis order: West, north, east, south edges of the spatial extent. + * but current example implementations and GDAL assume it's not. + * TODO: maybe consider advertised extent instead? + */ + static json layerExtent( const QgsVectorLayer *layer ) SIP_SKIP; + + /** + * Parses the CRS URI \a bboxCrs (example: "http://www.opengis.net/def/crs/OGC/1.3/CRS84") into a QGIS CRS object + */ + static QgsCoordinateReferenceSystem parseCrs( const QString &bboxCrs ); + + /** + * Returns the list of fields accessible to the service for a given \a layer. + * + * This method takes into account the ACL restrictions provided by QGIS Server Access Control plugins. + * TODO: implement ACL + */ + static const QgsFields publishedFields( const QgsVectorLayer *layer ); + + /** + * Returns the list of layers accessible to the service for a given \a project. + * + * This method takes into account the ACL restrictions provided by QGIS Server Access Control plugins. + * + * \note project must not be NULL + * TODO: implement ACL + */ + static const QVector publishedWfsLayers( const QgsProject *project ); + +#ifndef SIP_RUN + + /** + * Returns the list of layers of type T accessible to the WFS service for a given \a project. + * + * Example: + * + * QVector vectorLayers = publishedLayers(); + * + * TODO: implement ACL + * \note not available in Python bindings + * \see publishedWfsLayers() + */ + template + static const QVector publishedWfsLayers( const QgsProject *project ) + { + const QStringList wfsLayerIds = QgsServerProjectUtils::wfsLayerIds( *project ); + const QStringList wfstUpdateLayersId = QgsServerProjectUtils::wfstUpdateLayerIds( *project ); + const QStringList wfstInsertLayersId = QgsServerProjectUtils::wfstInsertLayerIds( *project ); + const QStringList wfstDeleteLayersId = QgsServerProjectUtils::wfstDeleteLayerIds( *project ); + QVector result; + const auto constLayers { project->layers() }; + for ( const auto &layer : constLayers ) + { + if ( wfstUpdateLayersId.contains( layer->id() ) || + wfstInsertLayersId.contains( layer->id() ) || + wfstDeleteLayersId.contains( layer->id() ) ) + { + result.push_back( layer ); + } + + } + return result; + } + +#endif + + /** + * Sanitizes the input \a value by removing URL encoding and checking for malicious content. + * In case of failure returns an empty string. + */ + static QString sanitizedFieldValue( const QString &value ); + + /** + * Returns the list of CRSs (format: http://www.opengis.net/def/crs/OGC/1.3/CRS84) available for this \a project. + * Information is read from project WMS configuration. + */ + static QStringList publishedCrsList( const QgsProject *project ); + + /** + * Returns a \a crs as OGC URI (format: http://www.opengis.net/def/crs/OGC/1.3/CRS84) + * Returns an empty string on failure. + */ + static QString crsToOgcUri( const QgsCoordinateReferenceSystem &crs ); + + /** + * Appends MAP query string parameter from current \a requestUrl to the given \a path + */ + static QString appendMapParameter( const QString &path, const QUrl &requestUrl ); + +}; +#endif // QGSSERVERAPIUTILS_H diff --git a/src/server/qgsserverexception.h b/src/server/qgsserverexception.h index f67e564ab89..a89de020847 100644 --- a/src/server/qgsserverexception.h +++ b/src/server/qgsserverexception.h @@ -18,12 +18,18 @@ #ifndef QGSSERVEREXCEPTION_H #define QGSSERVEREXCEPTION_H + #include #include #include "qgsexception.h" #include "qgis_server.h" #include "qgis_sip.h" +#include "nlohmann/json.hpp" + +#ifndef SIP_RUN +using json = nlohmann::json; +#endif /** @@ -128,4 +134,183 @@ class SERVER_EXPORT QgsBadRequestException: public QgsOgcServiceException }; #endif +#ifndef SIP_RUN // No API exceptions for SIP, see python/server/qgsserverexception.sip + +/** + * \ingroup server + * \class QgsServerApiException + * \brief Exception base class for API exceptions. + * + * Note that this exception is associated with a default return code 200 which may be + * not appropriate in some situations. + * + * \since QGIS 3.10 + */ +class SERVER_EXPORT QgsServerApiException: public QgsServerException +{ + public: + //! Construction + QgsServerApiException( const QString &code, const QString &message, const QString &mimeType = QStringLiteral( "application/json" ), int responseCode = 200 ) + : QgsServerException( message, responseCode ) + , mCode( code ) + , mMimeType( mimeType ) + { + } + + QByteArray formatResponse( QString &responseFormat SIP_OUT ) const override + { + responseFormat = mMimeType; + json data + { + { + { "code", mCode.toStdString() }, + { "description", what().toStdString() }, + } + }; + if ( responseFormat == QStringLiteral( "application/json" ) ) + { + return QByteArray::fromStdString( data.dump() ); + } + else if ( responseFormat == QStringLiteral( "text/html" ) ) + { + // TODO: template + return QByteArray::fromStdString( data.dump() ); + } + else + { + // TODO: template + return QByteArray::fromStdString( data.dump() ); + } + } + + private: + QString mCode; + QString mMimeType; +}; + + +/** + * \ingroup server + * \class QgsServerApiInternalServerError + * \brief Internal server error API exception. + * + * Note that this exception is associated with a default return code 500 which may be + * not appropriate in some situations. + * + * \since QGIS 3.10 + */ +class SERVER_EXPORT QgsServerApiInternalServerError: public QgsServerApiException +{ + public: + //! Construction + QgsServerApiInternalServerError( const QString &message = QStringLiteral( "Internal server error" ), const QString &mimeType = QStringLiteral( "application/json" ), int responseCode = 500 ) + : QgsServerApiException( QStringLiteral( "Internal server error" ), message, mimeType, responseCode ) + { + } +}; + + +/** + * \ingroup server + * \class QgsServerApiNotFoundError + * \brief Not found error API exception. + * + * Note that this exception is associated with a default return code 404 which may be + * not appropriate in some situations. + * + * \since QGIS 3.10 + */ +class SERVER_EXPORT QgsServerApiNotFoundError: public QgsServerApiException +{ + public: + //! Construction + QgsServerApiNotFoundError( const QString &message, const QString &mimeType = QStringLiteral( "application/json" ), int responseCode = 404 ) + : QgsServerApiException( QStringLiteral( "API not found error" ), message, mimeType, responseCode ) + { + } +}; + + +/** + * \ingroup server + * \class QgsServerApiBadRequestException + * \brief Bad request error API exception. + * + * Note that this exception is associated with a default return code 400 which may be + * not appropriate in some situations. + * + * \since QGIS 3.10 + */ +class SERVER_EXPORT QgsServerApiBadRequestException: public QgsServerApiException +{ + public: + //! Construction + QgsServerApiBadRequestException( const QString &message, const QString &mimeType = QStringLiteral( "application/json" ), int responseCode = 400 ) + : QgsServerApiException( QStringLiteral( "Bad request error" ), message, mimeType, responseCode ) + { + } +}; + +/** + * \ingroup server + * \class QgsServerApiImproperlyConfiguredException + * \brief configuration error on the server prevents to serve the request, which would be valid otherwise. + * + * Note that this exception is associated with a default return code 500 which may be + * not appropriate in some situations. + * + * \since QGIS 3.10 + */ +class SERVER_EXPORT QgsServerApiImproperlyConfiguredException: public QgsServerApiException +{ + public: + //! Construction + QgsServerApiImproperlyConfiguredException( const QString &message, const QString &mimeType = QStringLiteral( "application/json" ), int responseCode = 500 ) + : QgsServerApiException( QStringLiteral( "Improperly configured error" ), message, mimeType, responseCode ) + { + } +}; + + +/** + * \ingroup server + * \class QgsServerApiNotImplementedException + * \brief this method is not yet implemented + * + * Note that this exception is associated with a default return code 500 which may be + * not appropriate in some situations. + * + * \since QGIS 3.10 + */ +class SERVER_EXPORT QgsServerApiNotImplementedException: public QgsServerApiException +{ + public: + //! Construction + QgsServerApiNotImplementedException( const QString &message = QStringLiteral( "Requested method is not implemented" ), const QString &mimeType = QStringLiteral( "application/json" ), int responseCode = 500 ) + : QgsServerApiException( QStringLiteral( "Not implemented error" ), message, mimeType, responseCode ) + { + } +}; + + +/** + * \ingroup server + * \class QgsServerApiInvalidMimeTypeException + * \brief the client sent an invalid mime type in the "Accept" header + * + * Note that this exception is associated with a default return code 406 + * + * \since QGIS 3.10 + */ +class SERVER_EXPORT QgsServerApiInvalidMimeTypeException: public QgsServerApiException +{ + public: + //! Construction + QgsServerApiInvalidMimeTypeException( const QString &message = QStringLiteral( "The Accept header submitted in the request did not support any of the media types supported by the server for the requested resource" ), const QString &mimeType = QStringLiteral( "application/json" ), int responseCode = 406 ) + : QgsServerApiException( QStringLiteral( "Invalid mime-type" ), message, mimeType, responseCode ) + { + } +}; +#endif // no API exceptions for SIP + #endif diff --git a/src/server/qgsserverogcapi.cpp b/src/server/qgsserverogcapi.cpp new file mode 100644 index 00000000000..cdf3e7dcb5c --- /dev/null +++ b/src/server/qgsserverogcapi.cpp @@ -0,0 +1,152 @@ +/*************************************************************************** + qgsserverogcapi.cpp - QgsServerOgcApi + + --------------------- + begin : 10.7.2019 + copyright : (C) 2019 by Alessandro Pasotti + email : elpaso at itopen dot it + *************************************************************************** + * * + * 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 +#include + +#include "qgsserverogcapi.h" +#include "qgsserverogcapihandler.h" +#include "qgsmessagelog.h" +#include "qgsapplication.h" + +QMap QgsServerOgcApi::sContentTypeMime = [ ]() -> QMap +{ + QMap map; + map[QgsServerOgcApi::ContentType::JSON] = QStringLiteral( "application/json" ); + map[QgsServerOgcApi::ContentType::GEOJSON] = QStringLiteral( "application/geo+json" ); + map[QgsServerOgcApi::ContentType::HTML] = QStringLiteral( "text/html" ); + map[QgsServerOgcApi::ContentType::OPENAPI3] = QStringLiteral( "application/openapi+json;version=3.0" ); + return map; +}(); + +QHash> QgsServerOgcApi::sContentTypeAliases = [ ]() -> QHash> +{ + QHash> map; + map[ContentType::JSON] = { QgsServerOgcApi::ContentType::GEOJSON, QgsServerOgcApi::ContentType::OPENAPI3 }; + return map; +}(); + + +QgsServerOgcApi::QgsServerOgcApi( QgsServerInterface *serverIface, const QString &rootPath, const QString &name, const QString &description, const QString &version ): + QgsServerApi( serverIface ), + mRootPath( rootPath ), + mName( name ), + mDescription( description ), + mVersion( version ) +{ + +} + +QgsServerOgcApi::~QgsServerOgcApi() +{ + //qDebug() << "API destroyed: " << name(); +} + +void QgsServerOgcApi::registerHandler( QgsServerOgcApiHandler *handler ) +{ + std::shared_ptr hp( handler ); + mHandlers.emplace_back( std::move( hp ) ); +} + +QUrl QgsServerOgcApi::sanitizeUrl( const QUrl &url ) +{ + return url.adjusted( QUrl::StripTrailingSlash | QUrl::NormalizePathSegments ); +} + +void QgsServerOgcApi::executeRequest( const QgsServerApiContext &context ) const +{ + // Get url + auto path { sanitizeUrl( context.request()->url() ).path() }; + //path.truncate( context.apiRootPath().length() ); + // Find matching handler + auto hasMatch { false }; + for ( const auto &h : mHandlers ) + { + QgsMessageLog::logMessage( QStringLiteral( "Checking API path %1 for %2 " ).arg( path, h->path().pattern() ), QStringLiteral( "Server" ), Qgis::Info ); + if ( h->path().match( path ).hasMatch() ) + { + hasMatch = true; + // Execute handler + QgsMessageLog::logMessage( QStringLiteral( "Found API handler %1" ).arg( QString::fromStdString( h->operationId() ) ), QStringLiteral( "Server" ), Qgis::Info ); + // May throw QgsServerApiBadRequestException or JSON exceptions on serializing + try + { + h->handleRequest( context ); + } + catch ( json::exception &ex ) + { + throw QgsServerApiInternalServerError( QStringLiteral( "The API handler returned an error: %1" ).arg( ex.what() ) ); + } + break; + } + } + // Throw + if ( ! hasMatch ) + { + throw QgsServerApiBadRequestException( QStringLiteral( "Requested URI does not match any registered API handler" ) ); + } +} + +const QMap QgsServerOgcApi::contentTypeMimes() +{ + return sContentTypeMime; +} + +const QHash > QgsServerOgcApi::contentTypeAliases() +{ + return sContentTypeAliases; +} + +std::string QgsServerOgcApi::relToString( const Rel &rel ) +{ + static QMetaEnum metaEnum = QMetaEnum::fromType(); + return metaEnum.valueToKey( rel ); +} + +QString QgsServerOgcApi::contentTypeToString( const ContentType &ct ) +{ + static QMetaEnum metaEnum = QMetaEnum::fromType(); + QString result { metaEnum.valueToKey( ct ) }; + return result.replace( '_', '-' ); +} + +std::string QgsServerOgcApi::contentTypeToStdString( const ContentType &ct ) +{ + static QMetaEnum metaEnum = QMetaEnum::fromType(); + return metaEnum.valueToKey( ct ); +} + +QString QgsServerOgcApi::contentTypeToExtension( const ContentType &ct ) +{ + return contentTypeToString( ct ).toLower(); +} + +QgsServerOgcApi::ContentType QgsServerOgcApi::contenTypeFromExtension( const std::string &extension ) +{ + return sContentTypeMime.key( QString::fromStdString( extension ) ); +} + +std::string QgsServerOgcApi::mimeType( const QgsServerOgcApi::ContentType &contentType ) +{ + return sContentTypeMime.value( contentType, QString() ).toStdString(); +} + +const std::vector > QgsServerOgcApi::handlers() const +{ + return mHandlers; +} + + diff --git a/src/server/qgsserverogcapi.h b/src/server/qgsserverogcapi.h new file mode 100644 index 00000000000..3b0b1a6b44e --- /dev/null +++ b/src/server/qgsserverogcapi.h @@ -0,0 +1,195 @@ +/*************************************************************************** + qgsserverogcapi.h - QgsServerOgcApi + + --------------------- + begin : 10.7.2019 + copyright : (C) 2019 by Alessandro Pasotti + email : elpaso at itopen dot it + *************************************************************************** + * * + * 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 QGSSERVEROGCAPI_H +#define QGSSERVEROGCAPI_H + +#include "qgsserverapi.h" +#include "qgis_server.h" + + +class QgsServerOgcApiHandler; + +/** + * \ingroup server + * QGIS Server OGC API endpoint. QgsServerOgcApi provides the foundation for + * the new generation of REST-API based OGC services (e.g. WFS3). + * + * This class can be used directly and configured by registering handlers + * as instances of QgsServerOgcApiHandler. + * + * \code{.py} + * + * \endcode + * + * \since QGIS 3.10 + */ +class SERVER_EXPORT QgsServerOgcApi : public QgsServerApi +{ + + Q_GADGET + + public: + + // Note: non a scoped enum or qHash fails + //! Rel link types + enum Rel + { + // The following registered link relation types are used + alternate, //! Refers to a substitute for this context. + describedBy, //! Refers to a resource providing information about the link’s context. + collection, //! The target IRI points to a resource that is a member of the collection represented by the context IRI. + item, //! The target IRI points to a resource that is a member of the collection represented by the context IRI. + self, //! Conveys an identifier for the link’s context. + service_desc, //! Identifies service description for the context that is primarily intended for consumption by machines. + service_doc, //! Identifies service documentation for the context that is primarily intended for human consumption. + prev, //! Indicates that the link’s context is a part of a series, and that the previous in the series is the link targe + next, //! Indicates that the link’s context is a part of a series, and that the next in the series is the link target. + license, //! Refers to a license associated with this context. + // In addition the following link relation types are used for which no applicable registered link relation type could be identified: + items, //! Refers to a resource that is comprised of members of the collection represented by the link’s context. + conformance, //! The target IRI points to a resource which represents the collection resource for the context IRI. + data //! The target IRI points to resource data + }; + Q_ENUM( Rel ) + + // Note: cannot be a scoped enum because qHash does not support them + //! Media types used for content negotiation, insert more specific first + enum ContentType + { + GEOJSON, + OPENAPI3, //! "application/openapi+json;version=3.0" + JSON, + HTML + }; + Q_ENUM( ContentType ) + + /** + * QgsServerOgcApi constructor + * \param serverIface pointer to the server interface + * \param rootPath root path for this API (usually starts with a "/", e.g. "/wfs3") + * \param name API name + * \param description API description + * \param version API version + */ + QgsServerOgcApi( QgsServerInterface *serverIface, + const QString &rootPath, + const QString &name, + const QString &description = QString(), + const QString &version = QString() ); + + // QgsServerApi interface + const QString name() const override { return mName; } + const QString description() const override { return mDescription; } + const QString version() const override { return mVersion; } + const QString rootPath() const override { return mRootPath ; } + + ~QgsServerOgcApi() override; + + /** + * Executes a request by passing the given \a context to the API handlers. + */ + virtual void executeRequest( const QgsServerApiContext &context ) const override SIP_THROW( QgsServerApiBadRequestException ) SIP_VIRTUALERRORHANDLER( serverapi_badrequest_exception_handler ); + + /** + * Returns a map of contentType => mime type + * \note not available in Python bindings + */ + static const QMap contentTypeMimes() SIP_SKIP; + + /** + * Returns contenType specializations (e.g. JSON => [GEOJSON, OPENAPI3], XML => [GML]) + * \note not available in Python bindings + */ + static const QHash > contentTypeAliases() SIP_SKIP; + + // Utilities +#ifndef SIP_RUN + + /** + * Registers an OGC API handler passing \a Args to the constructor + * \note not available in Python bindings + */ + template + void registerHandler( Args... args ) + { + mHandlers.emplace_back( std::make_shared( args... ) ); + } +#endif + + /** + * Registers an OGC API \a handler, ownership of the handler is transferred to the API + */ + void registerHandler( QgsServerOgcApiHandler *handler SIP_TRANSFER ); + + /** + * Returns a sanitized \a url with extra slashes removed + */ + static QUrl sanitizeUrl( const QUrl &url ); + + /** + * Returns the string representation of \a rel attribute. + */ + static std::string relToString( const QgsServerOgcApi::Rel &rel ); + + /** + * Returns the string representation of a \a ct (Content-Type) attribute. + */ + static QString contentTypeToString( const QgsServerOgcApi::ContentType &ct ); + + /** + * Returns the string representation of a \a ct (Content-Type) attribute. + */ + static std::string contentTypeToStdString( const QgsServerOgcApi::ContentType &ct ); + + /** + * Returns the file extension for a \a ct (Content-Type). + */ + static QString contentTypeToExtension( const QgsServerOgcApi::ContentType &ct ); + + /** + * Returns the Content-Type value corresponding to \a extension. + */ + static QgsServerOgcApi::ContentType contenTypeFromExtension( const std::string &extension ); + + /** + * Returns the mime-type for the \a contentType or an empty string if not found + */ + static std::string mimeType( const QgsServerOgcApi::ContentType &contentType ); + + /** + * Returns registered handlers + */ + const std::vector > handlers() const SIP_SKIP; + + private: + + QString mRootPath; + QString mName; + QString mDescription; + QString mVersion; + + //Note: this cannot be unique because of SIP bindings + std::vector> mHandlers; + + //! Stores content type mime strings + static QMap sContentTypeMime; + + //! Stores content type aliases (e.g. JSON->[GEOJSON,OPENAPI3], XML->[GML] ) + static QHash> sContentTypeAliases; + +}; + +#endif // QGSSERVEROGCAPI_H diff --git a/src/server/qgsserverogcapihandler.cpp b/src/server/qgsserverogcapihandler.cpp new file mode 100644 index 00000000000..bc76c4f0a9d --- /dev/null +++ b/src/server/qgsserverogcapihandler.cpp @@ -0,0 +1,494 @@ +/*************************************************************************** + qgsserverogcapihandler.cpp - QgsServerOgcApiHandler + + --------------------- + begin : 10.7.2019 + copyright : (C) 2019 by Alessandro Pasotti + email : elpaso at itopen dot it + *************************************************************************** + * * + * 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 +#include +#include +#include + +#include "qgsmessagelog.h" +#include "qgsproject.h" +#include "qgsjsonutils.h" +#include "qgsvectorlayer.h" + +#include "qgsserverogcapihandler.h" +#include "qgsserverapiutils.h" +#include "qgsserverresponse.h" +#include "qgsserverinterface.h" + + +#include "nlohmann/json.hpp" +#include "inja/inja.hpp" + +using json = nlohmann::json; +using namespace inja; + + + +QVariantMap QgsServerOgcApiHandler::values( const QgsServerApiContext &context ) const +{ + QVariantMap result ; + QVariantList positional; + const auto constParameters { parameters( context ) }; + for ( const auto &p : constParameters ) + { + // value() calls the validators and throw an exception if validation fails + result[p.name()] = p.value( context ); + } + const auto match { path().match( context.request()->url().toString() ) }; + if ( match.hasMatch() ) + { + const auto constNamed { path().namedCaptureGroups() }; + // Get named path parameters + for ( const auto &name : constNamed ) + { + if ( ! name.isEmpty() ) + result[name] = QUrlQuery( match.captured( name ) ).toString() ; + } + } + return result; +} + +QgsServerOgcApiHandler::~QgsServerOgcApiHandler() +{ + //qDebug() << "handler destroyed"; +} + +QString QgsServerOgcApiHandler::contentTypeForAccept( const QString &accept ) const +{ + + QString result; + const auto constMimes { QgsServerOgcApi::contentTypeMimes() }; + for ( const auto &ct : constMimes ) + { + if ( accept.contains( ct ) ) + { + result = ct; + break; + } + } + return result; + +} + +void QgsServerOgcApiHandler::write( json &data, const QgsServerApiContext &context, const json &htmlMetadata ) const +{ + const auto contentType { contentTypeFromRequest( context.request() ) }; + switch ( contentType ) + { + case QgsServerOgcApi::ContentType::HTML: + data["handler"] = schema( context ); + if ( ! htmlMetadata.is_null() ) + { + data["metadata"] = htmlMetadata; + } + htmlDump( data, context ); + break; + case QgsServerOgcApi::ContentType::GEOJSON: + case QgsServerOgcApi::ContentType::JSON: + case QgsServerOgcApi::ContentType::OPENAPI3: + jsonDump( data, context, QgsServerOgcApi::contentTypeMimes().value( contentType ) ); + break; + } +} +void QgsServerOgcApiHandler::write( QVariant &data, const QgsServerApiContext &context, const QVariantMap &htmlMetadata ) const +{ + json j { QgsJsonUtils::jsonFromVariant( data ) }; + json jm { QgsJsonUtils::jsonFromVariant( htmlMetadata ) }; + QgsServerOgcApiHandler::write( j, context, jm ); +} + +std::string QgsServerOgcApiHandler::href( const QgsServerApiContext &context, const QString &extraPath, const QString &extension ) const +{ + QUrl url { context.request()->url() }; + QString urlBasePath { context.matchedPath() }; + const auto match { path().match( url.path() ) }; + if ( match.captured().count() > 0 ) + { + url.setPath( urlBasePath + match.captured( 0 ) ); + } + else + { + url.setPath( urlBasePath ); + } + + // Remove any existing extension + const auto suffixLength { QFileInfo( url.path() ).completeSuffix().length() }; + if ( suffixLength > 0 ) + { + auto path {url.path()}; + path.truncate( path.length() - ( suffixLength + 1 ) ); + url.setPath( path ); + } + + // Add extra path + url.setPath( url.path() + extraPath ); + + // (re-)add extension + // JSON is the default anyway, we don'n need to add it + if ( ! extension.isEmpty() ) + { + // Remove trailing slashes if any. + QString path { url.path() }; + while ( path.endsWith( '/' ) ) + { + path.chop( 1 ); + } + url.setPath( path + '.' + extension ); + } + return QgsServerOgcApi::sanitizeUrl( url ).toString( QUrl::FullyEncoded ).toStdString(); + +} + +void QgsServerOgcApiHandler::jsonDump( json &data, const QgsServerApiContext &context, const QString &contentType ) const +{ + QDateTime time { QDateTime::currentDateTime() }; + time.setTimeSpec( Qt::TimeSpec::UTC ); + data["timeStamp"] = time.toString( Qt::DateFormat::ISODate ).toStdString() ; + context.response()->setHeader( QStringLiteral( "Content-Type" ), contentType ); +#ifdef QGISDEBUG + context.response()->write( data.dump( 2 ) ); +#else + context.response()->write( data.dump( ) ); +#endif +} + +json QgsServerOgcApiHandler::schema( const QgsServerApiContext &context ) const +{ + Q_UNUSED( context ); + return nullptr; +} + +json QgsServerOgcApiHandler::link( const QgsServerApiContext &context, const QgsServerOgcApi::Rel &linkType, const QgsServerOgcApi::ContentType contentType, const std::string &title ) const +{ + json l + { + { + "href", href( context, "/", + QgsServerOgcApi::contentTypeToExtension( contentType ) ) + }, + { "rel", QgsServerOgcApi::relToString( linkType ) }, + { "type", QgsServerOgcApi::mimeType( contentType ) }, + { "title", title != "" ? title : linkTitle() }, + }; + return l; +} + +json QgsServerOgcApiHandler::links( const QgsServerApiContext &context ) const +{ + const QgsServerOgcApi::ContentType currentCt { contentTypeFromRequest( context.request() ) }; + json links = json::array(); + const QList constCts { contentTypes() }; + for ( const auto &ct : constCts ) + { + links.push_back( link( context, ( ct == currentCt ? QgsServerOgcApi::Rel::self : + QgsServerOgcApi::Rel::alternate ), ct, + linkTitle() + " as " + QgsServerOgcApi::contentTypeToStdString( ct ) ) ); + } + return links; +} + +QgsVectorLayer *QgsServerOgcApiHandler::layerFromContext( const QgsServerApiContext &context ) const +{ + if ( ! context.project() ) + { + throw QgsServerApiImproperlyConfiguredException( QStringLiteral( "Project is invalid or undefined" ) ); + } + // Check collectionId + const QRegularExpressionMatch match { path().match( context.request()->url().path( ) ) }; + if ( ! match.hasMatch() ) + { + throw QgsServerApiNotFoundError( QStringLiteral( "Collection was not found" ) ); + } + const QString collectionId { match.captured( QStringLiteral( "collectionId" ) ) }; + // May throw if not found + return layerFromCollection( context, collectionId ); + +} + +const QString QgsServerOgcApiHandler::staticPath( const QgsServerApiContext &context ) const +{ + // resources/server/api + /static + return context.serverInterface()->serverSettings()->apiResourcesDirectory() + QStringLiteral( "/ogc/static" ); +} + +const QString QgsServerOgcApiHandler::templatePath( const QgsServerApiContext &context ) const +{ + // resources/server/api + /ogc/templates/ + operationId + .html + QString path { context.serverInterface()->serverSettings()->apiResourcesDirectory() }; + path += QStringLiteral( "/ogc/templates" ); + path += context.apiRootPath(); + path += '/'; + path += QString::fromStdString( operationId() ); + path += QStringLiteral( ".html" ); + return path; +} + + +void QgsServerOgcApiHandler::htmlDump( const json &data, const QgsServerApiContext &context ) const +{ + context.response()->setHeader( QStringLiteral( "Content-Type" ), QStringLiteral( "text/html" ) ); + auto path { templatePath( context ) }; + if ( ! QFile::exists( path ) ) + { + QgsMessageLog::logMessage( QStringLiteral( "Template not found error: %1" ).arg( path ), QStringLiteral( "Server" ), Qgis::Critical ); + throw QgsServerApiBadRequestException( QStringLiteral( "Template not found: %1" ).arg( QFileInfo( path ).fileName() ) ); + } + + QFile f( path ); + if ( ! f.open( QFile::ReadOnly | QFile::Text ) ) + { + QgsMessageLog::logMessage( QStringLiteral( "Could not open template file: %1" ).arg( path ), QStringLiteral( "Server" ), Qgis::Critical ); + throw QgsServerApiInternalServerError( QStringLiteral( "Could not open template file: %1" ).arg( QFileInfo( path ).fileName() ) ); + } + + try + { + // Get the template directory and the file name + QFileInfo pathInfo { path }; + Environment env { ( pathInfo.dir().path() + QDir::separator() ).toStdString() }; + + // For template debugging: + env.add_callback( "json_dump", 0, [ = ]( Arguments & ) + { + return data.dump(); + } ); + + // Path manipulation: appends a directory path to the current url + env.add_callback( "path_append", 1, [ = ]( Arguments & args ) + { + auto url { context.request()->url() }; + QFileInfo fi{ url.path() }; + auto suffix { fi.suffix() }; + auto fName { fi.filePath()}; + fName.chop( suffix.length() + 1 ); + fName += '/' + QString::number( args.at( 0 )->get( ) ); + if ( !suffix.isEmpty() ) + { + fName += '.' + suffix; + } + fi.setFile( fName ); + url.setPath( fi.filePath() ); + return url.toString().toStdString(); + } ); + + // Path manipulation: removes the specified number of directory components from the current url path + env.add_callback( "path_chomp", 1, [ = ]( Arguments & args ) + { + QUrl url { QString::fromStdString( args.at( 0 )->get( ) ) }; + QFileInfo fi{ url.path() }; + auto suffix { fi.suffix() }; + auto fName { fi.filePath()}; + fName.chop( suffix.length() + 1 ); + // Chomp last segment + fName = fName.replace( QRegularExpression( R"raw(\/[^/]+$)raw" ), QString() ); + if ( !suffix.isEmpty() ) + { + fName += '.' + suffix; + } + fi.setFile( fName ); + url.setPath( fi.filePath() ); + return url.toString().toStdString(); + } ); + + // Returns filtered links from a link list + // links_filter( , , ) + env.add_callback( "links_filter", 3, [ = ]( Arguments & args ) + { + json links { args.at( 0 )->get( ) }; + std::string key { args.at( 1 )->get( ) }; + std::string value { args.at( 2 )->get( ) }; + json result = json::array(); + for ( const auto &l : links ) + { + if ( l[key] == value ) + { + result.push_back( l ); + } + } + return result; + } ); + + // Returns a short name from content types + env.add_callback( "content_type_name", 1, [ = ]( Arguments & args ) + { + const QgsServerOgcApi::ContentType ct { QgsServerOgcApi::contenTypeFromExtension( args.at( 0 )->get( ) ) }; + return QgsServerOgcApi::contentTypeToStdString( ct ); + } ); + + + // Static: returns the full URL to the specified static + env.add_callback( "static", 1, [ = ]( Arguments & args ) + { + auto asset( args.at( 0 )->get( ) ); + return context.matchedPath().toStdString() + "/static/" + asset; + } ); + + context.response()->write( env.render_file( pathInfo.fileName().toStdString(), data ) ); + } + catch ( std::exception &e ) + { + QgsMessageLog::logMessage( QStringLiteral( "Error parsing template file: %1 - %2" ).arg( path, e.what() ), QStringLiteral( "Server" ), Qgis::Critical ); + throw QgsServerApiInternalServerError( QStringLiteral( "Error parsing template file: %1" ).arg( e.what() ) ); + } +} +QgsServerOgcApi::ContentType QgsServerOgcApiHandler::contentTypeFromRequest( const QgsServerRequest *request ) const +{ + // Fallback to default + QgsServerOgcApi::ContentType result { defaultContentType() }; + bool found { false }; + // First file extension ... + const QString extension { QFileInfo( request->url().path() ).completeSuffix().toUpper() }; + if ( ! extension.isEmpty() ) + { + static QMetaEnum metaEnum { QMetaEnum::fromType() }; + bool ok { false }; + const int ct { metaEnum.keyToValue( extension.toLocal8Bit().constData(), &ok ) }; + if ( ok ) + { + result = static_cast( ct ); + found = true; + } + else + { + QgsMessageLog::logMessage( QStringLiteral( "The client requested an unsupported extension: %1" ).arg( extension ), QStringLiteral( "Server" ), Qgis::Warning ); + } + } + // ... then "Accept" + const QString accept { request->header( QStringLiteral( "Accept" ) ) }; + if ( ! found && ! accept.isEmpty() ) + { + const QString ctFromAccept { contentTypeForAccept( accept ) }; + if ( ! ctFromAccept.isEmpty() ) + { + result = QgsServerOgcApi::contentTypeMimes().key( ctFromAccept ); + found = true; + } + else + { + QgsMessageLog::logMessage( QStringLiteral( "The client requested an unsupported content type in Accept header: %1" ).arg( accept ), QStringLiteral( "Server" ), Qgis::Warning ); + } + } + // Validation: check if the requested content type (or an alias) is supported by the handler + if ( ! contentTypes().contains( result ) ) + { + // Check aliases + bool found { false }; + if ( QgsServerOgcApi::contentTypeAliases().keys().contains( result ) ) + { + const QList constCt { contentTypes() }; + for ( const auto &ct : constCt ) + { + if ( QgsServerOgcApi::contentTypeAliases()[result].contains( ct ) ) + { + result = ct; + found = true; + break; + } + } + } + + if ( ! found ) + { + QgsMessageLog::logMessage( QStringLiteral( "Unsupported Content-Type: %1" ).arg( QgsServerOgcApi::contentTypeToString( result ) ), QStringLiteral( "Server" ), Qgis::Info ); + throw QgsServerApiBadRequestException( QStringLiteral( "Unsupported Content-Type: %1" ).arg( QgsServerOgcApi::contentTypeToString( result ) ) ); + } + } + return result; +} + +QString QgsServerOgcApiHandler::parentLink( const QUrl &url, int levels ) +{ + QString path { url.path() }; + const QFileInfo fi { path }; + const QString suffix { fi.suffix() }; + if ( ! suffix.isEmpty() ) + { + path.chop( suffix.length() + 1 ); + } + while ( path.endsWith( '/' ) ) + { + path.chop( 1 ); + } + QRegularExpression re( R"raw(\/[^/]+$)raw" ); + for ( int i = 0; i < levels ; i++ ) + { + path = path.replace( re, QString() ); + } + QUrl result( url ); + QList > qi; + const auto constItems { result.queryItems( ) }; + for ( const auto &i : constItems ) + { + if ( i.first.compare( QStringLiteral( "MAP" ), Qt::CaseSensitivity::CaseInsensitive ) == 0 ) + { + qi.push_back( i ); + } + } + result.setQueryItems( qi ); + result.setPath( path ); + return result.toString(); +} + +QgsVectorLayer *QgsServerOgcApiHandler::layerFromCollection( const QgsServerApiContext &context, const QString &collectionId ) +{ + const auto mapLayers { context.project()->mapLayersByShortName( collectionId ) }; + if ( mapLayers.count() != 1 ) + { + throw QgsServerApiImproperlyConfiguredException( QStringLiteral( "Collection with given id (%1) was not found or multiple matches were found" ).arg( collectionId ) ); + } + return mapLayers.first(); +} + +json QgsServerOgcApiHandler::defaultResponse() +{ + static json defRes = + { + { + "default", { + { "description", "An error occurred." }, + { + "content", { + { + "application/json", { + { + "schema", { + { "$ref", "#/components/schemas/exception" } + } + }, + { + "text/html", { + { + "schema", { + { "type", "string" } + } + } + } + } + } + } + } + } + } + } + }; + return defRes; +} + +json QgsServerOgcApiHandler::jsonTags() const +{ + return QgsJsonUtils::jsonFromVariant( tags() ); +} diff --git a/src/server/qgsserverogcapihandler.h b/src/server/qgsserverogcapihandler.h new file mode 100644 index 00000000000..000e502b3b2 --- /dev/null +++ b/src/server/qgsserverogcapihandler.h @@ -0,0 +1,337 @@ +/*************************************************************************** + qgsserverogcapihandler.h - QgsServerOgcApiHandler + + --------------------- + begin : 10.7.2019 + copyright : (C) 2019 by Alessandro Pasotti + email : elpaso at itopen dot it + *************************************************************************** + * * + * 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 QGSSERVEROGCAPIHANDLER_H +#define QGSSERVEROGCAPIHANDLER_H + +#include +#include "qgis_server.h" +#include "qgsserverquerystringparameter.h" +#include "qgsserverogcapi.h" +#include "nlohmann/json_fwd.hpp" +#include "inja/inja.hpp" + +#ifndef SIP_RUN +using json = nlohmann::json; +#endif + +class QgsServerApiContext; + +/** + * \ingroup server + * The QgsServerOgcApiHandler abstract class represents a OGC API handler to be registered + * in QgsServerOgcApi class. + * + * Subclasses must override operational and informative methods and define + * the core functionality in handleRequest() method. + * + * The following methods MUST be implemented: + * - path + * - operationId + * - summary (shorter text) + * - description (longer text) + * - linkTitle + * - linkType + * - schema + * + * Optionally, override: + * - tags + * - parameters + * - contentTypes + * - defaultContentType + * + * + * \since QGIS 3.10 + */ +class SERVER_EXPORT QgsServerOgcApiHandler +{ + + public: + + virtual ~QgsServerOgcApiHandler(); + + // ///////////////////////////////////////////// + // MAIN Section (operational) + + /** + * URL pattern for this handler, named capture group are automatically + * extracted and returned by values() + * + * Example: "/handlername/(?P\d{2})/items" will capture "code1" as a + * named parameter. + * + * \see values() + */ + virtual QRegularExpression path() const = 0; + + //! Returns the operation id for template file names and other internal references + virtual std::string operationId() const = 0; + + /** + * Returns a list of query string parameters. + * + * Depending on the handler, it may be dynamic (per-request) or static. + * \param context the request context + */ + virtual QList parameters( const QgsServerApiContext &context ) const { Q_UNUSED( context ); return { }; } + + // ///////////////////////////////////////////// + // METADATA Sections (informative) + + //! Summary + virtual std::string summary() const = 0; + + //! Description + virtual std::string description() const = 0; + + //! Title for the handler link + virtual std::string linkTitle() const = 0; + + //! Main role for the resource link + virtual QgsServerOgcApi::Rel linkType() const = 0; + + //! Tags + virtual QStringList tags() const { return {}; } + + /** + * Returns the default response content type in case the client did not specifically + * ask for any particular content type. + */ + virtual QgsServerOgcApi::ContentType defaultContentType() const { return QgsServerOgcApi::ContentType::JSON; } + + /** + * Returns the list of content types this handler can serve, default to JSON and HTML. + * In case a specialized type (such as GEOJSON) is supported, + * the generic type (such as JSON) should not be listed. + */ + virtual QList contentTypes() const { return { QgsServerOgcApi::ContentType::JSON, QgsServerOgcApi::ContentType::HTML }; } + + /** + * Handles the request within its \a context + * + * Subclasses must implement this methods, and call validate() to + * extract validated parameters from the request. + * + * \throws QgsServerApiBadRequestError if the method encounters any error + */ + virtual void handleRequest( const QgsServerApiContext &context ) const = 0; + + /** + * Analyzes the incoming request \a context and returns the validated + * parameter map, throws QgsServerApiBadRequestError in case of errors. + * + * Path fragments from the named groups in the path() regular expression + * are also added to the map. + * + * Your handleRequest method should call this function to retrieve + * the parameters map. + * + * \returns the validated parameters map by extracting captured + * named parameters from the path (no validation is performed on + * the type because the regular expression can do it), + * and the query string parameters. + * + * \see path() + * \see parameters() + * \throws QgsServerApiBadRequestError if validation fails + */ + virtual QVariantMap values( const QgsServerApiContext &context ) const SIP_THROW( QgsServerApiBadRequestException ); + + /** + * Looks for the first ContentType match in the accept header and returns its mime type, + * returns an empty string if there are not matches. + */ + QString contentTypeForAccept( const QString &accept ) const; + + // ///////////////////////////////////////////////////// + // Utility methods: override should not be required + +#ifndef SIP_RUN // Skip SIP + + /** + * Writes \a data to the \a context response stream, content-type is calculated from the \a context request, + * optional \a htmlMetadata for the HTML templates can be specified and will be added as "metadata" to + * the HTML template variables. + * + * HTML output uses a template engine. + * + * Available template functions: + * See: https://github.com/pantor/inja#tutorial + * + * Available custom template functions: + * - path_append( path ): appends a directory path to the current url + * - path_chomp( n ):removes the specified number "n" of directory components from the current url path + * - json_dump( ): prints current JSON data passed to the template + * - static( path ): returns the full URL to the specified static path, for example: + * static( "/style/black.css" ) will return something like "/wfs3/static/style/black.css". + * - links_filter( links, key, value ): Returns filtered links from a link list + * - content_type_name( content_type ): Returns a short name from a content type for example "text/html" will return "HTML" + * + * \note not available in Python bindings + */ + void write( json &data, const QgsServerApiContext &context, const json &htmlMetadata = nullptr ) const; + + /** + * Writes \a data to the \a context response stream as JSON + * (indented if debug is active), an optional \a contentType can be specified. + * + * \note not available in Python bindings + */ + void jsonDump( json &data, const QgsServerApiContext &context, const QString &contentType = QStringLiteral( "application/json" ) ) const; + + /** + * Writes \a data as HTML to the response stream in \a context using a template. + * + * \see templatePath() + * \note not available in Python bindings + */ + void htmlDump( const json &data, const QgsServerApiContext &context ) const; + + /** + * Returns handler information from the \a context for the OPENAPI description (id, description and other metadata) as JSON. + * It may return a NULL JSON object in case the handler does not need to be included in the API. + * + * \note requires a valid project to be present in the context + * \note not available in Python bindings + */ + virtual json schema( const QgsServerApiContext &context ) const; + + /** + * Builds and returns a link to the resource. + * + * \param context request context + * \param linkType type of the link (rel attribute), default to SELF + * \param contentType content type of the link (default to JSON) + * \param title title of the link + * \note not available in Python bindings + */ + json link( const QgsServerApiContext &context, + const QgsServerOgcApi::Rel &linkType = QgsServerOgcApi::Rel::self, + const QgsServerOgcApi::ContentType contentType = QgsServerOgcApi::ContentType::JSON, + const std::string &title = "" ) const; + + /** + * Returns all the links for the given request \a context. + * + * The base implementation returns the alternate and self links, subclasses may + * add other links. + * + * \note not available in Python bindings + */ + json links( const QgsServerApiContext &context ) const; + + + /** + * Returns a vector layer instance from the "collectionId" parameter of the path in the given \a context, + * requires a valid project instance in the context. + * + * \note not available in Python bindings + * + * \throws QgsServerApiNotFoundError if the layer could not be found + * \throws QgsServerApiImproperlyConfiguredException if project is not set + */ + QgsVectorLayer *layerFromContext( const QgsServerApiContext &context ) const; + +#endif // SIP skipped + + /** + * Writes \a data to the \a context response stream, content-type is calculated from the \a context request, + * optional \a htmlMetadata for the HTML templates can be specified and will be added as "metadata" to + * the HTML template variables. + * + * HTML output uses a template engine. + * + * Available template functions: + * See: https://github.com/pantor/inja#tutorial + * + * Available custom template functions: + * - path_append( path ): appends a directory path to the current url + * - path_chomp( n ): removes the specified number "n" of directory components from the current url path + * - json_dump(): prints current JSON data passed to the template + * - static( path): returns the full URL to the specified static path, for example: + * static("/style/black.css") will return something like "/wfs3/static/style/black.css". + * - links_filter( links, key, value ): returns filtered links from a link list + * - content_type_name( content_type ): returns a short name from a content type for example "text/html" will return "HTML" + * + */ + void write( QVariant &data, const QgsServerApiContext &context, const QVariantMap &htmlMetadata = QVariantMap() ) const; + + /** + * Returns an URL to self, to be used for links to the current resources and as a base for constructing links to sub-resources + * + * \param context the current request context + * \param extraPath an optional extra path that will be appended to the calculated URL + * \param extension optional file extension to add (the dot will be added automatically). + */ + std::string href( const QgsServerApiContext &context, const QString &extraPath = QString(), const QString &extension = QString() ) const; + + /** + * Returns the HTML template path for the handler in the given \a context + * + * The template path is calculated from QgsServerSettings's apiResourcesDirectory() as follow: + * apiResourcesDirectory() + "/ogc/templates/" + context.apiRootPath + operationId + ".html" + * e.g. for an API with root path "/wfs3" and an handler with operationId "collectionItems", the path + * will be apiResourcesDirectory() + "/ogc/templates/wfs3/collectionItems.html" + */ + const QString templatePath( const QgsServerApiContext &context ) const; + + /** + * Returns the absolute path to the base directory where static resources for + * this handler are stored in the given \a context. + * + */ + const QString staticPath( const QgsServerApiContext &context ) const; + + /** + * Returns the content type from the \a request. + * + * The path file extension is examined first and checked for known mime types, + * the "Accept" HTTP header is examined next. + * Fallback to the default content type of the handler if none of the above matches. + * + * \throws QgsServerApiBadRequestError if the content type of the request is not compatible with the handler (\see contentTypes member) + */ + QgsServerOgcApi::ContentType contentTypeFromRequest( const QgsServerRequest *request ) const; + + /** + * Returns a link to the parent page up to \a levels in the HTML hierarchy from the given \a url, MAP query argument is preserved + */ + static QString parentLink( const QUrl &url, int levels = 1 ); + + /** + * Returns a vector layer from the \a collectionId in the given \a context + */ + static QgsVectorLayer *layerFromCollection( const QgsServerApiContext &context, const QString &collectionId ); + + /** + * Returns the defaultResponse as JSON + * + * \note not available in Python bindings + */ + static json defaultResponse() SIP_SKIP; + + /** + * Returns tags as JSON + * + * \see tags() + * + * \note not available in Python bindings + */ + json jsonTags( ) const SIP_SKIP; + + +}; + +#endif // QGSSERVEROGCAPIHANDLER_H diff --git a/src/server/qgsserverquerystringparameter.cpp b/src/server/qgsserverquerystringparameter.cpp new file mode 100644 index 00000000000..1e1392afdfe --- /dev/null +++ b/src/server/qgsserverquerystringparameter.cpp @@ -0,0 +1,158 @@ +/*************************************************************************** + qgsserverquerystringparameter.cpp - QgsServerQueryStringParameter + + --------------------- + begin : 10.7.2019 + copyright : (C) 2019 by Alessandro Pasotti + email : elpaso at itopen dot it + *************************************************************************** + * * + * 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 "qgsserverquerystringparameter.h" +#include "qgsserverrequest.h" +#include "qgsserverexception.h" +#include "nlohmann/json.hpp" + +QgsServerQueryStringParameter::QgsServerQueryStringParameter( const QString name, + bool required, + QgsServerQueryStringParameter::Type type, + const QString &description, + const QVariant &defaultValue ): + mName( name ), + mRequired( required ), + mType( type ), + mDescription( description ), + mDefaultValue( defaultValue ) +{ +} + +QgsServerQueryStringParameter::~QgsServerQueryStringParameter() +{ + +} + +QVariant QgsServerQueryStringParameter::value( const QgsServerApiContext &context ) const +{ + + // 1: check required + if ( mRequired && ! context.request()->url().hasQueryItem( mName ) ) + { + throw QgsServerApiBadRequestException( QStringLiteral( "Missing required argument: '%1'" ).arg( mName ) ); + } + + // 2: get value from query string or set it to the default + QVariant value; + if ( context.request()->url().hasQueryItem( mName ) ) + { + value = QUrlQuery( context.request()->url() ).queryItemValue( mName, QUrl::FullyDecoded ); + } + else if ( mDefaultValue.isValid() ) + { + value = mDefaultValue; + } + + if ( value.isValid() ) + { + + // 3: check type + const QVariant::Type targetType { static_cast< QVariant::Type >( mType )}; + // Handle csv list type + if ( mType == Type::List ) + { + value = value.toString().split( ',' ); + } + if ( value.type() != targetType ) + { + bool ok = false; + if ( value.canConvert( static_cast( targetType ) ) ) + { + ok = true; + switch ( mType ) + { + case Type::String: + value = value.toString( ); + break; + case Type::Boolean: + value = value.toBool( ); + break; + case Type::Double: + value = value.toDouble( &ok ); + break; + case Type::Integer: + value = value.toLongLong( &ok ); + break; + case Type::List: + // already converted to a string list + break; + } + } + + if ( ! ok ) + { + throw QgsServerApiBadRequestException( QStringLiteral( "Argument '%1' could not be converted to %2" ).arg( mName ) + .arg( typeName( mType ) ) ); + } + } + + // 4: check custom validation + if ( mCustomValidator && ! mCustomValidator( context, value ) ) + { + throw QgsServerApiBadRequestException( QStringLiteral( "Argument '%1' is not valid. %2" ).arg( name() ).arg( description() ) ); + } + } + return value; +} + +void QgsServerQueryStringParameter::setCustomValidator( const customValidator &customValidator ) +{ + mCustomValidator = customValidator; +} + +json QgsServerQueryStringParameter::data() const +{ + const auto nameString { name().toStdString() }; + auto dataType { typeName( mType ).toLower().toStdString() }; + // Map list to string because it will be serialized + if ( dataType == "list" ) + { + dataType = "string"; + } + return + { + { "name", nameString }, + { "description", "Filter the collection by '" + nameString + "'" }, + { "required", mRequired }, + { "in", "query"}, + { "style", "form"}, + { "explode", false }, + { "schema", {{ "type", dataType }}}, + // This is unfortunately not in OAS: { "default", mDefaultValue.toString().toStdString() } + }; +} + +QString QgsServerQueryStringParameter::description() const +{ + return mDescription; +} + +QString QgsServerQueryStringParameter::typeName( const QgsServerQueryStringParameter::Type type ) +{ + static QMetaEnum metaEnum = QMetaEnum::fromType(); + return metaEnum.valueToKey( static_cast( type ) ); +} + +QString QgsServerQueryStringParameter::name() const +{ + return mName; +} + +void QgsServerQueryStringParameter::setDescription( const QString &description ) +{ + mDescription = description; +} diff --git a/src/server/qgsserverquerystringparameter.h b/src/server/qgsserverquerystringparameter.h new file mode 100644 index 00000000000..f027446d452 --- /dev/null +++ b/src/server/qgsserverquerystringparameter.h @@ -0,0 +1,155 @@ +/*************************************************************************** + qgsserverquerystringparameter.h - QgsServerQueryStringParameter + + --------------------- + begin : 10.7.2019 + copyright : (C) 2019 by Alessandro Pasotti + email : elpaso at itopen dot it + *************************************************************************** + * * + * 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 QGSSERVERQUERYSTRINGPARAMETER_H +#define QGSSERVERQUERYSTRINGPARAMETER_H + +#include "qgsserverapicontext.h" +#include "qgis_server.h" +#include "qgis_sip.h" +#include +#include +#include + + +#include "nlohmann/json_fwd.hpp" + +#ifndef SIP_RUN +using json = nlohmann::json; +#endif + + +class QgsServerApiBadRequestException; + + +/** + * The QgsServerQueryStringParameter class holds the information regarding + * a query string input parameter and its validation. + * + * The class is extendable through custom validators (C++ only) and/or by + * subclassing and overriding the value() method. + * + * \ingroup server + * \since QGIS 3.10 + */ +class SERVER_EXPORT QgsServerQueryStringParameter +{ + + Q_GADGET + +#ifndef SIP_RUN + typedef std::function< bool ( const QgsServerApiContext &, QVariant & ) > customValidator; +#endif + public: + + /** + * The Type enum represents the parameter type + */ + enum class Type + { + String = QVariant::String, //! parameter is a string + Integer = QVariant::LongLong, //! parameter is an integer + Double = QVariant::Double, //! parameter is a double + Boolean = QVariant::Bool, //! parameter is a boolean + List = QVariant::StringList, //! parameter is a (comma separated) list of strings, the handler will perform any further required conversion of the list values + }; + Q_ENUM( Type ) + + + /** + * Constructs a QgsServerQueryStringParameter object. + * + * \param name parameter name + * \param required + * \param type the parameter type + * \param description parameter description + * \param defaultValue default value, it is ignored if the parameter is required + */ + QgsServerQueryStringParameter( const QString name, + bool required = false, + Type type = QgsServerQueryStringParameter::Type::String, + const QString &description = QString(), + const QVariant &defaultValue = QVariant() ); + + virtual ~QgsServerQueryStringParameter(); + + /** + * Extracts the value from the request \a context by validating the parameter + * value and converting it to its proper Type. + * If the value is not set and a default was not provided an invalid QVariant is returned. + * + * Validation steps: + * - required + * - can convert to proper Type + * - custom validator (if set - not available in Python bindings) + * + * \see setCustomValidator() (not available in Python bindings) + * \returns the parameter value or an invalid QVariant if not found (and not required) + * \throws QgsServerApiBadRequestError if validation fails + */ + virtual QVariant value( const QgsServerApiContext &context ) const; + +#ifndef SIP_RUN + + /** + * Sets the custom validation function to \a customValidator. + * Validator function signature is: + * bool ( const QgsServerApiContext &context, QVariant &value ) + * \note a validator can change the value if needed and must return TRUE if the validation passed + * \note not available in Python bindings + */ + void setCustomValidator( const customValidator &customValidator ); + + /** + * Returns the handler information as a JSON object. + */ + json data( ) const; + +#endif + + /** + * Returns parameter description + */ + QString description() const; + + /** + * Returns the name of the \a type + */ + static QString typeName( const Type type ); + + /** + * Returns the name of the parameter + */ + QString name() const; + + /** + * Sets validator \a description + */ + void setDescription( const QString &description ); + + private: + + QString mName; + bool mRequired = false; + Type mType = Type::String; + customValidator mCustomValidator = nullptr; + QString mDescription; + QVariant mDefaultValue; + + friend class TestQgsServerQueryStringParameter; + +}; + +#endif // QGSSERVERQUERYSTRINGPARAMETER_H diff --git a/src/server/qgsserverrequest.cpp b/src/server/qgsserverrequest.cpp index 25ccde82ac7..bf30ac6ff10 100644 --- a/src/server/qgsserverrequest.cpp +++ b/src/server/qgsserverrequest.cpp @@ -97,9 +97,14 @@ void QgsServerRequest::setParameter( const QString &key, const QString &value ) mUrl.setQuery( mParams.urlQuery() ); } -QString QgsServerRequest::parameter( const QString &key ) const +QString QgsServerRequest::parameter( const QString &key, const QString &defaultValue ) const { - return mParams.value( key ); + const auto value { mParams.value( key ) }; + if ( value.isEmpty() ) + { + return defaultValue; + } + return value; } void QgsServerRequest::removeParameter( const QString &key ) @@ -119,3 +124,13 @@ void QgsServerRequest::setMethod( Method method ) { mMethod = method; } + +const QString QgsServerRequest::queryParameter( const QString &name, const QString &defaultValue ) const +{ + if ( ! mUrl.hasQueryItem( name ) ) + { + return defaultValue; + } + return QUrl::fromPercentEncoding( mUrl.queryItemValue( name ).toUtf8() ); +} + diff --git a/src/server/qgsserverrequest.h b/src/server/qgsserverrequest.h index fdf834028b6..72fdd186f6a 100644 --- a/src/server/qgsserverrequest.h +++ b/src/server/qgsserverrequest.h @@ -113,7 +113,7 @@ class SERVER_EXPORT QgsServerRequest /** * Gets a parameter value */ - QString parameter( const QString &key ) const; + QString parameter( const QString &key, const QString &defaultValue = QString() ) const; /** * Remove a parameter @@ -172,6 +172,12 @@ class SERVER_EXPORT QgsServerRequest */ void setMethod( QgsServerRequest::Method method ); + /** + * Returns the query string parameter with the given \a name from the request URL, a \a defaultValue can be specified. + * \since QGIS 3.10 + */ + const QString queryParameter( const QString &name, const QString &defaultValue = QString( ) ) const; + protected: /** diff --git a/src/server/qgsserverresponse.cpp b/src/server/qgsserverresponse.cpp index d7263700853..8d803a8be63 100644 --- a/src/server/qgsserverresponse.cpp +++ b/src/server/qgsserverresponse.cpp @@ -65,6 +65,11 @@ qint64 QgsServerResponse::write( const char *data ) return 0; } +qint64 QgsServerResponse::write( const std::string data ) +{ + return write( data.c_str() ); +} + void QgsServerResponse::write( const QgsServerException &ex ) { QString responseFormat; diff --git a/src/server/qgsserverresponse.h b/src/server/qgsserverresponse.h index 97282b5ae04..424f2350eca 100644 --- a/src/server/qgsserverresponse.h +++ b/src/server/qgsserverresponse.h @@ -137,6 +137,17 @@ class SERVER_EXPORT QgsServerResponse */ virtual qint64 write( const char *data ) SIP_SKIP; + /** + * Writes at most maxSize bytes of data + * + * This is a convenient method that will write directly + * to the underlying I/O device + * \returns the number of bytes written + * \note not available in Python bindings + * \since QGIS 3.10 + */ + virtual qint64 write( std::string data ) SIP_SKIP; + /** * Write server exception */ diff --git a/src/server/qgsserversettings.cpp b/src/server/qgsserversettings.cpp index 78524cf01db..f5811be3492 100644 --- a/src/server/qgsserversettings.cpp +++ b/src/server/qgsserversettings.cpp @@ -20,6 +20,7 @@ #include "qgsapplication.h" #include +#include QgsServerSettings::QgsServerSettings() { @@ -183,6 +184,30 @@ void QgsServerSettings::initSettings() QVariant() }; mSettings[ sMaxWidth.envVar ] = sMaxWidth; + + // API templates and static override directory + const Setting sApiResourcesDirectory = { QgsServerSettingsEnv::QGIS_SERVER_API_RESOURCES_DIRECTORY, + QgsServerSettingsEnv::DEFAULT_VALUE, + QStringLiteral( "Base directory where HTML templates and static assets (e.g. images, js and css files) are searched for" ), + QStringLiteral( "/qgis/server_api_resources_directory" ), + QVariant::String, + QDir( QgsApplication::pkgDataPath() ).absoluteFilePath( QStringLiteral( "resources/server/api" ) ), + QString() + }; + + mSettings[ sApiResourcesDirectory.envVar ] = sApiResourcesDirectory; + + // API WFS3 max limit + const Setting sApiWfs3MaxLimit = { QgsServerSettingsEnv::QGIS_SERVER_API_WFS3_MAX_LIMIT, + QgsServerSettingsEnv::DEFAULT_VALUE, + QStringLiteral( "Maximum value for \"limit\" in a features request, defaults to 10000" ), + QStringLiteral( "/qgis/server_api_wfs3_max_limit" ), + QVariant::LongLong, + QVariant( 10000 ), + QVariant() + }; + + mSettings[ sApiWfs3MaxLimit.envVar ] = sApiWfs3MaxLimit; } void QgsServerSettings::load() @@ -383,3 +408,13 @@ int QgsServerSettings::wmsMaxWidth() const { return value( QgsServerSettingsEnv::QGIS_SERVER_WMS_MAX_WIDTH ).toInt(); } + +QString QgsServerSettings::apiResourcesDirectory() const +{ + return value( QgsServerSettingsEnv::QGIS_SERVER_API_RESOURCES_DIRECTORY ).toString(); +} + +qlonglong QgsServerSettings::apiWfs3MaxLimit() const +{ + return value( QgsServerSettingsEnv::QGIS_SERVER_API_WFS3_MAX_LIMIT ).toLongLong(); +} diff --git a/src/server/qgsserversettings.h b/src/server/qgsserversettings.h index b2e12908e8b..344f481b383 100644 --- a/src/server/qgsserversettings.h +++ b/src/server/qgsserversettings.h @@ -64,7 +64,9 @@ class SERVER_EXPORT QgsServerSettingsEnv : public QObject QGIS_SERVER_SHOW_GROUP_SEPARATOR, //! Show group (thousands) separator when formatting numeric values, defaults to FALSE (since QGIS 3.8) QGIS_SERVER_OVERRIDE_SYSTEM_LOCALE, //! Override system locale (since QGIS 3.8) QGIS_SERVER_WMS_MAX_HEIGHT, //! Maximum height for a WMS request. The most conservative between this and the project one is used (since QGIS 3.8) - QGIS_SERVER_WMS_MAX_WIDTH //! Maximum width for a WMS request. The most conservative between this and the project one is used (since QGIS 3.8) + QGIS_SERVER_WMS_MAX_WIDTH, //! Maximum width for a WMS request. The most conservative between this and the project one is used (since QGIS 3.8) + QGIS_SERVER_API_RESOURCES_DIRECTORY, //! Base directory where HTML templates and static assets (e.g. images, js and css files) are searched for (since QGIS 3.10). + QGIS_SERVER_API_WFS3_MAX_LIMIT //! Maximum value for "limit" in a features request, defaults to 10000 (since QGIS 3.10). }; Q_ENUM( EnvVar ) }; @@ -200,6 +202,26 @@ class SERVER_EXPORT QgsServerSettings */ int wmsMaxWidth() const; + /** + * Returns the server-wide base directory where HTML templates and static assets (e.g. images, js and css files) are searched for. + * + * The default path is calculated by joining QgsApplication::pkgDataPath() with "resources/server/api", this path + * can be changed by setting the environment variable QGIS_SERVER_API_RESOURCES_DIRECTORY. + * + * \since QGIS 3.10 + */ + QString apiResourcesDirectory() const; + + /** + * Returns the server-wide maximum allowed value for \"limit\" in a features request. + * + * The default value is 10000, this value can be changed by setting the environment + * variable QGIS_SERVER_API_WFS3_MAX_LIMIT. + * + * \since QGIS 3.10 + */ + qlonglong apiWfs3MaxLimit() const; + private: void initSettings(); QVariant value( QgsServerSettingsEnv::EnvVar envVar ) const; diff --git a/src/server/qgsserviceregistry.cpp b/src/server/qgsserviceregistry.cpp index a57ead7cf41..8321350c325 100644 --- a/src/server/qgsserviceregistry.cpp +++ b/src/server/qgsserviceregistry.cpp @@ -19,6 +19,7 @@ #include "qgsserviceregistry.h" #include "qgsservice.h" +#include "qgsserverapi.h" #include "qgsmessagelog.h" #include @@ -91,8 +92,8 @@ QgsService *QgsServiceRegistry::getService( const QString &name, const QString & QString key; // Check that we have a service of that name - VersionTable::const_iterator v = mVersions.constFind( name ); - if ( v != mVersions.constEnd() ) + VersionTable::const_iterator v = mServiceVersions.constFind( name ); + if ( v != mServiceVersions.constEnd() ) { key = version.isEmpty() ? v->second : makeServiceKey( name, version ); ServiceTable::const_iterator it = mServices.constFind( key ); @@ -102,7 +103,7 @@ QgsService *QgsServiceRegistry::getService( const QString &name, const QString & } else { - // Return the dofault version + // Return the default version QgsMessageLog::logMessage( QString( "Service %1 %2 not found, returning default" ).arg( name, version ) ); service = mServices[v->second].get(); } @@ -123,11 +124,11 @@ void QgsServiceRegistry::registerService( QgsService *service ) QString key = makeServiceKey( name, version ); if ( mServices.constFind( key ) != mServices.constEnd() ) { - QgsMessageLog::logMessage( QString( "Error Service %1 %2 is already registered" ).arg( name, version ) ); + QgsMessageLog::logMessage( QStringLiteral( "Error Service %1 %2 is already registered" ).arg( name, version ) ); return; } - QgsMessageLog::logMessage( QString( "Adding service %1 %2" ).arg( name, version ) ); + QgsMessageLog::logMessage( QStringLiteral( "Adding service %1 %2" ).arg( name, version ) ); mServices.insert( key, std::shared_ptr( service ) ); // Check the default version @@ -135,11 +136,11 @@ void QgsServiceRegistry::registerService( QgsService *service ) // is the default one. // this will ensure that native services are always // the defaults. - VersionTable::const_iterator v = mVersions.constFind( name ); - if ( v == mVersions.constEnd() ) + VersionTable::const_iterator v = mServiceVersions.constFind( name ); + if ( v == mServiceVersions.constEnd() ) { // Insert the service as the default one - mVersions.insert( name, VersionTable::mapped_type( version, key ) ); + mServiceVersions.insert( name, VersionTable::mapped_type( version, key ) ); } /* if ( v != mVersions.constEnd() ) @@ -158,12 +159,121 @@ void QgsServiceRegistry::registerService( QgsService *service ) } +int QgsServiceRegistry::unregisterApi( const QString &name, const QString &version ) +{ + + // Check that we have an API of that name + int removed = 0; + VersionTable::const_iterator v = mApiVersions.constFind( name ); + if ( v != mApiVersions.constEnd() ) + { + if ( version.isEmpty() ) + { + // No version specified, remove all versions + ApiTable::iterator it = mApis.begin(); + while ( it != mApis.end() ) + { + if ( ( *it )->name() == name ) + { + QgsMessageLog::logMessage( QString( "Unregistering API %1 %2" ).arg( name, ( *it )->version() ) ); + it = mApis.erase( it ); + ++removed; + } + else + { + ++it; + } + } + // Remove from version table + mApiVersions.remove( name ); + } + else + { + const QString key = makeServiceKey( name, version ); + ApiTable::iterator found = mApis.find( key ); + if ( found != mApis.end() ) + { + QgsMessageLog::logMessage( QString( "Unregistering API %1 %2" ).arg( name, version ) ); + mApis.erase( found ); + removed = 1; + + // Find if we have other services of that name + // but with different version + // + QString maxVer; + std::function < void ( const ApiTable::mapped_type & ) > + findGreaterVersion = [name, &maxVer]( const ApiTable::mapped_type & api ) + { + if ( api->name() == name && + ( maxVer.isEmpty() || isVersionGreater( api->version(), maxVer ) ) ) + maxVer = api->version(); + }; + + mApiVersions.remove( name ); + + std::for_each( mApis.constBegin(), mApis.constEnd(), findGreaterVersion ); + if ( !maxVer.isEmpty() ) + { + // Set the new default service + const QString key = makeServiceKey( name, maxVer ); + mApiVersions.insert( name, VersionTable::mapped_type( version, key ) ); + } + } + } + } + return removed; +} + +QgsServerApi *QgsServiceRegistry::apiForRequest( const QgsServerRequest &request ) const +{ + for ( const auto &api : mApis ) + { + QgsMessageLog::logMessage( QStringLiteral( "Trying URL path: %1 for %2" ).arg( request.url().path(), api->rootPath() ), QStringLiteral( "Server" ), Qgis::Info ); + if ( api->accept( request.url() ) ) + { + Q_ASSERT( !api->name().isEmpty() ); + QgsMessageLog::logMessage( QStringLiteral( "API %1 accepts the URL path %2 " ).arg( api->name(), request.url().path() ), QStringLiteral( "Server" ), Qgis::Info ); + return api.get(); + } + } + return nullptr; +} + +QgsServerApi *QgsServiceRegistry::getApi( const QString &name, const QString &version ) +{ + QgsServerApi *api = nullptr; + QString key; + + // Check that we have an API of that name + VersionTable::const_iterator v = mApiVersions.constFind( name ); + if ( v != mApiVersions.constEnd() ) + { + key = version.isEmpty() ? v->second : makeServiceKey( name, version ); + ApiTable::const_iterator it = mApis.constFind( key ); + if ( it != mApis.constEnd() ) + { + api = it->get(); + } + else + { + // Return the default version + QgsMessageLog::logMessage( QString( "API %1 %2 not found, returning default" ).arg( name, version ) ); + api = mApis[v->second].get(); + } + } + else + { + QgsMessageLog::logMessage( QString( "API %1 is not registered" ).arg( name ) ); + } + return api; +} + int QgsServiceRegistry::unregisterService( const QString &name, const QString &version ) { // Check that we have a service of that name int removed = 0; - VersionTable::const_iterator v = mVersions.constFind( name ); - if ( v != mVersions.constEnd() ) + VersionTable::const_iterator v = mServiceVersions.constFind( name ); + if ( v != mServiceVersions.constEnd() ) { if ( version.isEmpty() ) { @@ -183,7 +293,7 @@ int QgsServiceRegistry::unregisterService( const QString &name, const QString &v } } // Remove from version table - mVersions.remove( name ); + mServiceVersions.remove( name ); } else { @@ -207,14 +317,14 @@ int QgsServiceRegistry::unregisterService( const QString &name, const QString &v maxVer = service->version(); }; - mVersions.remove( name ); + mServiceVersions.remove( name ); std::for_each( mServices.constBegin(), mServices.constEnd(), findGreaterVersion ); if ( !maxVer.isEmpty() ) { // Set the new default service QString key = makeServiceKey( name, maxVer ); - mVersions.insert( name, VersionTable::mapped_type( version, key ) ); + mServiceVersions.insert( name, VersionTable::mapped_type( version, key ) ); } } } @@ -230,9 +340,39 @@ void QgsServiceRegistry::init( const QString &nativeModulePath, QgsServerInterfa void QgsServiceRegistry::cleanUp() { // Release all services - mVersions.clear(); + mServiceVersions.clear(); mServices.clear(); + mApis.clear(); mNativeLoader.unloadModules(); } +bool QgsServiceRegistry::registerApi( QgsServerApi *api ) +{ + const QString name = api->name(); + const QString version = api->version(); + + // Test if service is already registered + const QString key = makeServiceKey( name, version ); + if ( mApis.constFind( key ) != mApis.constEnd() ) + { + QgsMessageLog::logMessage( QStringLiteral( "Error API %1 %2 is already registered" ).arg( name, version ) ); + return false; + } + + QgsMessageLog::logMessage( QStringLiteral( "Adding API %1 %2" ).arg( name, version ) ); + mApis.insert( key, std::shared_ptr( api ) ); + + // Check the default version + // The first inserted service of a given name + // is the default one. + // this will ensure that native services are always + // the defaults. + VersionTable::const_iterator v = mApiVersions.constFind( name ); + if ( v == mApiVersions.constEnd() ) + { + // Insert the service as the default one + mApiVersions.insert( name, VersionTable::mapped_type( version, key ) ); + } + return true; +} diff --git a/src/server/qgsserviceregistry.h b/src/server/qgsserviceregistry.h index 1641cb22c7a..fd94f6e1c7e 100644 --- a/src/server/qgsserviceregistry.h +++ b/src/server/qgsserviceregistry.h @@ -29,6 +29,8 @@ #include class QgsService; +class QgsServerRequest; +class QgsServerApi; class QgsServerInterface; /** @@ -71,16 +73,55 @@ class SERVER_EXPORT QgsServiceRegistry * This method is intended to be called by modules for registering * services. A module may register multiple services. * - * The registry gain ownership of services and will call 'delete' on cleanup + * The registry takes ownership of services and will call 'delete' on cleanup * * \param service a QgsService to be registered */ void registerService( QgsService *service SIP_TRANSFER ); + /** + * Registers the QgsServerApi \a api + * + * The registry takes ownership of services and will call 'delete' on cleanup + * \since QGIS 3.10 + */ + bool registerApi( QgsServerApi *api SIP_TRANSFER ); + + /** + * Unregisters API from its name and version + * + * \param name the name of the service + * \param version (optional) the specific version to unload + * \returns the number of APIs unregistered + * + * If the version is not specified then all versions from the specified API + * are unloaded + * \since QGIS 3.10 + */ + int unregisterApi( const QString &name, const QString &version = QString() ); + + /** + * Searches the API register for an API matching the \a request and returns a (possibly NULL) pointer to it. + * \since QGIS 3.10 + */ + QgsServerApi *apiForRequest( const QgsServerRequest &request ) const SIP_SKIP; + + /** + * Retrieves an API from its name + * + * If the version is not provided the higher version of the service is returned + * + * \param name the name of the API + * \param version the version string (optional) + * \returns QgsServerApi + * \since QGIS 3.10 + */ + QgsServerApi *getApi( const QString &name, const QString &version = QString() ); + /** * Unregister service from its name and version * - * \param name the tame of the service + * \param name the name of the service * \param version (optional) the specific version to unload * \returns the number of services unregistered * @@ -102,15 +143,20 @@ class SERVER_EXPORT QgsServiceRegistry void cleanUp(); private: + // XXX consider using QMap because of the few numbers of // elements to handle typedef QHash > ServiceTable; + typedef QHash > ApiTable; typedef QHash > VersionTable; QgsServiceNativeLoader mNativeLoader; ServiceTable mServices; - VersionTable mVersions; + VersionTable mServiceVersions; + ApiTable mApis; + VersionTable mApiVersions; + }; #endif diff --git a/src/server/services/CMakeLists.txt b/src/server/services/CMakeLists.txt index c48165eeb76..f51d9fb9645 100644 --- a/src/server/services/CMakeLists.txt +++ b/src/server/services/CMakeLists.txt @@ -9,6 +9,7 @@ SET (CMAKE_LIBRARY_OUTPUT_DIRECTORY ${QGIS_OUTPUT_DIRECTORY}/${QGIS_SERVER_MODUL ADD_SUBDIRECTORY(DummyService) ADD_SUBDIRECTORY(wms) ADD_SUBDIRECTORY(wfs) +ADD_SUBDIRECTORY(wfs3) ADD_SUBDIRECTORY(wcs) ADD_SUBDIRECTORY(wmts) diff --git a/src/server/services/DummyService/CMakeLists.txt b/src/server/services/DummyService/CMakeLists.txt index 8d9dd81d7cd..b1446255cfa 100644 --- a/src/server/services/DummyService/CMakeLists.txt +++ b/src/server/services/DummyService/CMakeLists.txt @@ -13,6 +13,7 @@ ADD_LIBRARY (dummy MODULE ${dummy_SRCS}) INCLUDE_DIRECTORIES( + ${CMAKE_SOURCE_DIR}/external ${CMAKE_BINARY_DIR}/src/core ${CMAKE_BINARY_DIR}/src/gui ${CMAKE_BINARY_DIR}/src/python diff --git a/src/server/services/qgsmodule.h b/src/server/services/qgsmodule.h index 6cec51ed19a..5bc32ef0ee7 100644 --- a/src/server/services/qgsmodule.h +++ b/src/server/services/qgsmodule.h @@ -20,6 +20,7 @@ #include "qgsservicemodule.h" #include "qgsserviceregistry.h" #include "qgsservice.h" +#include "qgsserverapi.h" #include "qgsserverinterface.h" #include "qgslogger.h" #include "qgsmessagelog.h" diff --git a/src/server/services/wcs/CMakeLists.txt b/src/server/services/wcs/CMakeLists.txt index 7b253d91396..fb3bf645d82 100644 --- a/src/server/services/wcs/CMakeLists.txt +++ b/src/server/services/wcs/CMakeLists.txt @@ -22,6 +22,7 @@ INCLUDE_DIRECTORIES(SYSTEM ) INCLUDE_DIRECTORIES( + ${CMAKE_SOURCE_DIR}/external ${CMAKE_BINARY_DIR}/src/core ${CMAKE_BINARY_DIR}/src/python ${CMAKE_BINARY_DIR}/src/analysis diff --git a/src/server/services/wfs/CMakeLists.txt b/src/server/services/wfs/CMakeLists.txt index 1ca1d6400e3..7909bad12df 100644 --- a/src/server/services/wfs/CMakeLists.txt +++ b/src/server/services/wfs/CMakeLists.txt @@ -32,6 +32,7 @@ INCLUDE_DIRECTORIES(SYSTEM ) INCLUDE_DIRECTORIES( + ${CMAKE_SOURCE_DIR}/external ${CMAKE_BINARY_DIR}/src/core ${CMAKE_BINARY_DIR}/src/python ${CMAKE_BINARY_DIR}/src/analysis diff --git a/src/server/services/wfs3/CMakeLists.txt b/src/server/services/wfs3/CMakeLists.txt new file mode 100644 index 00000000000..2bb975ffafc --- /dev/null +++ b/src/server/services/wfs3/CMakeLists.txt @@ -0,0 +1,62 @@ +######################################################## +# Files + +SET (wfs3_SRCS + ${CMAKE_SOURCE_DIR}/external/nlohmann/json.hpp + ${CMAKE_SOURCE_DIR}/external/inja/inja.hpp + qgswfs3.cpp + qgswfs3handlers.cpp +) + +SET (wfs3_MOC_HDRS +) + +######################################################## +# Build + +QT5_WRAP_CPP(wfs3_MOC_SRCS ${wfs3_MOC_HDRS}) + +ADD_LIBRARY (wfs3 MODULE ${wfs3_SRCS} ${wfs3_MOC_SRCS} ${wfs3_MOC_HDRS}) + + +INCLUDE_DIRECTORIES(SYSTEM + ${GDAL_INCLUDE_DIR} + ${POSTGRES_INCLUDE_DIR} +) + +INCLUDE_DIRECTORIES( + ${CMAKE_SOURCE_DIR}/external + ${CMAKE_BINARY_DIR}/src/core + ${CMAKE_BINARY_DIR}/src/python + ${CMAKE_BINARY_DIR}/src/analysis + ${CMAKE_BINARY_DIR}/src/server + ${CMAKE_CURRENT_BINARY_DIR} + ../../../core + ../../../core/dxf + ../../../core/expression + ../../../core/geometry + ../../../core/metadata + ../../../core/raster + ../../../core/symbology + ../../../core/layertree + ../../../core/fieldformatter + ../.. + .. + . +) + + +TARGET_LINK_LIBRARIES(wfs3 + qgis_core + qgis_server +) + + +######################################################## +# Install + +INSTALL(TARGETS wfs3 + RUNTIME DESTINATION ${QGIS_SERVER_MODULE_DIR} + LIBRARY DESTINATION ${QGIS_SERVER_MODULE_DIR} +) + diff --git a/src/server/services/wfs3/openapi.json b/src/server/services/wfs3/openapi.json new file mode 100644 index 00000000000..b90ac146fad --- /dev/null +++ b/src/server/services/wfs3/openapi.json @@ -0,0 +1,663 @@ +{ + "openapi" : "3.0.1", + "info" : { + "title" : "A sample API conforming to the OGC Web Feature Service standard", + "description" : "This is a sample OpenAPI definition that conforms to the OGC Web Feature Service specification (conformance classes: \"Core\", \"GeoJSON\", \"HTML\" and \"OpenAPI 3.0\").", + "contact" : { + "name" : "Acme Corporation", + "url" : "http://example.org/", + "email" : "info@example.org" + }, + "license" : { + "name" : "CC-BY 4.0 license", + "url" : "https://creativecommons.org/licenses/by/4.0/" + }, + "version" : "M1" + }, + "servers" : [ { + "url" : "https://dev.example.org/", + "description" : "Development server" + }, { + "url" : "https://data.example.org/", + "description" : "Production server" + } ], + "tags" : [ { + "name" : "Capabilities", + "description" : "Essential characteristics of this API including information about the data." + }, { + "name" : "Features", + "description" : "Access to data (features)." + } ], + "paths" : { + "/" : { + "get" : { + "tags" : [ "Capabilities" ], + "summary" : "landing page of this API", + "description" : "The landing page provides links to the API definition, the Conformance statements and the metadata about the feature data in this dataset.", + "operationId" : "getLandingPage", + "responses" : { + "200" : { + "description" : "links to the API capabilities", + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/root" + } + }, + "text/html" : { + "schema" : { + "type" : "string" + } + } + } + } + } + } + }, + "/conformance" : { + "get" : { + "tags" : [ "Capabilities" ], + "summary" : "information about standards that this API conforms to", + "description" : "list all requirements classes specified in a standard (e.g., WFS 3.0 Part 1: Core) that the server conforms to", + "operationId" : "getRequirementsClasses", + "responses" : { + "200" : { + "description" : "the URIs of all requirements classes supported by the server", + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/req-classes" + } + } + } + }, + "default" : { + "description" : "An error occurred.", + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/exception" + } + } + } + } + } + } + }, + "/collections" : { + "get" : { + "tags" : [ "Capabilities" ], + "summary" : "describe the feature collections in the dataset", + "operationId" : "describeCollections", + "responses" : { + "200" : { + "description" : "Metdata about the feature collections shared by this API.", + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/content" + } + }, + "text/html" : { + "schema" : { + "type" : "string" + } + } + } + }, + "default" : { + "description" : "An error occurred.", + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/exception" + } + }, + "text/html" : { + "schema" : { + "type" : "string" + } + } + } + } + } + } + }, + "/collections/{collectionId}" : { + "get" : { + "tags" : [ "Capabilities" ], + "summary" : "describe the {collectionId} feature collection", + "operationId" : "describeCollection", + "parameters" : [ { + "name" : "collectionId", + "in" : "path", + "description" : "Identifier (name) of a specific collection", + "required" : true, + "style" : "simple", + "explode" : false, + "schema" : { + "type" : "string" + } + } ], + "responses" : { + "200" : { + "description" : "Metadata about the {collectionId} collection shared by this API.", + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/collectionInfo" + } + }, + "text/html" : { + "schema" : { + "type" : "string" + } + } + } + }, + "default" : { + "description" : "An error occurred.", + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/exception" + } + }, + "text/html" : { + "schema" : { + "type" : "string" + } + } + } + } + } + } + }, + "/collections/{collectionId}/items" : { + "get" : { + "tags" : [ "Features" ], + "summary" : "retrieve features of feature collection {collectionId}", + "description" : "Every feature in a dataset belongs to a collection. A dataset may consist of multiple feature collections. A feature collection is often a collection of features of a similar type, based on a common schema.\\\nUse content negotiation to request HTML or GeoJSON.", + "operationId" : "getFeatures", + "parameters" : [ { + "name" : "collectionId", + "in" : "path", + "description" : "Identifier (name) of a specific collection", + "required" : true, + "style" : "simple", + "explode" : false, + "schema" : { + "type" : "string" + } + }, { + "name" : "limit", + "in" : "query", + "description" : "The optional limit parameter limits the number of items that are\npresented in the response document.\n\nOnly items are counted that are on the first level of the collection in\nthe response document. Nested objects contained within the explicitly\nrequested items shall not be counted.\n\n* Minimum = 1\n* Maximum = 10000\n* Default = 10\n", + "required" : false, + "style" : "form", + "explode" : false, + "schema" : { + "maximum" : 10000, + "minimum" : 1, + "type" : "integer", + "default" : 10 + } + }, { + "name" : "bbox", + "in" : "query", + "description" : "Only features that have a geometry that intersects the bounding box are selected. The bounding box is provided as four or six numbers, depending on whether the coordinate reference system includes a vertical axis (elevation or depth):\n* Lower left corner, coordinate axis 1 * Lower left corner, coordinate axis 2 * Lower left corner, coordinate axis 3 (optional) * Upper right corner, coordinate axis 1 * Upper right corner, coordinate axis 2 * Upper right corner, coordinate axis 3 (optional)\nThe coordinate reference system of the values is WGS84 longitude/latitude (http://www.opengis.net/def/crs/OGC/1.3/CRS84) unless a different coordinate reference system is specified in the parameter `bbox-crs`.\nFor WGS84 longitude/latitude the values are in most cases the sequence of minimum longitude, minimum latitude, maximum longitude and maximum latitude. However, in cases where the box spans the antimeridian the first value (west-most box edge) is larger than the third value (east-most box edge).\nIf a feature has multiple spatial geometry properties, it is the decision of the server whether only a single spatial geometry property is used to determine the extent or all relevant geometries.\n", + "required" : false, + "style" : "form", + "explode" : false, + "schema" : { + "maxItems" : 6, + "minItems" : 4, + "type" : "array", + "items" : { + "type" : "number" + } + } + }, { + "name" : "time", + "in" : "query", + "description" : "Either a date-time or a period string that adheres to RFC 3339. Examples:\n* A date-time: \"2018-02-12T23:20:50Z\" * A period: \"2018-02-12T00:00:00Z/2018-03-18T12:31:12Z\" or \"2018-02-12T00:00:00Z/P1M6DT12H31M12S\"\nOnly features that have a temporal property that intersects the value of `time` are selected.\nIf a feature has multiple temporal properties, it is the decision of the server whether only a single temporal property is used to determine the extent or all relevant temporal properties.", + "required" : false, + "style" : "form", + "explode" : false, + "schema" : { + "type" : "string" + } + } ], + "responses" : { + "200" : { + "description" : "Information about the feature collection plus the first features matching the selection parameters.", + "content" : { + "application/geo+json" : { + "schema" : { + "$ref" : "#/components/schemas/featureCollectionGeoJSON" + } + }, + "text/html" : { + "schema" : { + "type" : "string" + } + } + } + }, + "default" : { + "description" : "An error occurred.", + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/exception" + } + }, + "text/html" : { + "schema" : { + "type" : "string" + } + } + } + } + } + } + }, + "/collections/{collectionId}/items/{featureId}" : { + "get" : { + "tags" : [ "Features" ], + "summary" : "retrieve a feature; use content negotiation to request HTML or GeoJSON", + "operationId" : "getFeature", + "parameters" : [ { + "name" : "collectionId", + "in" : "path", + "description" : "Identifier (name) of a specific collection", + "required" : true, + "style" : "simple", + "explode" : false, + "schema" : { + "type" : "string" + } + }, { + "name" : "featureId", + "in" : "path", + "description" : "Local identifier of a specific feature", + "required" : true, + "style" : "simple", + "explode" : false, + "schema" : { + "type" : "string" + } + } ], + "responses" : { + "200" : { + "description" : "A feature.", + "content" : { + "application/geo+json" : { + "schema" : { + "$ref" : "#/components/schemas/featureGeoJSON" + } + }, + "text/html" : { + "schema" : { + "type" : "string" + } + } + } + }, + "default" : { + "description" : "An error occurred.", + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/exception" + } + }, + "text/html" : { + "schema" : { + "type" : "string" + } + } + } + } + } + } + } + }, + "components" : { + "schemas" : { + "exception" : { + "required" : [ "code" ], + "type" : "object", + "properties" : { + "code" : { + "type" : "string" + }, + "description" : { + "type" : "string" + } + } + }, + "root" : { + "required" : [ "links" ], + "type" : "object", + "properties" : { + "links" : { + "type" : "array", + "example" : [ { + "href" : "http://data.example.org/", + "rel" : "self", + "type" : "application/json", + "title" : "this document" + }, { + "href" : "http://data.example.org/api", + "rel" : "service", + "type" : "application/openapi+json;version=3.0", + "title" : "the API definition" + }, { + "href" : "http://data.example.org/conformance", + "rel" : "conformance", + "type" : "application/json", + "title" : "WFS 3.0 conformance classes implemented by this server" + }, { + "href" : "http://data.example.org/collections", + "rel" : "data", + "type" : "application/json", + "title" : "Metadata about the feature collections" + } ], + "items" : { + "$ref" : "#/components/schemas/link" + } + } + } + }, + "req-classes" : { + "required" : [ "conformsTo" ], + "type" : "object", + "properties" : { + "conformsTo" : { + "type" : "array", + "example" : [ "http://www.opengis.net/spec/wfs-1/3.0/req/core", "http://www.opengis.net/spec/wfs-1/3.0/req/oas30", "http://www.opengis.net/spec/wfs-1/3.0/req/html", "http://www.opengis.net/spec/wfs-1/3.0/req/geojson" ], + "items" : { + "type" : "string" + } + } + } + }, + "link" : { + "required" : [ "href" ], + "type" : "object", + "properties" : { + "href" : { + "type" : "string" + }, + "rel" : { + "type" : "string", + "example" : "prev" + }, + "type" : { + "type" : "string", + "example" : "application/geo+json" + }, + "hreflang" : { + "type" : "string", + "example" : "en" + } + } + }, + "content" : { + "required" : [ "collections", "links" ], + "type" : "object", + "properties" : { + "links" : { + "type" : "array", + "example" : [ { + "href" : "http://data.example.org/collections.json", + "rel" : "self", + "type" : "application/json", + "title" : "this document" + }, { + "href" : "http://data.example.org/collections.html", + "rel" : "alternate", + "type" : "text/html", + "title" : "this document as HTML" + }, { + "href" : "http://schemas.example.org/1.0/foobar.xsd", + "rel" : "describedBy", + "type" : "application/xml", + "title" : "XML schema for Acme Corporation data" + } ], + "items" : { + "$ref" : "#/components/schemas/link" + } + }, + "collections" : { + "type" : "array", + "items" : { + "$ref" : "#/components/schemas/collectionInfo" + } + } + } + }, + "collectionInfo" : { + "required" : [ "links", "name" ], + "type" : "object", + "properties" : { + "name" : { + "type" : "string", + "description" : "identifier of the collection used, for example, in URIs", + "example" : "buildings" + }, + "title" : { + "type" : "string", + "description" : "human readable title of the collection", + "example" : "Buildings" + }, + "description" : { + "type" : "string", + "description" : "a description of the features in the collection", + "example" : "Buildings in the city of Bonn." + }, + "links" : { + "type" : "array", + "example" : [ { + "href" : "http://data.example.org/collections/buildings/items", + "rel" : "item", + "type" : "application/geo+json", + "title" : "Buildings" + }, { + "href" : "http://example.org/concepts/building.html", + "rel" : "describedBy", + "type" : "text/html", + "title" : "Feature catalogue for buildings" + } ], + "items" : { + "$ref" : "#/components/schemas/link" + } + }, + "extent" : { + "$ref" : "#/components/schemas/extent" + }, + "crs" : { + "type" : "array", + "description" : "The coordinate reference systems in which geometries may be retrieved. Coordinate reference systems are identified by a URI. The first coordinate reference system is the coordinate reference system that is used by default. This is always \"http://www.opengis.net/def/crs/OGC/1.3/CRS84\", i.e. WGS84 longitude/latitude.", + "items" : { + "type" : "string" + }, + "default" : [ "http://www.opengis.net/def/crs/OGC/1.3/CRS84" ] + } + } + }, + "extent" : { + "type" : "object", + "properties" : { + "crs" : { + "type" : "string", + "description" : "Coordinate reference system of the coordinates in the spatial extent (property `spatial`). In the Core, only WGS84 longitude/latitude is supported. Extensions may support additional coordinate reference systems.", + "default" : "http://www.opengis.net/def/crs/OGC/1.3/CRS84", + "enum" : [ "http://www.opengis.net/def/crs/OGC/1.3/CRS84" ] + }, + "spatial" : { + "maxItems" : 6, + "minItems" : 4, + "type" : "array", + "description" : "West, north, east, south edges of the spatial extent. The minimum and maximum values apply to the coordinate reference system WGS84 longitude/latitude that is supported in the Core. If, for example, a projected coordinate reference system is used, the minimum and maximum values need to be adjusted.", + "example" : [ -180, -90, 180, 90 ], + "items" : { + "type" : "number" + } + }, + "trs" : { + "type" : "string", + "description" : "Temporal reference system of the coordinates in the temporal extent (property `temporal`). In the Core, only the Gregorian calendar is supported. Extensions may support additional temporal reference systems.", + "default" : "http://www.opengis.net/def/uom/ISO-8601/0/Gregorian", + "enum" : [ "http://www.opengis.net/def/uom/ISO-8601/0/Gregorian" ] + }, + "temporal" : { + "maxItems" : 2, + "minItems" : 2, + "type" : "array", + "description" : "Begin and end times of the temporal extent.", + "example" : [ "2011-11-11T12:22:11Z", "2012-11-24T12:32:43Z" ], + "items" : { + "type" : "string", + "format" : "dateTime" + } + } + } + }, + "featureCollectionGeoJSON" : { + "required" : [ "features", "type" ], + "type" : "object", + "properties" : { + "type" : { + "type" : "string", + "enum" : [ "FeatureCollection" ] + }, + "features" : { + "type" : "array", + "items" : { + "$ref" : "#/components/schemas/featureGeoJSON" + } + }, + "links" : { + "type" : "array", + "items" : { + "$ref" : "#/components/schemas/link" + } + }, + "timeStamp" : { + "type" : "string", + "format" : "dateTime" + }, + "numberMatched" : { + "minimum" : 0, + "type" : "integer" + }, + "numberReturned" : { + "minimum" : 0, + "type" : "integer" + } + } + }, + "featureGeoJSON" : { + "required" : [ "geometry", "properties", "type" ], + "type" : "object", + "properties" : { + "type" : { + "type" : "string", + "enum" : [ "Feature" ] + }, + "geometry" : { + "$ref" : "#/components/schemas/geometryGeoJSON" + }, + "properties" : { + "type" : "object", + "nullable" : true + }, + "id" : { + "oneOf" : [ { + "type" : "string" + }, { + "type" : "integer" + } ] + } + } + }, + "geometryGeoJSON" : { + "required" : [ "type" ], + "type" : "object", + "properties" : { + "type" : { + "type" : "string", + "enum" : [ "Point", "MultiPoint", "LineString", "MultiLineString", "Polygon", "MultiPolygon", "GeometryCollection" ] + } + } + } + }, + "parameters" : { + "limit" : { + "name" : "limit", + "in" : "query", + "description" : "The optional limit parameter limits the number of items that are\npresented in the response document.\n\nOnly items are counted that are on the first level of the collection in\nthe response document. Nested objects contained within the explicitly\nrequested items shall not be counted.\n\n* Minimum = 1\n* Maximum = 10000\n* Default = 10\n", + "required" : false, + "style" : "form", + "explode" : false, + "schema" : { + "maximum" : 10000, + "minimum" : 1, + "type" : "integer", + "default" : 10 + } + }, + "bbox" : { + "name" : "bbox", + "in" : "query", + "description" : "Only features that have a geometry that intersects the bounding box are selected. The bounding box is provided as four or six numbers, depending on whether the coordinate reference system includes a vertical axis (elevation or depth):\n* Lower left corner, coordinate axis 1 * Lower left corner, coordinate axis 2 * Lower left corner, coordinate axis 3 (optional) * Upper right corner, coordinate axis 1 * Upper right corner, coordinate axis 2 * Upper right corner, coordinate axis 3 (optional)\nThe coordinate reference system of the values is WGS84 longitude/latitude (http://www.opengis.net/def/crs/OGC/1.3/CRS84) unless a different coordinate reference system is specified in the parameter `bbox-crs`.\nFor WGS84 longitude/latitude the values are in most cases the sequence of minimum longitude, minimum latitude, maximum longitude and maximum latitude. However, in cases where the box spans the antimeridian the first value (west-most box edge) is larger than the third value (east-most box edge).\nIf a feature has multiple spatial geometry properties, it is the decision of the server whether only a single spatial geometry property is used to determine the extent or all relevant geometries.\n", + "required" : false, + "style" : "form", + "explode" : false, + "schema" : { + "maxItems" : 6, + "minItems" : 4, + "type" : "array", + "items" : { + "type" : "number" + } + } + }, + "time" : { + "name" : "time", + "in" : "query", + "description" : "Either a date-time or a period string that adheres to RFC 3339. Examples:\n* A date-time: \"2018-02-12T23:20:50Z\" * A period: \"2018-02-12T00:00:00Z/2018-03-18T12:31:12Z\" or \"2018-02-12T00:00:00Z/P1M6DT12H31M12S\"\nOnly features that have a temporal property that intersects the value of `time` are selected.\nIf a feature has multiple temporal properties, it is the decision of the server whether only a single temporal property is used to determine the extent or all relevant temporal properties.", + "required" : false, + "style" : "form", + "explode" : false, + "schema" : { + "type" : "string" + } + }, + "collectionId" : { + "name" : "collectionId", + "in" : "path", + "description" : "Identifier (name) of a specific collection", + "required" : true, + "style" : "simple", + "explode" : false, + "schema" : { + "type" : "string" + } + }, + "featureId" : { + "name" : "featureId", + "in" : "path", + "description" : "Local identifier of a specific feature", + "required" : true, + "style" : "simple", + "explode" : false, + "schema" : { + "type" : "string" + } + } + } + } +} diff --git a/src/server/services/wfs3/qgswfs3.cpp b/src/server/services/wfs3/qgswfs3.cpp new file mode 100644 index 00000000000..4cafc1769cb --- /dev/null +++ b/src/server/services/wfs3/qgswfs3.cpp @@ -0,0 +1,65 @@ +/*************************************************************************** + qgswfs3.cpp + ------------------------- + begin : April 15, 2019 + copyright : (C) 2019 by Alessandro Pasotti + email : elpaso at itopen dot it + ***************************************************************************/ + +/*************************************************************************** + * * + * 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 "qgsmodule.h" +#include "qgsserverogcapi.h" +#include "qgswfs3handlers.h" + +/** + * \ingroup server + * \class QgsWfsModule + * \brief Module specialized for WFS3 service + * \since QGIS 3.10 + */ +class QgsWfs3Module: public QgsServiceModule +{ + public: + void registerSelf( QgsServiceRegistry ®istry, QgsServerInterface *serverIface ) override + { + QgsServerOgcApi *wfs3Api = new QgsServerOgcApi { serverIface, + QStringLiteral( "/wfs3" ), + QStringLiteral( "OGC WFS3 (Draft)" ), + QStringLiteral( "1.0.0" ) + }; + // Register handlers + wfs3Api->registerHandler(); + wfs3Api->registerHandler(); + wfs3Api->registerHandler(); + wfs3Api->registerHandler(); + wfs3Api->registerHandler(); + wfs3Api->registerHandler(); + // API handler must access to the whole API + wfs3Api->registerHandler( wfs3Api ); + wfs3Api->registerHandler(); + + // Register API + registry.registerApi( wfs3Api ); + } +}; + + + +// Entry points +QGISEXTERN QgsServiceModule *QGS_ServiceModule_Init() +{ + static QgsWfs3Module module; + return &module; +} +QGISEXTERN void QGS_ServiceModule_Exit( QgsServiceModule * ) +{ + // Nothing to do +} diff --git a/src/server/services/wfs3/qgswfs3handlers.cpp b/src/server/services/wfs3/qgswfs3handlers.cpp new file mode 100644 index 00000000000..6fce93f06fb --- /dev/null +++ b/src/server/services/wfs3/qgswfs3handlers.cpp @@ -0,0 +1,1200 @@ +/*************************************************************************** + qgswfs3handlers.cpp + ------------------------- + begin : May 3, 2019 + copyright : (C) 2019 by Alessandro Pasotti + email : elpaso at itopen dot it + ***************************************************************************/ + +/*************************************************************************** + * * + * 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 "qgswfs3handlers.h" +#include "qgsserverogcapi.h" +#include "qgsserverapicontext.h" +#include "qgsserverrequest.h" +#include "qgsserverresponse.h" +#include "qgsserverapiutils.h" +#include "qgsfeaturerequest.h" +#include "qgsjsonutils.h" +#include "qgsvectorlayer.h" +#include "qgsmessagelog.h" +#include "qgsbufferserverrequest.h" +#include "qgsserverprojectutils.h" +#include "qgsserverinterface.h" + +#include + + +QgsWfs3APIHandler::QgsWfs3APIHandler( const QgsServerOgcApi *api ): + mApi( api ) +{ +} + +void QgsWfs3APIHandler::handleRequest( const QgsServerApiContext &context ) const +{ + if ( ! context.project() ) + { + throw QgsServerApiImproperlyConfiguredException( QStringLiteral( "Project not found, please check your server configuration." ) ); + } + + const QString contactPerson = QgsServerProjectUtils::owsServiceContactPerson( *context.project() ); + const QString contactMail = QgsServerProjectUtils::owsServiceContactMail( *context.project() ); + const QString projectTitle = QgsServerProjectUtils::owsServiceTitle( *context.project() ); + const QString projectDescription = QgsServerProjectUtils::owsServiceAbstract( *context.project() ); + + const QgsProjectMetadata metadata { context.project()->metadata() }; + json data + { + { "links", links( context ) }, + { "openapi", "3.0.1" }, + { + "tags", {{ + { "name", "Capabilities" }, + { "description", "Essential characteristics of this API including information about the data." } + }, { + { "name", "Features" }, + { "description", "Access to data (features)." } + } + } + }, + { + "info", { + { "title", projectTitle.toStdString() }, + { "description", projectDescription.toStdString() }, + { + "contact", { + { "name", contactPerson.toStdString() }, + { "email", contactMail.toStdString() }, + { "url", "" } // TODO: contact url + } + }, + { + "license", { + { "name", "" } // TODO: license + } + }, + { "version", mApi->version().toStdString() } + } + }, + { + "servers", {{ + { "url", parentLink( context.request()->url(), 1 ).toStdString() } + } + } + } + }; + + assert( data.is_object() ); + + // Gather path information from handlers + json paths = json::array(); + for ( const auto &h : mApi->handlers() ) + { + // Skip null schema + const auto hSchema { h->schema( context ) }; + if ( ! hSchema.is_null() ) + paths.merge_patch( hSchema ); + } + data[ "paths" ] = paths; + + // Schema: load common part from file schema.json + static json schema; + + QFile f( context.serverInterface()->serverSettings()->apiResourcesDirectory() + "/ogc/schema.json" ); + if ( f.open( QFile::ReadOnly | QFile::Text ) ) + { + QTextStream in( &f ); + schema = json::parse( in.readAll().toStdString() ); + } + else + { + QgsMessageLog::logMessage( QStringLiteral( "Could not find schema.json in %1, please check your server configuration" ).arg( f.fileName() ), QStringLiteral( "Server" ), Qgis::Critical ); + throw QgsServerApiInternalServerError( QStringLiteral( "Could not find schema.json" ) ); + } + + // Fill CRSs + json crss = json::array(); + for ( const QString &crs : QgsServerApiUtils::publishedCrsList( context.project() ) ) + { + crss.push_back( crs.toStdString() ); + } + schema[ "components" ][ "parameters" ][ "bbox-crs" ][ "schema" ][ "enum" ] = crss; + schema[ "components" ][ "parameters" ][ "crs" ][ "schema" ][ "enum" ] = crss; + data[ "components" ] = schema["components"]; + + // Add schema refs + json navigation = json::array(); + const QUrl url { context.request()->url() }; + navigation.push_back( {{ "title", "Landing page" }, { "href", parentLink( url, 1 ).toStdString() }} ) ; + write( data, context, {{ "pageTitle", linkTitle() }, { "navigation", navigation }} ); +} + +json QgsWfs3APIHandler::schema( const QgsServerApiContext &context ) const +{ + json data; + const std::string path { QgsServerApiUtils::appendMapParameter( context.apiRootPath() + QStringLiteral( "api" ), context.request()->url() ).toStdString() }; + data[ path ] = + { + { + "get", { + { "tags", jsonTags() }, + { "summary", summary() }, + { "description", description() }, + { "operationId", operationId() }, + { + "responses", { + { + "200", { + { "description", description() }, + { + "content", { + { + "application/openapi+json;version=3.0", { + { + "schema", { + { "type", "object" } + } + } + } + }, + { + "text/html", { + { + "schema", { + { "type", "string" } + } + } + } + } + } + } + } + }, + defaultResponse() + } + } + } + } + }; + return data; +} + +QgsWfs3LandingPageHandler::QgsWfs3LandingPageHandler() +{ + +} + +void QgsWfs3LandingPageHandler::handleRequest( const QgsServerApiContext &context ) const +{ + json data + { + { "links", links( context ) } + }; + // Append links to APIs + data["links"].push_back( + { + { "href", href( context, "/collections" )}, + { "rel", QgsServerOgcApi::relToString( QgsServerOgcApi::Rel::data ) }, + { "type", QgsServerOgcApi::mimeType( QgsServerOgcApi::ContentType::JSON ) }, + { "title", "Feature collections" }, + } ); + data["links"].push_back( + { + { "href", href( context, "/conformance" )}, + { "rel", QgsServerOgcApi::relToString( QgsServerOgcApi::Rel::conformance ) }, + { "type", QgsServerOgcApi::mimeType( QgsServerOgcApi::ContentType::JSON ) }, + { "title", "WFS 3.0 conformance classes" }, + } ); + data["links"].push_back( + { + { "href", href( context, "/api" )}, + { "rel", QgsServerOgcApi::relToString( QgsServerOgcApi::Rel::service_desc ) }, + { "type", QgsServerOgcApi::mimeType( QgsServerOgcApi::ContentType::OPENAPI3 ) }, + { "title", "API definition" }, + } ); + write( data, context, {{ "pageTitle", linkTitle() }, { "navigation", json::array() }} ); +} + +json QgsWfs3LandingPageHandler::schema( const QgsServerApiContext &context ) const +{ + json data; + const std::string path { QgsServerApiUtils::appendMapParameter( context.apiRootPath(), context.request()->url() ).toStdString() }; + + data[ path ] = + { + { + "get", { + { "tags", jsonTags() }, + { "summary", summary() }, + { "description", description() }, + { "operationId", operationId() }, + { + "responses", { + { + "200", { + { "description", description() }, + { + "content", { + { + "application/json", { + { + "schema", { + { "$ref", "#/components/schemas/root" } + } + } + } + }, + { + "text/html", { + { + "schema", { + { "type", "string" } + } + } + } + } + } + } + } + }, + defaultResponse() + } + } + } + } + }; + return data; +} + + +QgsWfs3ConformanceHandler::QgsWfs3ConformanceHandler() +{ +} + +void QgsWfs3ConformanceHandler::handleRequest( const QgsServerApiContext &context ) const +{ + json data + { + { "links", links( context ) }, + { + "conformsTo", { "http://www.opengis.net/spec/wfs-1/3.0/req/core", + "http://www.opengis.net/spec/wfs-1/3.0/req/oas30", + "http://www.opengis.net/spec/wfs-1/3.0/req/html", + "http://www.opengis.net/spec/wfs-1/3.0/req/gmlsf2", + "http://www.opengis.net/spec/wfs-1/3.0/req/geojson" + } + } + }; + json navigation = json::array(); + const QUrl url { context.request()->url() }; + navigation.push_back( {{ "title", "Landing page" }, { "href", parentLink( url, 1 ).toStdString() }} ) ; + write( data, context, {{ "pageTitle", linkTitle() }, { "navigation", navigation }} ); +} + +json QgsWfs3ConformanceHandler::schema( const QgsServerApiContext &context ) const +{ + json data; + const std::string path { QgsServerApiUtils::appendMapParameter( context.apiRootPath() + QStringLiteral( "/conformance" ), context.request()->url() ).toStdString() }; + data[ path ] = + { + { + "get", { + { "tags", jsonTags() }, + { "summary", summary() }, + { "description", description() }, + { "operationId", operationId() }, + { + "responses", { + { + "200", { + { "description", description() }, + { + "content", { + { + "application/json", { + { + "schema", { + { "$ref", "#/components/schemas/root" } + } + } + } + }, + { + "text/html", { + { + "schema", { + { "type", "string" } + } + } + } + } + } + } + } + }, + defaultResponse() + } + } + } + } + }; + return data; +} + +QgsWfs3CollectionsHandler::QgsWfs3CollectionsHandler() +{ +} + +void QgsWfs3CollectionsHandler::handleRequest( const QgsServerApiContext &context ) const +{ + json crss = json::array(); + for ( const QString &crs : QgsServerApiUtils::publishedCrsList( context.project() ) ) + { + crss.push_back( crs.toStdString() ); + } + json data + { + { + "links", links( context ) + }, // TODO: add XSD or other schema? + { "collections", json::array() }, + { + "crs", crss + } + }; + + if ( context.project() ) + { + // TODO: include meshes? + for ( const auto &layer : context.project()->layers( ) ) + { + const std::string title { layer->title().isEmpty() ? layer->name().toStdString() : layer->title().toStdString() }; + const QString shortName { layer->shortName().isEmpty() ? layer->name() : layer->shortName() }; + data["collections"].push_back( + { + // identifier of the collection used, for example, in URIs + { "name", shortName.toStdString() }, + // human readable title of the collection + { "title", title }, + // a description of the features in the collection + { "description", layer->abstract().toStdString() }, + { + "crs", crss + }, + // TODO: "relations" ? + { + "extent", { + { "crs", "http://www.opengis.net/def/crs/OGC/1.3/CRS84" }, + { "spatial", QgsServerApiUtils::layerExtent( layer ) } + } + }, + { + "links", { + { + { "href", href( context, QStringLiteral( "/%1/items" ).arg( shortName ), QgsServerOgcApi::contentTypeToExtension( QgsServerOgcApi::ContentType::JSON ) ) }, + { "rel", QgsServerOgcApi::relToString( QgsServerOgcApi::Rel::item ) }, + { "type", QgsServerOgcApi::mimeType( QgsServerOgcApi::ContentType::GEOJSON ) }, + { "title", title + " as GeoJSON" } + }, + { + { "href", href( context, QStringLiteral( "/%1/items" ).arg( shortName ), QgsServerOgcApi::contentTypeToExtension( QgsServerOgcApi::ContentType::HTML ) ) }, + { "rel", QgsServerOgcApi::relToString( QgsServerOgcApi::Rel::item ) }, + { "type", QgsServerOgcApi::mimeType( QgsServerOgcApi::ContentType::HTML ) }, + { "title", title + " as HTML" } + }/* TODO: not sure what these "concepts" are about, neither if they are mandatory + { + { "href", href( api, context.request(), QStringLiteral( "/%1/concepts" ).arg( shortName ) ) }, + { "rel", QgsServerOgcApi::relToString( QgsServerOgcApi::Rel::item ) }, + { "type", "text/html" }, + { "title", "Describe " + title } + } + */ + } + }, + } ); + } + } + json navigation = json::array(); + const QUrl url { context.request()->url() }; + navigation.push_back( {{ "title", "Landing page" }, { "href", parentLink( url, 1 ).toStdString() }} ) ; + write( data, context, {{ "pageTitle", linkTitle() }, { "navigation", navigation }} ); +} + +json QgsWfs3CollectionsHandler::schema( const QgsServerApiContext &context ) const +{ + json data; + const std::string path { QgsServerApiUtils::appendMapParameter( context.apiRootPath() + QStringLiteral( "/collections" ), context.request()->url() ).toStdString() }; + data[ path ] = + { + { + "get", { + { "tags", jsonTags() }, + { "summary", summary() }, + { "description", description() }, + { "operationId", operationId() }, + { + "responses", { + { + "200", { + { "description", description() }, + { + "content", { + { + "application/json", { + { + "schema", { + { "$ref", "#/components/schemas/content" } + } + } + } + }, + { + "text/html", { + { + "schema", { + { "type", "string" } + } + } + } + } + } + } + } + }, + defaultResponse() + } + } + } + } + }; + return data; +} + +QgsWfs3DescribeCollectionHandler::QgsWfs3DescribeCollectionHandler() +{ +} + +void QgsWfs3DescribeCollectionHandler::handleRequest( const QgsServerApiContext &context ) const +{ + if ( ! context.project() ) + { + throw QgsServerApiImproperlyConfiguredException( QStringLiteral( "Project is invalid or undefined" ) ); + } + // Check collectionId + const QRegularExpressionMatch match { path().match( context.request()->url().path( ) ) }; + if ( ! match.hasMatch() ) + { + throw QgsServerApiNotFoundError( QStringLiteral( "Collection was not found" ) ); + } + const QString collectionId { match.captured( QStringLiteral( "collectionId" ) ) }; + // May throw if not found + const QgsVectorLayer *mapLayer { layerFromCollection( context, collectionId ) }; + Q_ASSERT( mapLayer ); + + const std::string title { mapLayer->title().isEmpty() ? mapLayer->name().toStdString() : mapLayer->title().toStdString() }; + const QString shortName { mapLayer->shortName().isEmpty() ? mapLayer->name() : mapLayer->shortName() }; + json linksList { links( context ) }; + linksList.push_back( + { + { "href", href( context, QStringLiteral( "/items" ), QgsServerOgcApi::contentTypeToExtension( QgsServerOgcApi::ContentType::JSON ) ) }, + { "rel", QgsServerOgcApi::relToString( QgsServerOgcApi::Rel::items ) }, + { "type", QgsServerOgcApi::mimeType( QgsServerOgcApi::ContentType::JSON ) }, + { "title", title } + } ); + + linksList.push_back( + { + { "href", href( context, QStringLiteral( "/items" ), QgsServerOgcApi::contentTypeToExtension( QgsServerOgcApi::ContentType::HTML ) ) }, + { "rel", QgsServerOgcApi::relToString( QgsServerOgcApi::Rel::items ) }, + { "type", QgsServerOgcApi::mimeType( QgsServerOgcApi::ContentType::HTML ) }, + { "title", title } + } + /* TODO: not sure what these "concepts" are about, neither if they are mandatory + ,{ + { "href", href( api, *context.request() , QStringLiteral( "/concepts" ), QStringLiteral( "html") ) }, + { "rel", QgsServerOgcApi::relToString( QgsServerOgcApi::Rel::item ) }, + { "type", "text/html" }, + { "title", "Describe " + title } + } + */ + ); + json crss = json::array(); + for ( const auto &crs : QgsServerApiUtils::publishedCrsList( context.project() ) ) + { + crss.push_back( crs.toStdString() ); + } + json data + { + { "name", mapLayer->name().toStdString() }, + { "title", title }, + // TODO: check if we need to expose other advertised CRS here + { + "crs", crss + }, + // TODO: "relations" ? + { + "extent", { + { "crs", "http://www.opengis.net/def/crs/OGC/1.3/CRS84" }, + { "spatial", QgsServerApiUtils::layerExtent( mapLayer ) } + } + }, + { + "links", linksList + } + }; + json navigation = json::array(); + const QUrl url { context.request()->url() }; + navigation.push_back( {{ "title", "Landing page" }, { "href", parentLink( url, 2 ).toStdString() }} ) ; + navigation.push_back( {{ "title", "Collections" }, { "href", parentLink( url, 1 ).toStdString() }} ) ; + write( data, context, {{ "pageTitle", title }, { "navigation", navigation }} ); +} + +json QgsWfs3DescribeCollectionHandler::schema( const QgsServerApiContext &context ) const +{ + json data; + Q_ASSERT( context.project() ); + + const auto layers { QgsServerApiUtils::publishedWfsLayers( context.project() ) }; + // Construct the context with collection id + for ( const auto &mapLayer : layers ) + { + const QString shortName { mapLayer->shortName().isEmpty() ? mapLayer->name() : mapLayer->shortName() }; + const std::string title { mapLayer->title().isEmpty() ? mapLayer->name().toStdString() : mapLayer->title().toStdString() }; + const std::string path { QgsServerApiUtils::appendMapParameter( context.apiRootPath() + QStringLiteral( "collections/%1" ).arg( shortName ), context.request()->url() ).toStdString() }; + + data[ path ] = + { + { + "get", { + { "tags", jsonTags() }, + { "summary", "Describe the '" + title + "' feature collection"}, + { "description", description() }, + { "operationId", operationId() + '_' + shortName.toStdString() }, + { + "responses", { + { + "200", { + { "description", "Metadata about the collection '" + title + "' shared by this API." }, + { + "content", { + { + "application/json", { + { + "schema", { + { "$ref", "#/components/schemas/collectionInfo" } + } + } + } + }, + { + "text/html", { + { + "schema", { + { "type", "string" } + } + } + } + } + } + } + } + }, + defaultResponse() + } + } + } + } + }; + } + return data; +} + +QgsWfs3CollectionsItemsHandler::QgsWfs3CollectionsItemsHandler() +{ +} + +QList QgsWfs3CollectionsItemsHandler::parameters( const QgsServerApiContext &context ) const +{ + QList params; + + // Limit + const qlonglong maxLimit { context.serverInterface()->serverSettings()->apiWfs3MaxLimit() }; + QgsServerQueryStringParameter limit { QStringLiteral( "limit" ), false, + QgsServerQueryStringParameter::Type::Integer, + QStringLiteral( "Number of features to retrieve [0-%1]" ).arg( maxLimit ), + 10 }; + limit.setCustomValidator( [ = ]( const QgsServerApiContext &, QVariant & value ) -> bool + { + return value >= 0 && value <= maxLimit; // TODO: make this configurable! + } ); + params.push_back( limit ); + + // Offset + QgsServerQueryStringParameter offset { QStringLiteral( "offset" ), false, + QgsServerQueryStringParameter::Type::Integer, + QStringLiteral( "Offset for features to retrieve [0-]" ), + 0 }; + + bool offsetValidatorSet = false; + + // I'm not yet sure if we should get here without a project, + // but parameters() may be called to document the API - better safe than sorry. + if ( context.project() ) + { + // Fields filters + const QgsVectorLayer *mapLayer { layerFromContext( context ) }; + if ( mapLayer ) + { + offset.setCustomValidator( [ = ]( const QgsServerApiContext &, QVariant & value ) -> bool + { + const qlonglong longVal { value.toLongLong( ) }; + return longVal >= 0 && longVal <= mapLayer->featureCount( ); + } ); + offset.setDescription( QStringLiteral( "Offset for features to retrieve [0-%1]" ).arg( mapLayer->featureCount( ) ) ); + offsetValidatorSet = true; + for ( const auto &p : fieldParameters( mapLayer ) ) + { + params.push_back( p ); + } + } + } + + if ( ! offsetValidatorSet ) + { + offset.setCustomValidator( [ ]( const QgsServerApiContext &, QVariant & value ) -> bool + { + const qlonglong longVal { value.toLongLong( ) }; + return longVal >= 0 ; + } ); + } + + params.push_back( offset ); + + // BBOX + QgsServerQueryStringParameter bbox { QStringLiteral( "bbox" ), false, + QgsServerQueryStringParameter::Type::String, + QStringLiteral( "BBOX filter for the features to retrieve" ) }; + params.push_back( bbox ); + + auto crsValidator = [ = ]( const QgsServerApiContext &, QVariant & value ) -> bool + { + return QgsServerApiUtils::publishedCrsList( context.project() ).contains( value.toString() ); + }; + + // BBOX CRS + QgsServerQueryStringParameter bboxCrs { QStringLiteral( "bbox-crs" ), false, + QgsServerQueryStringParameter::Type::String, + QStringLiteral( "CRS for the BBOX filter" ), + QStringLiteral( "http://www.opengis.net/def/crs/OGC/1.3/CRS84" ) }; + bboxCrs.setCustomValidator( crsValidator ); + params.push_back( bboxCrs ); + + // CRS + QgsServerQueryStringParameter crs { QStringLiteral( "crs" ), false, + QgsServerQueryStringParameter::Type::String, + QStringLiteral( "The coordinate reference system of the response geometries." ), + QStringLiteral( "http://www.opengis.net/def/crs/OGC/1.3/CRS84" ) }; + crs.setCustomValidator( crsValidator ); + params.push_back( crs ); + + // Result type + QgsServerQueryStringParameter resultType { QStringLiteral( "resultType" ), false, + QgsServerQueryStringParameter::Type::String, + QStringLiteral( "Type of returned result: 'results' (default) or 'hits'" ), + QStringLiteral( "results" ) }; + params.push_back( resultType ); + + return params; +} + +json QgsWfs3CollectionsItemsHandler::schema( const QgsServerApiContext &context ) const +{ + json data; + Q_ASSERT( context.project() ); + + const auto layers { QgsServerApiUtils::publishedWfsLayers( context.project() ) }; + // Construct the context with collection id + for ( const auto &mapLayer : layers ) + { + const QString shortName { mapLayer->shortName().isEmpty() ? mapLayer->name() : mapLayer->shortName() }; + const std::string title { mapLayer->title().isEmpty() ? mapLayer->name().toStdString() : mapLayer->title().toStdString() }; + const std::string path { QgsServerApiUtils::appendMapParameter( context.apiRootPath() + QStringLiteral( "/collections/%1/items" ).arg( shortName ), context.request()->url() ).toStdString() }; + + json parameters = {{ + {{ "$ref", "#/components/parameters/limit" }}, + {{ "$ref", "#/components/parameters/offset" }}, + {{ "$ref", "#/components/parameters/resultType" }}, + {{ "$ref", "#/components/parameters/bbox" }}, + {{ "$ref", "#/components/parameters/bbox-crs" }}, + // TODO: {{ "$ref", "#/components/parameters/time" }}, + } + }; + + for ( const auto &p : fieldParameters( mapLayer ) ) + { + const std::string name { p.name().toStdString() }; + parameters.push_back( p.data() ); + } + + data[ path ] = + { + { + "get", { + { "tags", jsonTags() }, + { "summary", "Retrieve features of '" + title + "' feature collection" }, + { "description", description() }, + { "operationId", operationId() + '_' + shortName.toStdString() }, + { "parameters", parameters }, + { + "responses", { + { + "200", { + { "description", "Metadata about the collection '" + title + "' shared by this API." }, + { + "content", { + { + "application/geo+json", { + { + "schema", { + { "$ref", "#/components/schemas/featureCollectionGeoJSON" } + } + } + } + }, + { + "text/html", { + { + "schema", { + { "type", "string" } + } + } + } + } + } + } + } + }, + defaultResponse() + } + } + } + } + }; + } + return data; +} + +const QList QgsWfs3CollectionsItemsHandler::fieldParameters( const QgsVectorLayer *mapLayer ) const +{ + QList params; + if ( mapLayer ) + { + const QgsFields constFields { QgsServerApiUtils::publishedFields( mapLayer ) }; + for ( const auto &f : constFields ) + { + QgsServerQueryStringParameter::Type t; + switch ( f.type() ) + { + case QVariant::Int: + case QVariant::LongLong: + t = QgsServerQueryStringParameter::Type::Integer; + break; + case QVariant::Double: + t = QgsServerQueryStringParameter::Type::Double; + break; + // TODO: date & time + default: + t = QgsServerQueryStringParameter::Type::String; + break; + } + QgsServerQueryStringParameter fieldParam { f.name(), false, + t, QStringLiteral( "Retrieve features filtered by: %1 (%2)" ).arg( f.name() ) + .arg( QgsServerQueryStringParameter::typeName( t ) ) }; + params.push_back( fieldParam ); + } + } + return params; +} + +void QgsWfs3CollectionsItemsHandler::handleRequest( const QgsServerApiContext &context ) const +{ + if ( ! context.project() ) + { + throw QgsServerApiImproperlyConfiguredException( QStringLiteral( "Project is invalid or undefined" ) ); + } + QgsVectorLayer *mapLayer { layerFromContext( context ) }; + Q_ASSERT( mapLayer ); + const std::string title { mapLayer->title().isEmpty() ? mapLayer->name().toStdString() : mapLayer->title().toStdString() }; + const QString shortName { mapLayer->shortName().isEmpty() ? mapLayer->name() : mapLayer->shortName() }; + + // Get parameters + QVariantMap params { values( context )}; + + if ( context.request()->method() == QgsServerRequest::Method::GetMethod ) + { + + // Validate inputs + bool ok { false }; + + // BBOX + const QString bbox { params[ QStringLiteral( "bbox" )].toString() }; + const QgsRectangle filterRect { QgsServerApiUtils::parseBbox( bbox ) }; + if ( ! bbox.isEmpty() && filterRect.isNull() ) + { + throw QgsServerApiBadRequestException( QStringLiteral( "bbox is not valid" ) ); + } + + // BBOX CRS + const QgsCoordinateReferenceSystem bboxCrs { QgsServerApiUtils::parseCrs( params[ QStringLiteral( "bbox-crs" ) ].toString() ) }; + if ( ! bboxCrs.isValid() ) + { + throw QgsServerApiBadRequestException( QStringLiteral( "BBOX CRS is not valid" ) ); + } + + // CRS + const QgsCoordinateReferenceSystem crs { QgsServerApiUtils::parseCrs( params[ QStringLiteral( "crs" ) ].toString() ) }; + if ( ! crs.isValid() ) + { + throw QgsServerApiBadRequestException( QStringLiteral( "CRS is not valid" ) ); + } + + // resultType + const QString resultType { params[ QStringLiteral( "resultType" ) ].toString() }; + static const QStringList availableResultTypes { QStringLiteral( "results" ), QStringLiteral( "hits" )}; + if ( ! availableResultTypes.contains( resultType ) ) + { + throw QgsServerApiBadRequestException( QStringLiteral( "resultType is not valid [results, hits]" ) ); + } + + // Attribute filters + QgsStringMap attrFilters; + const QgsFields constField { QgsServerApiUtils::publishedFields( mapLayer ) }; + for ( const QgsField &f : constField ) + { + const QString val = params.value( f.name() ).toString() ; + if ( ! val.isEmpty() ) + { + QString sanitized { QgsServerApiUtils::sanitizedFieldValue( val ) }; + if ( sanitized.isEmpty() ) + { + throw QgsServerApiBadRequestException( QStringLiteral( "Invalid filter field value [%1=%2]" ).arg( f.name() ).arg( val ) ); + } + attrFilters[f.name()] = sanitized; + } + } + + // limit & offset + // Apparently the standard set limits 0-10000 (and does not implement paging, + // so we do our own paging with "offset") + const long offset { params.value( QStringLiteral( "offset" ) ).toLongLong( &ok ) }; + + // TODO: make the max limit configurable + const long limit { params.value( QStringLiteral( "limit" ) ).toLongLong( &ok ) }; + + // TODO: implement time + const QString time { context.request()->queryParameter( QStringLiteral( "time" ) ) }; + if ( ! time.isEmpty() ) + { + throw QgsServerApiNotImplementedException( QStringLiteral( "Time filter is not implemented" ) ) ; + } + + // Inputs are valid, process request + QgsFeatureRequest req; + if ( ! filterRect.isNull() ) + { + QgsCoordinateTransform ct( bboxCrs, mapLayer->crs(), context.project()->transformContext() ); + ct.transform( filterRect ); + req.setFilterRect( ct.transform( filterRect ) ); + } + + QString filterExpression; + if ( ! attrFilters.isEmpty() ) + { + QStringList expressions; + for ( auto it = attrFilters.constBegin(); it != attrFilters.constEnd(); it++ ) + { + // Handle star + static const QRegularExpression re2( R"raw([^\\]\*)raw" ); + if ( re2.match( it.value() ).hasMatch() ) + { + QString val { it.value() }; + expressions.push_back( QStringLiteral( "\"%1\" LIKE '%2'" ).arg( it.key() ).arg( val.replace( '%', QStringLiteral( "%%" ) ).replace( '*', '%' ) ) ); + } + else + { + expressions.push_back( QStringLiteral( "\"%1\" = '%2'" ).arg( it.key() ).arg( it.value() ) ); + } + } + filterExpression = expressions.join( QStringLiteral( " AND " ) ); + req.setFilterExpression( filterExpression ); + } + + // WFS3 core specs only serves 4326 + req.setDestinationCrs( crs, context.project()->transformContext() ); + // Add offset to limit because paging is not supported from QgsFeatureRequest + req.setLimit( limit + offset ); + QgsJsonExporter exporter { mapLayer }; + exporter.setSourceCrs( mapLayer->crs() ); + QgsFeatureList featureList; + QgsFeatureIterator features { mapLayer->getFeatures( req ) }; + QgsFeature feat; + long i { 0 }; + while ( features.nextFeature( feat ) ) + { + // Ignore records before offset + if ( i >= offset ) + featureList << feat; + i++; + } + + // Count features + long matchedFeaturesCount = 0; + if ( attrFilters.isEmpty() && filterRect.isNull() ) + { + matchedFeaturesCount = mapLayer->featureCount(); + } + else + { + if ( filterExpression.isEmpty() ) + { + req.setNoAttributes(); + } + req.setFlags( QgsFeatureRequest::Flag::NoGeometry ); + req.setLimit( -1 ); + features = mapLayer->getFeatures( req ); + while ( features.nextFeature( feat ) ) + { + matchedFeaturesCount++; + } + } + + json data { exporter.exportFeaturesToJsonObject( featureList ) }; + + // Add some metadata + data["numberMatched"] = matchedFeaturesCount; + data["numberReturned"] = featureList.count(); + data["links"] = links( context ); + + // Current url + const QUrl url { context.request()->url() }; + + // Url without offset and limit + QString cleanedUrl { url.toString().replace( QRegularExpression( R"raw(&?(offset|limit)(=\d+)*)raw" ), QString() ) }; + + if ( ! url.hasQuery() ) + { + cleanedUrl += '?'; + } + + // Get the self link + json selfLink; + for ( const auto &l : data["links"] ) + { + if ( l["rel"] == "self" ) + { + selfLink = l; + break; + } + } + // This should never happen! + Q_ASSERT( !selfLink.is_null() ); + + // Add prev - next links + if ( offset != 0 ) + { + auto prevLink { selfLink }; + prevLink["href"] = QStringLiteral( "%1&offset=%2&limit=%3" ).arg( cleanedUrl ).arg( std::max( 0, limit - offset ) ).arg( limit ).toStdString(); + prevLink["rel"] = "prev"; + prevLink["name"] = "Previous page"; + data["links"].push_back( prevLink ); + } + if ( limit + offset < matchedFeaturesCount ) + { + auto nextLink { selfLink }; + nextLink["href"] = QStringLiteral( "%1&offset=%2&limit=%3" ).arg( cleanedUrl ).arg( std::min( matchedFeaturesCount, limit + offset ) ).arg( limit ).toStdString(); + nextLink["rel"] = "next"; + nextLink["name"] = "Next page"; + data["links"].push_back( nextLink ); + } + + json navigation = json::array(); + navigation.push_back( {{ "title", "Landing page" }, { "href", parentLink( url, 3 ).toStdString() }} ) ; + navigation.push_back( {{ "title", "Collections" }, { "href", parentLink( url, 2 ).toStdString() }} ) ; + navigation.push_back( {{ "title", title }, { "href", parentLink( url, 1 ).toStdString() }} ) ; + json htmlMetadata + { + { "pageTitle", "Features in layer " + title }, + { "layerTitle", title }, + { + "geojsonUrl", href( context, "/", + QgsServerOgcApi::contentTypeToExtension( QgsServerOgcApi::ContentType::GEOJSON ) ) + }, + { "navigation", navigation } + }; + write( data, context, htmlMetadata ); + } + else + { + throw QgsServerApiNotImplementedException( QStringLiteral( "Only GET method is implemented." ) ); + } + +} + +QgsWfs3CollectionsFeatureHandler::QgsWfs3CollectionsFeatureHandler() +{ +} + +void QgsWfs3CollectionsFeatureHandler::handleRequest( const QgsServerApiContext &context ) const +{ + if ( ! context.project() ) + { + throw QgsServerApiImproperlyConfiguredException( QStringLiteral( "Project is invalid or undefined" ) ); + } + // Check collectionId + const QRegularExpressionMatch match { path().match( context.request()->url().path( ) ) }; + if ( ! match.hasMatch() ) + { + throw QgsServerApiNotFoundError( QStringLiteral( "Collection was not found" ) ); + } + const QString collectionId { match.captured( QStringLiteral( "collectionId" ) ) }; + // May throw if not found + QgsVectorLayer *mapLayer { layerFromCollection( context, collectionId ) }; + Q_ASSERT( mapLayer ); + const std::string title { mapLayer->title().isEmpty() ? mapLayer->name().toStdString() : mapLayer->title().toStdString() }; + + if ( context.request()->method() == QgsServerRequest::Method::GetMethod ) + { + const QString featureId { match.captured( QStringLiteral( "featureId" ) ) }; + QgsJsonExporter exporter { mapLayer }; + const QgsFeature feature { mapLayer->getFeature( featureId.toLongLong() ) }; + if ( ! feature.isValid() ) + { + QgsServerApiInternalServerError( QStringLiteral( "Invalid feature [%1]" ).arg( featureId ) ); + } + json data { exporter.exportFeatureToJsonObject( feature ) }; + data["links"] = links( context ); + json navigation = json::array(); + const QUrl url { context.request()->url() }; + navigation.push_back( {{ "title", "Landing page" }, { "href", parentLink( url, 4 ).toStdString() }} ) ; + navigation.push_back( {{ "title", "Collections" }, { "href", parentLink( url, 3 ).toStdString() }} ) ; + navigation.push_back( {{ "title", title }, { "href", parentLink( url, 2 ).toStdString() }} ) ; + navigation.push_back( {{ "title", "Items of " + title }, { "href", parentLink( url ).toStdString() }} ) ; + json htmlMetadata + { + { "pageTitle", title + " - feature " + featureId.toStdString() }, + { + "geojsonUrl", href( context, "", + QgsServerOgcApi::contentTypeToExtension( QgsServerOgcApi::ContentType::GEOJSON ) ) + }, + { "navigation", navigation } + }; + write( data, context, htmlMetadata ); + } + else + { + throw QgsServerApiNotImplementedException( QStringLiteral( "Only GET method is implemented." ) ); + } +} + +json QgsWfs3CollectionsFeatureHandler::schema( const QgsServerApiContext &context ) const +{ + json data; + Q_ASSERT( context.project() ); + + const auto layers { QgsServerApiUtils::publishedWfsLayers( context.project() ) }; + // Construct the context with collection id + for ( const auto &mapLayer : layers ) + { + const QString shortName { mapLayer->shortName().isEmpty() ? mapLayer->name() : mapLayer->shortName() }; + const std::string title { mapLayer->title().isEmpty() ? mapLayer->name().toStdString() : mapLayer->title().toStdString() }; + const std::string path { QgsServerApiUtils::appendMapParameter( context.apiRootPath() + QStringLiteral( "collections/%1/items/{featureId}" ).arg( shortName ), context.request()->url() ).toStdString() }; + + data[ path ] = + { + { + "get", { + { "tags", jsonTags() }, + { "summary", "Retrieve a single feature from the '" + title + "' feature collection"}, + { "description", description() }, + { "operationId", operationId() + '_' + shortName.toStdString() }, + { + "responses", { + { + "200", { + { "description", "Retrieve a '" + title + "' feature by 'featureId'." }, + { + "content", { + { + "application/geo+json", { + { + "schema", { + { "$ref", "#/components/schemas/featureGeoJSON" } + } + } + } + }, + { + "text/html", { + { + "schema", { + { "type", "string" } + } + } + } + } + } + } + } + }, + defaultResponse() + } + } + } + } + }; + } + return data; +} + +QgsWfs3StaticHandler::QgsWfs3StaticHandler() +{ +} + +void QgsWfs3StaticHandler::handleRequest( const QgsServerApiContext &context ) const +{ + const QRegularExpressionMatch match { path().match( context.request()->url().path( ) ) }; + if ( ! match.hasMatch() ) + { + throw QgsServerApiNotFoundError( QStringLiteral( "Static file was not found" ) ); + } + + const QString staticFilePath { match.captured( QStringLiteral( "staticFilePath" ) ) }; + // Calculate real path + const QString filePath { staticPath( context ) + '/' + staticFilePath }; + if ( ! QFile::exists( filePath ) ) + { + QgsMessageLog::logMessage( QStringLiteral( "Static file was not found: %1" ).arg( filePath ), QStringLiteral( "Server" ), Qgis::Info ); + throw QgsServerApiNotFoundError( QStringLiteral( "Static file %1 was not found" ).arg( staticFilePath ) ); + } + + QFile f( filePath ); + if ( ! f.open( QIODevice::ReadOnly ) ) + { + throw QgsServerApiInternalServerError( QStringLiteral( "Could not open static file %1" ).arg( staticFilePath ) ); + } + + const qint64 size { f.size() }; + const QByteArray content { f.readAll() }; + const QMimeType mimeType { QMimeDatabase().mimeTypeForFile( filePath )}; + context.response()->setHeader( QStringLiteral( "Content-Type" ), mimeType.name() ); + context.response()->setHeader( QStringLiteral( "Content-Length" ), QString::number( size ) ); + context.response()->write( content ); +} + diff --git a/src/server/services/wfs3/qgswfs3handlers.h b/src/server/services/wfs3/qgswfs3handlers.h new file mode 100644 index 00000000000..30267514066 --- /dev/null +++ b/src/server/services/wfs3/qgswfs3handlers.h @@ -0,0 +1,239 @@ +/*************************************************************************** + qgswfs3handlers.h + ------------------------- + begin : May 3, 2019 + copyright : (C) 2019 by Alessandro Pasotti + email : elpaso at itopen dot it + ***************************************************************************/ + +/*************************************************************************** + * * + * 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 QGS_WFS3_HANDLERS_H +#define QGS_WFS3_HANDLERS_H + +#include "qgsserverogcapihandler.h" + +class QgsServerOgcApi; + +/** + * The APIHandler class Wfs3handles the API definition + */ +class QgsWfs3APIHandler: public QgsServerOgcApiHandler +{ + public: + + QgsWfs3APIHandler( const QgsServerOgcApi *api ); + + // QgsServerOgcApiHandler interface + void handleRequest( const QgsServerApiContext &context ) const override; + QRegularExpression path() const override { return QRegularExpression( R"re(/api)re" ); } + std::string operationId() const override { return "getApiDescription"; } + std::string summary() const override { return "The API definition"; } + std::string description() const override { return "The formal documentation of this API according to the OpenAPI specification, version 3.0. I.e., this document."; } + std::string linkTitle() const override { return "API definition"; } + QStringList tags() const override { return { QStringLiteral( "Capabilities" ) }; } + QList contentTypes() const override { return { QgsServerOgcApi::ContentType::OPENAPI3, QgsServerOgcApi::ContentType::HTML }; } + QgsServerOgcApi::Rel linkType() const override { return QgsServerOgcApi::Rel::service_desc; } + QgsServerOgcApi::ContentType defaultContentType() const override { return QgsServerOgcApi::ContentType::OPENAPI3; } + json schema( const QgsServerApiContext &context ) const override; + + private: + const QgsServerOgcApi *mApi = nullptr; +}; + + +/** + * The StaticHandler class Wfs3 serves static files from the static path (resources/server/api/wfs3/static) + * \see staticPath() + */ +class QgsWfs3StaticHandler: public QgsServerOgcApiHandler +{ + public: + + QgsWfs3StaticHandler( ); + + void handleRequest( const QgsServerApiContext &context ) const override; + + // QgsServerOgcApiHandler interface + QRegularExpression path() const override { return QRegularExpression( R"re(/static/(?.*)$)re" ); } + std::string operationId() const override { return "static"; } + std::string summary() const override { return "Serves static files"; } + std::string description() const override { return "Serves static files"; } + std::string linkTitle() const override { return "Serves static files"; } + QList contentTypes() const override { return { QgsServerOgcApi::ContentType::HTML }; } + QgsServerOgcApi::Rel linkType() const override { return QgsServerOgcApi::Rel::data; } + QgsServerOgcApi::ContentType defaultContentType() const override { return QgsServerOgcApi::ContentType::HTML; } + +}; + + +class QgsWfs3LandingPageHandler: public QgsServerOgcApiHandler +{ + public: + + QgsWfs3LandingPageHandler( ); + + void handleRequest( const QgsServerApiContext &context ) const override; + + // QgsServerOgcApiHandler interface + QRegularExpression path() const override { return QRegularExpression( R"re((.html|.json)?$)re" ); } + std::string operationId() const override { return "getLandingPage"; } + QStringList tags() const override { return { QStringLiteral( "Capabilities" ) }; } + std::string summary() const override + { + return "WFS 3.0 Landing Page"; + } + std::string description() const override + { + return "The landing page provides links to the API definition, the Conformance " + "statements and the metadata about the feature data in this dataset."; + } + std::string linkTitle() const override { return "Landing page"; } + QList contentTypes() const override { return { QgsServerOgcApi::ContentType::JSON, QgsServerOgcApi::ContentType::HTML }; } + QgsServerOgcApi::Rel linkType() const override { return QgsServerOgcApi::Rel::self; } + QgsServerOgcApi::ContentType defaultContentType() const override { return QgsServerOgcApi::ContentType::JSON; } + json schema( const QgsServerApiContext &context ) const override; +}; + + +class QgsWfs3ConformanceHandler: public QgsServerOgcApiHandler +{ + public: + + QgsWfs3ConformanceHandler( ); + + void handleRequest( const QgsServerApiContext &context ) const override; + + // QgsServerOgcApiHandler interface + QRegularExpression path() const override { return QRegularExpression( R"re(/conformance)re" ); } + std::string operationId() const override { return "getRequirementClasses"; } + std::string summary() const override { return "Information about standards that this API conforms to"; } + std::string description() const override + { + return "List all requirements classes specified in a standard (e.g., WFS 3.0 " + "Part 1: Core) that the server conforms to"; + } + QStringList tags() const override { return { QStringLiteral( "Capabilities" ) }; } + std::string linkTitle() const override { return "WFS 3.0 conformance classes"; } + QList contentTypes() const override { return { QgsServerOgcApi::ContentType::JSON, QgsServerOgcApi::ContentType::HTML }; } + QgsServerOgcApi::Rel linkType() const override { return QgsServerOgcApi::Rel::conformance; } + QgsServerOgcApi::ContentType defaultContentType() const override { return QgsServerOgcApi::ContentType::JSON; } + json schema( const QgsServerApiContext &context ) const override; +}; + + +/** + * The CollectionsHandler lists all available collections for the current project + * Path: /collections + */ +class QgsWfs3CollectionsHandler: public QgsServerOgcApiHandler +{ + public: + + QgsWfs3CollectionsHandler( ); + + void handleRequest( const QgsServerApiContext &context ) const override; + + // QgsServerOgcApiHandler interface + QRegularExpression path() const override { return QRegularExpression( R"re(/collections(\.json|\.html)?$)re" ); } + std::string operationId() const override { return "describeCollections"; } + std::string summary() const override + { + return "Metadata about the feature collections shared by this API."; + } + QStringList tags() const override { return { QStringLiteral( "Capabilities" ) }; } + std::string description() const override + { + return "Describe the feature collections in the dataset " + "statements and the metadata about the feature data in this dataset."; + } + std::string linkTitle() const override { return "Feature collections"; } + QList contentTypes() const override { return { QgsServerOgcApi::ContentType::JSON, QgsServerOgcApi::ContentType::HTML }; } + QgsServerOgcApi::Rel linkType() const override { return QgsServerOgcApi::Rel::data; } + QgsServerOgcApi::ContentType defaultContentType() const override { return QgsServerOgcApi::ContentType::JSON; } + json schema( const QgsServerApiContext &context ) const override; +}; + +/** + * The DescribeCollectionHandler describes a single collection + * Path: /collections/{collectionId} + */ +class QgsWfs3DescribeCollectionHandler: public QgsServerOgcApiHandler +{ + public: + QgsWfs3DescribeCollectionHandler( ); + void handleRequest( const QgsServerApiContext &context ) const override; + + QRegularExpression path() const override { return QRegularExpression( R"re(/collections/(?[^/]+?)(\.json|\.html)?$)re" ); } + std::string operationId() const override { return "describeCollection"; } + std::string summary() const override { return "Describe the feature collection"; } + std::string description() const override { return "Metadata about a feature collection."; } + std::string linkTitle() const override { return "Feature collection"; } + QStringList tags() const override { return { QStringLiteral( "Capabilities" ) }; } + QList contentTypes() const override { return { QgsServerOgcApi::ContentType::JSON, QgsServerOgcApi::ContentType::HTML }; } + QgsServerOgcApi::Rel linkType() const override { return QgsServerOgcApi::Rel::data; } + QgsServerOgcApi::ContentType defaultContentType() const override { return QgsServerOgcApi::ContentType::JSON; } + json schema( const QgsServerApiContext &context ) const override; +}; + +/** + * The CollectionsItemsHandler list all items in the collection + * Path: /collections/{collectionId} + */ +class QgsWfs3CollectionsItemsHandler: public QgsServerOgcApiHandler +{ + public: + QgsWfs3CollectionsItemsHandler( ); + void handleRequest( const QgsServerApiContext &context ) const override; + QRegularExpression path() const override { return QRegularExpression( R"re(/collections/(?[^/]+)/items(\.geojson|\.json|\.html)?$)re" ); } + std::string operationId() const override { return "getFeatures"; } + std::string summary() const override { return "Retrieve features of feature collection collectionId"; } + std::string description() const override + { + return "Every feature in a dataset belongs to a collection. A dataset may " + "consist of multiple feature collections. A feature collection is often a " + "collection of features of a similar type, based on a common schema. " + "Use content negotiation or specify a file extension to request HTML (.html) " + "or GeoJSON (.json)."; + } + std::string linkTitle() const override { return "Retrieve the features of the collection"; } + QStringList tags() const override { return { QStringLiteral( "Features" ) }; } + QList contentTypes() const override { return { QgsServerOgcApi::ContentType::GEOJSON, QgsServerOgcApi::ContentType::HTML }; } + QgsServerOgcApi::Rel linkType() const override { return QgsServerOgcApi::Rel::data; } + QgsServerOgcApi::ContentType defaultContentType() const override { return QgsServerOgcApi::ContentType::GEOJSON; } + QList parameters( const QgsServerApiContext &context ) const override; + json schema( const QgsServerApiContext &context ) const override; + + private: + + // Retrieve the fields filter parameters + const QList fieldParameters( const QgsVectorLayer *mapLayer ) const; +}; + + +class QgsWfs3CollectionsFeatureHandler: public QgsServerOgcApiHandler +{ + public: + QgsWfs3CollectionsFeatureHandler( ); + void handleRequest( const QgsServerApiContext &context ) const override; + QRegularExpression path() const override { return QRegularExpression( R"re(/collections/(?[^/]+)/items/(?[^/]+?)(\.json|\.geojson|\.html)?$)re" ); } + std::string operationId() const override { return "getFeature"; } + std::string description() const override { return "Retrieve a feature; use content negotiation or specify a file extension to request HTML (.html or GeoJSON (.json)"; } + std::string summary() const override { return "Retrieve a single feature"; } + std::string linkTitle() const override { return "Retrieve a feature"; } + QStringList tags() const override { return { QStringLiteral( "Features" ) }; } + QList contentTypes() const override { return { QgsServerOgcApi::ContentType::GEOJSON, QgsServerOgcApi::ContentType::HTML }; } + QgsServerOgcApi::Rel linkType() const override { return QgsServerOgcApi::Rel::data; } + QgsServerOgcApi::ContentType defaultContentType() const override { return QgsServerOgcApi::ContentType::GEOJSON; } + json schema( const QgsServerApiContext &context ) const override; +}; + + +#endif // QGS_WFS3_HANDLERS_H diff --git a/src/server/services/wms/CMakeLists.txt b/src/server/services/wms/CMakeLists.txt index 9ec1220ebcd..02c5eb38c7c 100644 --- a/src/server/services/wms/CMakeLists.txt +++ b/src/server/services/wms/CMakeLists.txt @@ -42,6 +42,7 @@ INCLUDE_DIRECTORIES(SYSTEM ) INCLUDE_DIRECTORIES( + ${CMAKE_SOURCE_DIR}/external ${CMAKE_SOURCE_DIR}/src/core ${CMAKE_SOURCE_DIR}/src/core/annotations ${CMAKE_SOURCE_DIR}/src/core/expression diff --git a/src/server/services/wmts/CMakeLists.txt b/src/server/services/wmts/CMakeLists.txt index 475883a8872..3fc26909e1f 100644 --- a/src/server/services/wmts/CMakeLists.txt +++ b/src/server/services/wmts/CMakeLists.txt @@ -29,6 +29,7 @@ INCLUDE_DIRECTORIES(SYSTEM ) INCLUDE_DIRECTORIES( + ${CMAKE_SOURCE_DIR}/external ${CMAKE_BINARY_DIR}/src/core ${CMAKE_BINARY_DIR}/src/python ${CMAKE_BINARY_DIR}/src/analysis diff --git a/tests/src/core/testqgsexpression.cpp b/tests/src/core/testqgsexpression.cpp index dfd3f8527b5..4d1b9750b7c 100644 --- a/tests/src/core/testqgsexpression.cpp +++ b/tests/src/core/testqgsexpression.cpp @@ -1,4 +1,4 @@ -/*************************************************************************** +/************************************************************************** test_template.cpp -------------------------------------- Date : Sun Sep 16 12:22:23 AKDT 2007 diff --git a/tests/src/python/CMakeLists.txt b/tests/src/python/CMakeLists.txt index f7d0f25bf7c..65e8542b77f 100644 --- a/tests/src/python/CMakeLists.txt +++ b/tests/src/python/CMakeLists.txt @@ -293,6 +293,8 @@ IF (WITH_SERVER) ADD_PYTHON_TEST(PyQgsServerLogger test_qgsserverlogger.py) ADD_PYTHON_TEST(PyQgsServerPlugins test_qgsserver_plugins.py) ADD_PYTHON_TEST(PyQgsServerWMS test_qgsserver_wms.py) + ADD_PYTHON_TEST(PyQgsServerApi test_qgsserver_api.py) + ADD_PYTHON_TEST(PyQgsServerApiContext test_qgsserver_apicontext.py) ADD_PYTHON_TEST(PyQgsServerWMSGetMap test_qgsserver_wms_getmap.py) ADD_PYTHON_TEST(PyQgsServerWMSGetMapSizeProject test_qgsserver_wms_getmap_size_project.py) ADD_PYTHON_TEST(PyQgsServerWMSGetMapSizeServer test_qgsserver_wms_getmap_size_server.py) diff --git a/tests/src/python/test_qgsserver_api.py b/tests/src/python/test_qgsserver_api.py new file mode 100644 index 00000000000..750450dd37a --- /dev/null +++ b/tests/src/python/test_qgsserver_api.py @@ -0,0 +1,590 @@ +# -*- coding: utf-8 -*- +"""QGIS Unit tests for QgsServer API. + +.. 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__ = '17/04/2019' +__copyright__ = 'Copyright 2019, The QGIS Project' +# This will get replaced with a git SHA1 when you do a git archive +__revision__ = '$Format:%H$' + +import os +import json +import re + +# Deterministic XML +os.environ['QT_HASH_SEED'] = '1' + +from qgis.server import ( + QgsBufferServerRequest, + QgsBufferServerResponse, + QgsServerApi, + QgsServerApiBadRequestException, + QgsServerQueryStringParameter, + QgsServerApiContext, + QgsServerOgcApi, + QgsServerOgcApiHandler, + QgsServerApiUtils, + QgsServiceRegistry +) +from qgis.core import QgsProject, QgsRectangle +from qgis.PyQt import QtCore + +from qgis.testing import unittest +from utilities import unitTestDataPath +from urllib import parse + +import tempfile + +from test_qgsserver import QgsServerTestBase + + +class QgsServerAPIUtilsTest(QgsServerTestBase): + """ QGIS API server utils tests""" + + def test_parse_bbox(self): + + bbox = QgsServerApiUtils.parseBbox('8.203495,44.901482,8.203497,44.901484') + self.assertEquals(bbox.xMinimum(), 8.203495) + self.assertEquals(bbox.yMinimum(), 44.901482) + self.assertEquals(bbox.xMaximum(), 8.203497) + self.assertEquals(bbox.yMaximum(), 44.901484) + + bbox = QgsServerApiUtils.parseBbox('8.203495,44.901482,100,8.203497,44.901484,120') + self.assertEquals(bbox.xMinimum(), 8.203495) + self.assertEquals(bbox.yMinimum(), 44.901482) + self.assertEquals(bbox.xMaximum(), 8.203497) + self.assertEquals(bbox.yMaximum(), 44.901484) + + bbox = QgsServerApiUtils.parseBbox('something_wrong_here') + self.assertTrue(bbox.isEmpty()) + bbox = QgsServerApiUtils.parseBbox('8.203495,44.901482,8.203497,something_wrong_here') + self.assertTrue(bbox.isEmpty()) + + def test_published_crs(self): + """Test published WMS CRSs""" + + project = QgsProject() + project.read(unitTestDataPath('qgis_server') + '/test_project.qgs') + crss = QgsServerApiUtils.publishedCrsList(project) + self.assertTrue('http://www.opengis.net/def/crs/OGC/1.3/CRS84' in crss) + self.assertTrue('http://www.opengis.net/def/crs/EPSG/9.6.2/3857' in crss) + self.assertTrue('http://www.opengis.net/def/crs/EPSG/9.6.2/4326' in crss) + + def test_parse_crs(self): + + crs = QgsServerApiUtils.parseCrs('http://www.opengis.net/def/crs/OGC/1.3/CRS84') + self.assertTrue(crs.isValid()) + self.assertEquals(crs.postgisSrid(), 4326) + + crs = QgsServerApiUtils.parseCrs('http://www.opengis.net/def/crs/EPSG/9.6.2/3857') + self.assertTrue(crs.isValid()) + self.assertEquals(crs.postgisSrid(), 3857) + + crs = QgsServerApiUtils.parseCrs('http://www.opengis.net/something_wrong_here') + self.assertFalse(crs.isValid()) + + def test_append_path(self): + + path = QgsServerApiUtils.appendMapParameter('/wfs3', QtCore.QUrl('https://www.qgis.org/wfs3?MAP=/some/path')) + self.assertEquals(path, '/wfs3?MAP=/some/path') + + +class API(QgsServerApi): + + def __init__(self, iface, version='1.0'): + super().__init__(iface) + self._version = version + + def name(self): + return "TEST" + + def version(self): + return self._version + + def rootPath(self): + return "/testapi" + + def executeRequest(self, request_context): + request_context.response().write(b"\"Test API\"") + + +class QgsServerAPITestBase(QgsServerTestBase): + """ QGIS API server tests""" + + # Set to True in child classes to re-generate reference files for this class + regeregenerate_api_reference = False + + def dump(self, response): + """Returns the response body as str""" + + result = [] + for n, v in response.headers().items(): + if n == 'Content-Length': + continue + result.append("%s: %s" % (n, v)) + result.append('') + result.append(bytes(response.body()).decode('utf8')) + return '\n'.join(result) + + def compareApi(self, request, project, reference_file): + response = QgsBufferServerResponse() + # Add json to accept it reference_file is JSON + if reference_file.endswith('.json'): + request.setHeader('Accept', 'application/json') + self.server.handleRequest(request, response, project) + result = bytes(response.body()).decode('utf8') if reference_file.endswith('html') else self.dump(response) + path = unitTestDataPath('qgis_server') + '/api/' + reference_file + if self.regeregenerate_api_reference: + f = open(path.encode('utf8'), 'w+', encoding='utf8') + f.write(result) + f.close() + print("Reference file %s regenerated!" % path.encode('utf8')) + + def __normalize_json(content): + reference_content = content.split('\n') + j = ''.join(reference_content[reference_content.index('') + 1:]) + # Do not test timeStamp + j = json.loads(j) + try: + j['timeStamp'] = '2019-07-05T12:27:07Z' + except: + pass + json_content = json.dumps(j) + headers_content = '\n'.join(reference_content[:reference_content.index('') + 1]) + return headers_content + '\n' + json_content + + with open(path.encode('utf8'), 'r', encoding='utf8') as f: + if reference_file.endswith('json'): + self.assertEqual(__normalize_json(result), __normalize_json(f.read())) + else: + self.assertEqual(f.read(), result) + + return response + + def compareContentType(self, url, headers, content_type): + request = QgsBufferServerRequest(url, headers=headers) + response = QgsBufferServerResponse() + self.server.handleRequest(request, response, QgsProject()) + self.assertEqual(response.headers()['Content-Type'], content_type) + + @classmethod + def setUpClass(cls): + super(QgsServerAPITestBase, cls).setUpClass() + cls.maxDiff = None + + +class QgsServerAPITest(QgsServerAPITestBase): + """ QGIS API server tests""" + + def test_api(self): + """Test API registering""" + + api = API(self.server.serverInterface()) + self.server.serverInterface().serviceRegistry().registerApi(api) + request = QgsBufferServerRequest('http://server.qgis.org/testapi') + self.compareApi(request, None, 'test_api.json') + self.server.serverInterface().serviceRegistry().unregisterApi(api.name()) + + def test_0_version_registration(self): + + reg = QgsServiceRegistry() + api = API(self.server.serverInterface()) + api1 = API(self.server.serverInterface(), '1.1') + + # 1.1 comes first + reg.registerApi(api1) + reg.registerApi(api) + + rapi = reg.getApi("TEST") + self.assertIsNotNone(rapi) + self.assertEqual(rapi.version(), "1.1") + + rapi = reg.getApi("TEST", "2.0") + self.assertIsNotNone(rapi) + self.assertEqual(rapi.version(), "1.1") + + rapi = reg.getApi("TEST", "1.0") + self.assertIsNotNone(rapi) + self.assertEqual(rapi.version(), "1.0") + + def test_1_unregister_services(self): + + reg = QgsServiceRegistry() + api = API(self.server.serverInterface(), '1.0a') + api1 = API(self.server.serverInterface(), '1.0b') + api2 = API(self.server.serverInterface(), '1.0c') + + reg.registerApi(api) + reg.registerApi(api1) + reg.registerApi(api2) + + # Check we get the default version + rapi = reg.getApi("TEST") + self.assertEqual(rapi.version(), "1.0a") + + # Remove one service + removed = reg.unregisterApi("TEST", "1.0a") + self.assertEqual(removed, 1) + + # Check that we get the highest version + rapi = reg.getApi("TEST") + self.assertEqual(rapi.version(), "1.0c") + + # Remove all services + removed = reg.unregisterApi("TEST") + self.assertEqual(removed, 2) + + # Check that there is no more services available + api = reg.getApi("TEST") + self.assertIsNone(api) + + def test_wfs3_landing_page(self): + """Test WFS3 API landing page in HTML format""" + + request = QgsBufferServerRequest('http://server.qgis.org/wfs3.html') + self.compareApi(request, None, 'test_wfs3_landing_page.html') + + def test_content_type_negotiation(self): + """Test content-type negotiation and conflicts""" + + # Default: json + self.compareContentType('http://server.qgis.org/wfs3', {}, 'application/json') + # Explicit request + self.compareContentType('http://server.qgis.org/wfs3', {'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'}, 'text/html') + self.compareContentType('http://server.qgis.org/wfs3', {'Accept': 'application/json'}, 'application/json') + # File suffix + self.compareContentType('http://server.qgis.org/wfs3.json', {}, 'application/json') + self.compareContentType('http://server.qgis.org/wfs3.html', {}, 'text/html') + # File extension must take precedence over Accept header + self.compareContentType('http://server.qgis.org/wfs3.html', {'Accept': 'application/json'}, 'text/html') + self.compareContentType('http://server.qgis.org/wfs3.json', {'Accept': 'text/html'}, 'application/json') + + def test_wfs3_landing_page_json(self): + """Test WFS3 API landing page in JSON format""" + request = QgsBufferServerRequest('http://server.qgis.org/wfs3.json') + self.compareApi(request, None, 'test_wfs3_landing_page.json') + request = QgsBufferServerRequest('http://server.qgis.org/wfs3') + request.setHeader('Accept', 'application/json') + self.compareApi(request, None, 'test_wfs3_landing_page.json') + + def test_wfs3_api(self): + """Test WFS3 API""" + + # No project: error + request = QgsBufferServerRequest('http://server.qgis.org/wfs3/api.openapi3') + self.compareApi(request, None, 'test_wfs3_api.json') + + request = QgsBufferServerRequest('http://server.qgis.org/wfs3/api.openapi3') + project = QgsProject() + project.read(unitTestDataPath('qgis_server') + '/test_project.qgs') + self.compareApi(request, project, 'test_wfs3_api_project.json') + + def test_wfs3_conformance(self): + """Test WFS3 API""" + request = QgsBufferServerRequest('http://server.qgis.org/wfs3/conformance') + self.compareApi(request, None, 'test_wfs3_conformance.json') + + def test_wfs3_collections_empty(self): + """Test WFS3 collections API""" + + request = QgsBufferServerRequest('http://server.qgis.org/wfs3/collections') + self.compareApi(request, None, 'test_wfs3_collections_empty.json') + request = QgsBufferServerRequest('http://server.qgis.org/wfs3/collections.json') + self.compareApi(request, None, 'test_wfs3_collections_empty.json') + request = QgsBufferServerRequest('http://server.qgis.org/wfs3/collections.html') + self.compareApi(request, None, 'test_wfs3_collections_empty.html') + + def test_wfs3_collections_json(self): + """Test WFS3 API collections in json format""" + request = QgsBufferServerRequest('http://server.qgis.org/wfs3/collections.json') + project = QgsProject() + project.read(unitTestDataPath('qgis_server') + '/test_project.qgs') + self.compareApi(request, project, 'test_wfs3_collections_project.json') + + def test_wfs3_collections_html(self): + """Test WFS3 API collections in html format""" + request = QgsBufferServerRequest('http://server.qgis.org/wfs3/collections.html') + project = QgsProject() + project.read(unitTestDataPath('qgis_server') + '/test_project.qgs') + self.compareApi(request, project, 'test_wfs3_collections_project.html') + + def test_wfs3_collections_content_type(self): + """Test WFS3 API collections in html format with Accept header""" + + request = QgsBufferServerRequest('http://server.qgis.org/wfs3/collections') + request.setHeader('Accept', 'text/html') + project = QgsProject() + project.read(unitTestDataPath('qgis_server') + '/test_project.qgs') + response = QgsBufferServerResponse() + self.server.handleRequest(request, response, project) + self.assertEqual(response.headers()['Content-Type'], 'text/html') + + def test_wfs3_collection_items(self): + """Test WFS3 API items""" + project = QgsProject() + project.read(unitTestDataPath('qgis_server') + '/test_project.qgs') + request = QgsBufferServerRequest('http://server.qgis.org/wfs3/collections/testlayer%20èé/items') + self.compareApi(request, project, 'test_wfs3_collections_items_testlayer_èé.json') + + def test_wfs3_collection_items_crs(self): + """Test WFS3 API items with CRS""" + project = QgsProject() + project.read(unitTestDataPath('qgis_server') + '/test_project.qgs') + encoded_crs = parse.quote('http://www.opengis.net/def/crs/EPSG/9.6.2/3857', safe='') + request = QgsBufferServerRequest('http://server.qgis.org/wfs3/collections/testlayer%20èé/items?crs={}'.format(encoded_crs)) + self.compareApi(request, project, 'test_wfs3_collections_items_testlayer_èé_crs_3857.json') + + def test_invalid_args(self): + """Test wrong args""" + project = QgsProject() + project.read(unitTestDataPath('qgis_server') + '/test_project.qgs') + request = QgsBufferServerRequest('http://server.qgis.org/wfs3/collections/testlayer%20èé/items?limit=-1') + response = QgsBufferServerResponse() + self.server.handleRequest(request, response, project) + self.assertEqual(response.statusCode(), 400) # Bad request + self.assertEqual(response.body(), b'[{"code":"Bad request error","description":"Argument \'limit\' is not valid. Number of features to retrieve [0-10000]"}]') # Bad request + + request = QgsBufferServerRequest('http://server.qgis.org/wfs3/collections/testlayer%20èé/items?limit=10001') + response = QgsBufferServerResponse() + self.server.handleRequest(request, response, project) + self.assertEqual(response.statusCode(), 400) # Bad request + self.assertEqual(response.body(), b'[{"code":"Bad request error","description":"Argument \'limit\' is not valid. Number of features to retrieve [0-10000]"}]') # Bad request + + def test_wfs3_collection_items_limit(self): + """Test WFS3 API item limits""" + project = QgsProject() + project.read(unitTestDataPath('qgis_server') + '/test_project.qgs') + request = QgsBufferServerRequest('http://server.qgis.org/wfs3/collections/testlayer%20èé/items?limit=1') + self.compareApi(request, project, 'test_wfs3_collections_items_testlayer_èé_limit_1.json') + + def test_wfs3_collection_items_limit_offset(self): + """Test WFS3 API offset""" + project = QgsProject() + project.read(unitTestDataPath('qgis_server') + '/test_project.qgs') + request = QgsBufferServerRequest('http://server.qgis.org/wfs3/collections/testlayer%20èé/items?limit=1&offset=1') + self.compareApi(request, project, 'test_wfs3_collections_items_testlayer_èé_limit_1_offset_1.json') + request = QgsBufferServerRequest('http://server.qgis.org/wfs3/collections/testlayer%20èé/items?limit=1&offset=-1') + response = QgsBufferServerResponse() + self.server.handleRequest(request, response, project) + self.assertEqual(response.statusCode(), 400) # Bad request + self.assertEqual(response.body(), b'[{"code":"Bad request error","description":"Argument \'offset\' is not valid. Offset for features to retrieve [0-3]"}]') # Bad request + request = QgsBufferServerRequest('http://server.qgis.org/wfs3/collections/testlayer%20èé/items?limit=-1&offset=1') + response = QgsBufferServerResponse() + self.server.handleRequest(request, response, project) + self.assertEqual(response.statusCode(), 400) # Bad request + self.assertEqual(response.body(), b'[{"code":"Bad request error","description":"Argument \'limit\' is not valid. Number of features to retrieve [0-10000]"}]') # Bad request + + def test_wfs3_collection_items_bbox(self): + """Test WFS3 API bbox""" + project = QgsProject() + project.read(unitTestDataPath('qgis_server') + '/test_project.qgs') + request = QgsBufferServerRequest('http://server.qgis.org/wfs3/collections/testlayer%20èé/items?bbox=8.203495,44.901482,8.203497,44.901484') + self.compareApi(request, project, 'test_wfs3_collections_items_testlayer_èé_bbox.json') + + # Test with a different CRS + encoded_crs = parse.quote('http://www.opengis.net/def/crs/EPSG/9.6.2/3857', safe='') + request = QgsBufferServerRequest('http://server.qgis.org/wfs3/collections/testlayer%20èé/items?bbox=913191,5606014,913234,5606029&bbox-crs={}'.format(encoded_crs)) + self.compareApi(request, project, 'test_wfs3_collections_items_testlayer_èé_bbox_3857.json') + + def test_wfs3_static_handler(self): + """Test static handler""" + request = QgsBufferServerRequest('http://server.qgis.org/wfs3/static/style.css') + response = QgsBufferServerResponse() + self.server.handleRequest(request, response, None) + body = bytes(response.body()).decode('utf8') + self.assertTrue('Content-Length' in response.headers()) + self.assertEqual(response.headers()['Content-Type'], 'text/css') + self.assertTrue(len(body) > 0) + + request = QgsBufferServerRequest('http://server.qgis.org/wfs3/static/does_not_exists.css') + response = QgsBufferServerResponse() + self.server.handleRequest(request, response, None) + body = bytes(response.body()).decode('utf8') + self.assertEqual(body, '[{"code":"API not found error","description":"Static file does_not_exists.css was not found"}]') + + def test_wfs3_field_filters(self): + """Test field filters""" + project = QgsProject() + project.read(unitTestDataPath('qgis_server') + '/test_project.qgs') + request = QgsBufferServerRequest('http://server.qgis.org/wfs3/collections/testlayer3/items?name=two') + self.compareApi(request, project, 'test_wfs3_collections_items_testlayer3_name_eq_two.json') + + def test_wfs3_field_filters_star(self): + """Test field filters""" + project = QgsProject() + project.read(unitTestDataPath('qgis_server') + '/test_project.qgs') + request = QgsBufferServerRequest('http://server.qgis.org/wfs3/collections/testlayer3/items?name=tw*') + self.compareApi(request, project, 'test_wfs3_collections_items_testlayer3_name_eq_tw_star.json') + + +class Handler1(QgsServerOgcApiHandler): + + def path(self): + return QtCore.QRegularExpression("/handlerone") + + def operationId(self): + return "handlerOne" + + def summary(self): + return "First of its name" + + def description(self): + return "The first handler ever" + + def linkTitle(self): + return "Handler One Link Title" + + def linkType(self): + return QgsServerOgcApi.data + + def handleRequest(self, context): + """Simple mirror: returns the parameters""" + + params = self.values(context) + self.write(params, context) + + def parameters(self, context): + return [QgsServerQueryStringParameter('value1', True, QgsServerQueryStringParameter.Type.Double, 'a double value')] + + +class Handler2(QgsServerOgcApiHandler): + + def path(self): + return QtCore.QRegularExpression(r"/handlertwo/(?P\d{2})/(\d{3})") + + def operationId(self): + return "handlerTwo" + + def summary(self): + return "Second of its name" + + def description(self): + return "The second handler ever" + + def linkTitle(self): + return "Handler Two Link Title" + + def linkType(self): + return QgsServerOgcApi.data + + def handleRequest(self, context): + """Simple mirror: returns the parameters""" + + params = self.values(context) + self.write(params, context) + + def parameters(self, context): + return [QgsServerQueryStringParameter('value1', True, QgsServerQueryStringParameter.Type.Double, 'a double value'), + QgsServerQueryStringParameter('value2', False, QgsServerQueryStringParameter.Type.String, 'a string value'), ] + + +class QgsServerOgcAPITest(QgsServerAPITestBase): + """ QGIS OGC API server tests""" + + def testOgcApi(self): + """Test OGC API""" + + api = QgsServerOgcApi(self.server.serverInterface(), '/api1', 'apione', 'an api', '1.1') + self.assertEqual(api.name(), 'apione') + self.assertEqual(api.description(), 'an api') + self.assertEqual(api.version(), '1.1') + self.assertEqual(api.rootPath(), '/api1') + url = 'http://server.qgis.org/wfs3/collections/testlayer%20èé/items?limit=-1' + self.assertEqual(api.sanitizeUrl(QtCore.QUrl(url)).toString(), 'http://server.qgis.org/wfs3/collections/testlayer \xe8\xe9/items?limit=-1') + self.assertEqual(api.sanitizeUrl(QtCore.QUrl('/path//double//slashes//#fr')).toString(), '/path/double/slashes#fr') + self.assertEqual(api.relToString(QgsServerOgcApi.data), 'data') + self.assertEqual(api.relToString(QgsServerOgcApi.alternate), 'alternate') + self.assertEqual(api.contentTypeToString(QgsServerOgcApi.JSON), 'JSON') + self.assertEqual(api.contentTypeToStdString(QgsServerOgcApi.JSON), 'JSON') + self.assertEqual(api.contentTypeToExtension(QgsServerOgcApi.JSON), 'json') + self.assertEqual(api.contentTypeToExtension(QgsServerOgcApi.GEOJSON), 'geojson') + + def testOgcApiHandler(self): + """Test OGC API Handler""" + + project = QgsProject() + project.read(unitTestDataPath('qgis_server') + '/test_project.qgs') + request = QgsBufferServerRequest('http://server.qgis.org/wfs3/collections/testlayer%20èé/items?limit=-1') + response = QgsBufferServerResponse() + + ctx = QgsServerApiContext('/services/api1', request, response, project, self.server.serverInterface()) + h = Handler1() + self.assertTrue(h.staticPath(ctx).endswith('/resources/server/api/ogc/static')) + self.assertEqual(h.path(), QtCore.QRegularExpression("/handlerone")) + self.assertEqual(h.description(), 'The first handler ever') + self.assertEqual(h.operationId(), 'handlerOne') + self.assertEqual(h.summary(), 'First of its name') + self.assertEqual(h.linkTitle(), 'Handler One Link Title') + self.assertEqual(h.linkType(), QgsServerOgcApi.data) + with self.assertRaises(QgsServerApiBadRequestException) as ex: + h.handleRequest(ctx) + self.assertEqual(str(ex.exception), 'Missing required argument: \'value1\'') + + r = ctx.response() + self.assertEqual(r.data(), '') + + with self.assertRaises(QgsServerApiBadRequestException) as ex: + h.values(ctx) + self.assertEqual(str(ex.exception), 'Missing required argument: \'value1\'') + + # Add handler to API and test for /api2 + ctx = QgsServerApiContext('/services/api2', request, response, project, self.server.serverInterface()) + api = QgsServerOgcApi(self.server.serverInterface(), '/api2', 'apitwo', 'a second api', '1.2') + api.registerHandler(h) + # Add a second handler (will be tested later) + h2 = Handler2() + api.registerHandler(h2) + + ctx.request().setUrl(QtCore.QUrl('http://www.qgis.org/services/api1')) + with self.assertRaises(QgsServerApiBadRequestException) as ex: + api.executeRequest(ctx) + self.assertEqual(str(ex.exception), 'Requested URI does not match any registered API handler') + + ctx.request().setUrl(QtCore.QUrl('http://www.qgis.org/services/api2')) + with self.assertRaises(QgsServerApiBadRequestException) as ex: + api.executeRequest(ctx) + self.assertEqual(str(ex.exception), 'Requested URI does not match any registered API handler') + + ctx.request().setUrl(QtCore.QUrl('http://www.qgis.org/services/api2/handlerone')) + with self.assertRaises(QgsServerApiBadRequestException) as ex: + api.executeRequest(ctx) + self.assertEqual(str(ex.exception), 'Missing required argument: \'value1\'') + + ctx.request().setUrl(QtCore.QUrl('http://www.qgis.org/services/api2/handlerone?value1=not+a+double')) + with self.assertRaises(QgsServerApiBadRequestException) as ex: + api.executeRequest(ctx) + self.assertEqual(str(ex.exception), 'Argument \'value1\' could not be converted to Double') + + ctx.request().setUrl(QtCore.QUrl('http://www.qgis.org/services/api2/handlerone?value1=1.2345')) + params = h.values(ctx) + self.assertEqual(params, {'value1': 1.2345}) + api.executeRequest(ctx) + self.assertEqual(json.loads(bytes(ctx.response().data()))['value1'], 1.2345) + + # Test path fragments extraction + ctx.request().setUrl(QtCore.QUrl('http://www.qgis.org/services/api2/handlertwo/00/555?value1=1.2345')) + params = h2.values(ctx) + self.assertEqual(params, {'code1': '00', 'value1': 1.2345, 'value2': None}) + + # Test string encoding + ctx.request().setUrl(QtCore.QUrl('http://www.qgis.org/services/api2/handlertwo/00/555?value1=1.2345&value2=a%2Fstring%20some')) + params = h2.values(ctx) + self.assertEqual(params, {'code1': '00', 'value1': 1.2345, 'value2': 'a/string some'}) + + # Test links + self.assertEqual(h2.href(ctx), 'http://www.qgis.org/services/api2/handlertwo/00/555?value1=1.2345&value2=a%2Fstring%20some') + self.assertEqual(h2.href(ctx, '/extra'), 'http://www.qgis.org/services/api2/handlertwo/00/555/extra?value1=1.2345&value2=a%2Fstring%20some') + self.assertEqual(h2.href(ctx, '/extra', 'json'), 'http://www.qgis.org/services/api2/handlertwo/00/555/extra.json?value1=1.2345&value2=a%2Fstring%20some') + + # Test template path + self.assertTrue(h2.templatePath(ctx).endswith('/resources/server/api/ogc/templates/services/api2/handlerTwo.html')) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/src/python/test_qgsserver_apicontext.py b/tests/src/python/test_qgsserver_apicontext.py new file mode 100644 index 00000000000..d0f5d1526ce --- /dev/null +++ b/tests/src/python/test_qgsserver_apicontext.py @@ -0,0 +1,58 @@ +# -*- coding: utf-8 -*- +"""QGIS Unit tests for QgsServerApiContext class. + +.. 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__ = '11/07/2019' +__copyright__ = 'Copyright 2019, The QGIS Project' +# This will get replaced with a git SHA1 when you do a git archive +__revision__ = '$Format:%H$' + +import os +import json +import re + +# Deterministic XML +os.environ['QT_HASH_SEED'] = '1' + +from qgis.server import ( + QgsBufferServerRequest, + QgsBufferServerResponse, + QgsServerApiContext +) +from qgis.testing import unittest +from utilities import unitTestDataPath +from urllib import parse + +import tempfile + +from test_qgsserver import QgsServerTestBase + + +class QgsServerApiContextsTest(QgsServerTestBase): + """ QGIS Server API context tests""" + + def testMatchedPath(self): + """Test path extraction""" + + response = QgsBufferServerResponse() + request = QgsBufferServerRequest("http://www.qgis.org/services/wfs3") + context = QgsServerApiContext("/wfs3", request, response, None, None) + self.assertEqual(context.matchedPath(), "/services/wfs3") + + request = QgsBufferServerRequest("http://www.qgis.org/services/wfs3/collections.hml") + context = QgsServerApiContext("/wfs3", request, response, None, None) + self.assertEqual(context.matchedPath(), "/services/wfs3") + + request = QgsBufferServerRequest("http://www.qgis.org/services/wfs3/collections.hml") + context = QgsServerApiContext("/wfs4", request, response, None, None) + self.assertEqual(context.matchedPath(), "") + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/src/python/test_qgsserver_services.py b/tests/src/python/test_qgsserver_services.py index f074ac538d6..980b36557f4 100644 --- a/tests/src/python/test_qgsserver_services.py +++ b/tests/src/python/test_qgsserver_services.py @@ -124,11 +124,11 @@ class TestServices(unittest.TestCase): def test_0_version_registration(self): reg = QgsServiceRegistry() - myserv1 = MyService("TEST", "1.1", "Hello") - myserv2 = MyService("TEST", "1.0", "Hello") + myserv11 = MyService("TEST", "1.1", "Hello") + myserv10 = MyService("TEST", "1.0", "Hello") - reg.registerService(myserv1) - reg.registerService(myserv2) + reg.registerService(myserv11) + reg.registerService(myserv10) service = reg.getService("TEST") self.assertIsNotNone(service) diff --git a/tests/src/server/CMakeLists.txt b/tests/src/server/CMakeLists.txt index 4c6b36b5b5e..dfeb6f7939c 100644 --- a/tests/src/server/CMakeLists.txt +++ b/tests/src/server/CMakeLists.txt @@ -1 +1,47 @@ ADD_SUBDIRECTORY(wms) + +INCLUDE_DIRECTORIES(${CMAKE_CURRENT_SOURCE_DIR} + ${CMAKE_CURRENT_BINARY_DIR} + ${CMAKE_SOURCE_DIR}/external + ${CMAKE_BINARY_DIR}/src/core + ${CMAKE_BINARY_DIR}/src/server + ${CMAKE_SOURCE_DIR}/src/core + ${CMAKE_SOURCE_DIR}/src/core/geometry + ${CMAKE_SOURCE_DIR}/src/server + ${CMAKE_SOURCE_DIR}/src/test +) + +#note for tests we should not include the moc of our +#qtests in the executable file list as the moc is +#directly included in the sources +#and should not be compiled twice. Trying to include +#them in will cause an error at build time + +#No relinking and full RPATH for the install tree +#See: http://www.cmake.org/Wiki/CMake_RPATH_handling#No_relinking_and_full_RPATH_for_the_install_tree + +MACRO (ADD_QGIS_TEST TESTSRC) + SET (TESTNAME ${TESTSRC}) + STRING(REPLACE "test" "" TESTNAME ${TESTNAME}) + STRING(REPLACE "qgs" "" TESTNAME ${TESTNAME}) + STRING(REPLACE ".cpp" "" TESTNAME ${TESTNAME}) + SET (TESTNAME "qgis_${TESTNAME}test") + ADD_EXECUTABLE(${TESTNAME} ${TESTSRC} ${util_SRCS}) + SET_TARGET_PROPERTIES(${TESTNAME} PROPERTIES AUTOMOC TRUE) + TARGET_LINK_LIBRARIES(${TESTNAME} + ${Qt5Core_LIBRARIES} + ${Qt5Test_LIBRARIES} + qgis_server) + ADD_TEST(${TESTNAME} ${CMAKE_BINARY_DIR}/output/bin/${TESTNAME} -maxwarnings 10000) +ENDMACRO (ADD_QGIS_TEST) + +############################################################# +# Tests: + +SET(TESTS + testqgsserverquerystringparameter.cpp +) + +FOREACH(TESTSRC ${TESTS}) + ADD_QGIS_TEST(${TESTSRC}) +ENDFOREACH(TESTSRC) diff --git a/tests/src/server/testqgsserverquerystringparameter.cpp b/tests/src/server/testqgsserverquerystringparameter.cpp new file mode 100644 index 00000000000..0c7619303d3 --- /dev/null +++ b/tests/src/server/testqgsserverquerystringparameter.cpp @@ -0,0 +1,177 @@ +/*************************************************************************** + + testqgsserverquerystringparameter.cpp + -------------------------------------- + Date : Jul 10 2019 + Copyright : (C) 2019 by Alessandro Pasotti + Email : elpaso at itopen dot it + *************************************************************************** + * * + * 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 "qgstest.h" +#include +#include +#include + +//qgis includes... +#include "qgsserverquerystringparameter.h" +#include "qgsserverapicontext.h" +#include "qgsserverrequest.h" +#include "qgsserverexception.h" + +/** + * \ingroup UnitTests + * Unit tests for the server query string parameter + */ +class TestQgsServerQueryStringParameter : public QObject +{ + Q_OBJECT + + public: + TestQgsServerQueryStringParameter() = default; + + private slots: + // will be called before the first testfunction is executed. + void initTestCase(); + + // will be called after the last testfunction was executed. + void cleanupTestCase(); + + // will be called before each testfunction is executed + void init(); + + // will be called after every testfunction. + void cleanup(); + + // Basic test on types and constraints + void testArguments(); + + // Test custom validators + void testCustomValidators(); + + // Test default values + void testDefaultValues(); +}; + + +void TestQgsServerQueryStringParameter::initTestCase() +{ + QgsApplication::init(); + QgsApplication::initQgis(); + QgsApplication::showSettings(); +} + +void TestQgsServerQueryStringParameter::cleanupTestCase() +{ + QgsApplication::exitQgis(); +} + +void TestQgsServerQueryStringParameter::init() +{ +} + +void TestQgsServerQueryStringParameter::cleanup() +{ +} + +void TestQgsServerQueryStringParameter::testArguments() +{ + QgsServerQueryStringParameter p { QStringLiteral( "parameter1" ) }; + QgsServerRequest request; + QgsServerApiContext ctx { "/wfs3", &request, nullptr, nullptr, nullptr }; + + // Test string (default) + request.setUrl( QStringLiteral( "http://www.qgis.org/api/?parameter1=123" ) ); + QCOMPARE( p.value( ctx ).toString(), QString( "123" ) ); + QCOMPARE( p.value( ctx ).type(), QVariant::String ); + request.setUrl( QStringLiteral( "http://www.qgis.org/api/?parameter1=a%20string" ) ); + QCOMPARE( p.value( ctx ).toString(), QString( "a string" ) ); + QCOMPARE( p.value( ctx ).type(), QVariant::String ); + request.setUrl( QStringLiteral( "http://www.qgis.org/api/" ) ); + QCOMPARE( p.value( ctx ).toString(), QString() ); + + // Test required + p.mRequired = true; + request.setUrl( QStringLiteral( "http://www.qgis.org/api/" ) ); + QVERIFY_EXCEPTION_THROWN( p.value( ctx ), QgsServerApiBadRequestException ); + + // Test int + p.mType = QgsServerQueryStringParameter::Type::Integer; + request.setUrl( QStringLiteral( "http://www.qgis.org/api/?parameter1=123" ) ); + QCOMPARE( p.value( ctx ).toInt(), 123 ); + QCOMPARE( p.value( ctx ).type(), QVariant::LongLong ); + request.setUrl( QStringLiteral( "http://www.qgis.org/api/?parameter1=a%20string" ) ); + QVERIFY_EXCEPTION_THROWN( p.value( ctx ), QgsServerApiBadRequestException ); + + // Test double + p.mType = QgsServerQueryStringParameter::Type::Double; + request.setUrl( QStringLiteral( "http://www.qgis.org/api/?parameter1=123" ) ); + QCOMPARE( p.value( ctx ).toDouble(), 123.0 ); + QCOMPARE( p.value( ctx ).type(), QVariant::Double ); + request.setUrl( QStringLiteral( "http://www.qgis.org/api/?parameter1=123.456" ) ); + QCOMPARE( p.value( ctx ).toDouble(), 123.456 ); + QCOMPARE( p.value( ctx ).type(), QVariant::Double ); + request.setUrl( QStringLiteral( "http://www.qgis.org/api/?parameter1=a%20string" ) ); + QVERIFY_EXCEPTION_THROWN( p.value( ctx ), QgsServerApiBadRequestException ); + + // Test list + p.mType = QgsServerQueryStringParameter::Type::List; + request.setUrl( QStringLiteral( "http://www.qgis.org/api/?parameter1=123,a%20value" ) ); + QCOMPARE( p.value( ctx ).toStringList(), QStringList() << QStringLiteral( "123" ) << QStringLiteral( "a value" ) ); + QCOMPARE( p.value( ctx ).type(), QVariant::StringList ); + request.setUrl( QStringLiteral( "http://www.qgis.org/api/?parameter1=a%20value" ) ); + QCOMPARE( p.value( ctx ).toStringList(), QStringList() << QStringLiteral( "a value" ) ); + QCOMPARE( p.value( ctx ).type(), QVariant::StringList ); + +} + +void TestQgsServerQueryStringParameter::testCustomValidators() +{ + QgsServerQueryStringParameter p { QStringLiteral( "parameter1" ), true, QgsServerQueryStringParameter::Type::Integer }; + QgsServerRequest request; + QgsServerApiContext ctx { "/wfs3", &request, nullptr, nullptr, nullptr }; + + request.setUrl( QStringLiteral( "http://www.qgis.org/api/?parameter1=123" ) ); + QCOMPARE( p.value( ctx ).toInt(), 123 ); + + // Test a range validator that increments the value + QgsServerQueryStringParameter::customValidator validator = [ ]( const QgsServerApiContext &, QVariant & value ) -> bool + { + const auto v { value.toLongLong() }; + // Change the value by adding 1 + value.setValue( v + 1 ); + return v > 500 && v < 1000; + }; + p.setCustomValidator( validator ); + QVERIFY_EXCEPTION_THROWN( p.value( ctx ), QgsServerApiBadRequestException ); + + request.setUrl( QStringLiteral( "http://www.qgis.org/api/?parameter1=501" ) ); + QCOMPARE( p.value( ctx ).toInt(), 502 ); + QCOMPARE( p.value( ctx ).type(), QVariant::LongLong ); + +} + +void TestQgsServerQueryStringParameter::testDefaultValues() +{ + // Set a default AND required, verify it's ignored + QgsServerQueryStringParameter p { QStringLiteral( "parameter1" ), true, QgsServerQueryStringParameter::Type::Integer, QStringLiteral( "Paramerer 1" ), 10 }; + QgsServerRequest request; + QgsServerApiContext ctx { "/wfs3", &request, nullptr, nullptr, nullptr }; + + request.setUrl( QStringLiteral( "http://www.qgis.org/api/" ) ); + QVERIFY_EXCEPTION_THROWN( p.value( ctx ), QgsServerApiBadRequestException ); + + QgsServerQueryStringParameter p2 { QStringLiteral( "parameter1" ), false, QgsServerQueryStringParameter::Type::Integer, QStringLiteral( "Paramerer 1" ), 10 }; + QCOMPARE( p2.value( ctx ).toInt(), 10 ); + request.setUrl( QStringLiteral( "http://www.qgis.org/api/?parameter1=501" ) ); + QCOMPARE( p2.value( ctx ).toInt(), 501 ); + +} + +QGSTEST_MAIN( TestQgsServerQueryStringParameter ) +#include "testqgsserverquerystringparameter.moc" diff --git a/tests/src/server/wms/CMakeLists.txt b/tests/src/server/wms/CMakeLists.txt index 0f43aa14059..40039275374 100644 --- a/tests/src/server/wms/CMakeLists.txt +++ b/tests/src/server/wms/CMakeLists.txt @@ -2,6 +2,7 @@ # Don't forget to include output directory, otherwise # the UI file won't be wrapped! INCLUDE_DIRECTORIES(${CMAKE_CURRENT_SOURCE_DIR} + ${CMAKE_SOURCE_DIR}/external ${CMAKE_CURRENT_BINARY_DIR} ${CMAKE_SOURCE_DIR}/src/core ${CMAKE_SOURCE_DIR}/src/core/geometry diff --git a/tests/testdata/qgis_server/api/test_api.json b/tests/testdata/qgis_server/api/test_api.json new file mode 100644 index 00000000000..fd796131f02 --- /dev/null +++ b/tests/testdata/qgis_server/api/test_api.json @@ -0,0 +1,2 @@ + +"Test API" \ No newline at end of file diff --git a/tests/testdata/qgis_server/api/test_wfs3_api.json b/tests/testdata/qgis_server/api/test_wfs3_api.json new file mode 100644 index 00000000000..d8795d431e8 --- /dev/null +++ b/tests/testdata/qgis_server/api/test_wfs3_api.json @@ -0,0 +1,3 @@ +Content-Type: application/json + +[{"code":"Improperly configured error","description":"Project not found, please check your server configuration."}] \ No newline at end of file diff --git a/tests/testdata/qgis_server/api/test_wfs3_api_project.json b/tests/testdata/qgis_server/api/test_wfs3_api_project.json new file mode 100644 index 00000000000..73e75f59e3b --- /dev/null +++ b/tests/testdata/qgis_server/api/test_wfs3_api_project.json @@ -0,0 +1,881 @@ +Content-Type: application/openapi+json;version=3.0 + +{ + "components": { + "parameters": { + "bbox": { + "description": "Only features that have a geometry that intersects the bounding box are selected. The bounding box is provided as four or six numbers, depending on whether the coordinate reference system includes a vertical axis (elevation or depth):\n \n* Lower left corner, coordinate axis 1\n* Lower left corner, coordinate axis 2\n* Lower left corner, coordinate axis 3 (optional)\n* Upper right corner, coordinate axis 1\n* Upper right corner, coordinate axis 2\n* Upper right corner, coordinate axis 3 (optional)\n\nThe coordinate reference system of the values is WGS84 longitude/latitude (http://www.opengis.net/def/crs/OGC/1.3/CRS84) unless a different coordinate reference system is specified in the parameter `bbox-crs`.\n\nFor WGS84 longitude/latitude the values are in most cases the sequence of minimum longitude, minimum latitude, maximum longitude and maximum latitude. However, in cases where the box spans the antimeridian the first value (west-most box edge) is larger than the third value (east-most box edge).\n\nIf a feature has multiple spatial geometry properties, it is the decision of the server whether only a single spatial geometry property is used to determine the extent or all relevant geometries.", + "explode": false, + "in": "query", + "name": "bbox", + "required": false, + "schema": { + "items": { + "type": "number" + }, + "maxItems": 6, + "minItems": 4, + "type": "array" + }, + "style": "form" + }, + "bbox-crs": { + "description": "The coordinate reference system of the bbox parameter. Default is WGS84 longitude/latitude (http://www.opengis.net/def/crs/OGC/1.3/CRS84).", + "explode": false, + "in": "query", + "name": "bbox-crs", + "required": false, + "schema": { + "default": "http://www.opengis.net/def/crs/OGC/1.3/CRS84", + "enum": [ + "http://www.opengis.net/def/crs/OGC/1.3/CRS84", + "http://www.opengis.net/def/crs/EPSG/9.6.2/4326", + "http://www.opengis.net/def/crs/EPSG/9.6.2/3857" + ], + "type": "string" + }, + "style": "form" + }, + "crs": { + "description": "The coordinate reference system of the response geometries. Default is WGS84 longitude/latitude (http://www.opengis.net/def/crs/OGC/1.3/CRS84).", + "explode": false, + "in": "query", + "name": "crs", + "required": false, + "schema": { + "default": "http://www.opengis.net/def/crs/OGC/1.3/CRS84", + "enum": [ + "http://www.opengis.net/def/crs/OGC/1.3/CRS84", + "http://www.opengis.net/def/crs/EPSG/9.6.2/4326", + "http://www.opengis.net/def/crs/EPSG/9.6.2/3857" + ], + "type": "string" + }, + "style": "form" + }, + "featureId": { + "description": "Local identifier of a specific feature", + "in": "path", + "name": "featureId", + "required": true, + "schema": { + "type": "string" + } + }, + "limit": { + "description": "The optional limit parameter limits the number of items that are presented in the response document.\\\nOnly items are counted that are on the first level of the collection in the response document. Nested objects contained within the explicitly requested items shall not be counted.\\\nMinimum = 1.\\\nMaximum = 10000.\\\nDefault = 10.", + "example": 10, + "explode": false, + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 10, + "maximum": 10000, + "minimum": 1, + "type": "integer" + }, + "style": "form" + }, + "offset": { + "description": "The optional offset parameter indicates the index within the result set from which the server shall begin presenting results in the response document. The first element has an index of 0.\\\nMinimum = 0.\\\nDefault = 0.", + "example": 0, + "explode": false, + "in": "query", + "name": "offset", + "required": false, + "schema": { + "default": 0, + "minimum": 0, + "type": "integer" + }, + "style": "form" + }, + "relations": { + "description": "Comma-separated list of related collections that should be shown for this feature", + "explode": false, + "in": "query", + "name": "relations", + "required": false, + "schema": { + "items": { + "type": "string" + }, + "type": "array" + }, + "style": "form" + }, + "resultType": { + "description": "This service will respond to a query in one of two ways (excluding an exception response). It may either generate a complete response document containing resources that satisfy the operation or it may simply generate an empty response container that indicates the count of the total number of resources that the operation would return. Which of these two responses is generated is determined by the value of the optional resultType parameter.\\\nThe allowed values for this parameter are \"results\" and \"hits\".\\\nIf the value of the resultType parameter is set to \"results\", the server will generate a complete response document containing resources that satisfy the operation.\\\nIf the value of the resultType attribute is set to \"hits\", the server will generate an empty response document containing no resource instances.\\\nDefault = \"results\".", + "example": "results", + "explode": false, + "in": "query", + "name": "resultType", + "required": false, + "schema": { + "default": "results", + "enum": [ + "hits", + "results" + ], + "type": "string" + }, + "style": "form" + }, + "time": { + "description": "Either a date-time or a period string that adheres to RFC 3339. Examples:\n\n* A date-time: \"2018-02-12T23:20:50Z\"\n* A period: \"2018-02-12T00:00:00Z/2018-03-18T12:31:12Z\" or \"2018-02-12T00:00:00Z/P1M6DT12H31M12S\"\n\nOnly features that have a temporal property that intersects the value of\n`time` are selected.\n\nIf a feature has multiple temporal properties, it is the decision of the\nserver whether only a single temporal property is used to determine\nthe extent or all relevant temporal properties.", + "explode": false, + "in": "query", + "name": "time", + "required": false, + "schema": { + "type": "string" + }, + "style": "form" + } + }, + "schemas": { + "collectionInfo": { + "properties": { + "crs": { + "default": [ + "http://www.opengis.net/def/crs/OGC/1.3/CRS84" + ], + "description": "The coordinate reference systems in which geometries may be retrieved. Coordinate reference systems are identified by a URI. The first coordinate reference system is the coordinate reference system that is used by default. This is always \"http://www.opengis.net/def/crs/OGC/1.3/CRS84\", i.e. WGS84 longitude/latitude.", + "example": [ + "http://www.opengis.net/def/crs/OGC/1.3/CRS84", + "http://www.opengis.net/def/crs/EPSG/0/4326" + ], + "items": { + "type": "string" + }, + "type": "array" + }, + "description": { + "description": "a description of the features in the collection", + "example": "Buildings in the city of Bonn.", + "type": "string" + }, + "extent": { + "$ref": "#/components/schemas/extent" + }, + "links": { + "example": [ + { + "href": "http://data.example.org/collections/buildings/items", + "rel": "item", + "title": "Buildings", + "type": "application/geo+json" + }, + { + "href": "http://example.com/concepts/buildings.html", + "rel": "describedBy", + "title": "Feature catalogue for buildings", + "type": "text/html" + } + ], + "items": { + "$ref": "#/components/schemas/link" + }, + "type": "array" + }, + "name": { + "description": "identifier of the collection used, for example, in URIs", + "example": "buildings", + "type": "string" + }, + "relations": { + "description": "Related collections that may be retrieved for this collection", + "example": "{\"id\": \"label\"}", + "type": "object" + }, + "title": { + "description": "human readable title of the collection", + "example": "Buildings", + "type": "string" + } + }, + "required": [ + "links", + "name" + ], + "type": "object" + }, + "content": { + "properties": { + "collections": { + "items": { + "$ref": "#/components/schemas/collectionInfo" + }, + "type": "array" + }, + "links": { + "example": [ + { + "href": "http://data.example.org/collections.json", + "rel": "self", + "title": "this document", + "type": "application/json" + }, + { + "href": "http://data.example.org/collections.html", + "rel": "alternate", + "title": "this document as HTML", + "type": "text/html" + }, + { + "href": "http://schemas.example.org/1.0/foobar.xsd", + "rel": "describedBy", + "title": "XML schema for Acme Corporation data", + "type": "application/xml" + } + ], + "items": { + "$ref": "#/components/schemas/link" + }, + "type": "array" + } + }, + "required": [ + "collections", + "links" + ], + "type": "object" + }, + "exception": { + "properties": { + "code": { + "type": "string" + }, + "description": { + "type": "string" + } + }, + "required": [ + "code" + ], + "type": "object" + }, + "extent": { + "properties": { + "crs": { + "default": "http://www.opengis.net/def/crs/OGC/1.3/CRS84", + "description": "Coordinate reference system of the coordinates in the spatial extent (property `spatial`). In the Core, only WGS84 longitude/latitude is supported. Extensions may support additional coordinate reference systems.", + "enum": [ + "http://www.opengis.net/def/crs/OGC/1.3/CRS84" + ], + "type": "string" + }, + "spatial": { + "description": "West, north, east, south edges of the spatial extent. The minimum and maximum values apply to the coordinate reference system WGS84 longitude/latitude that is supported in the Core. If, for example, a projected coordinate reference system is used, the minimum and maximum values need to be adjusted.", + "example": [ + -180, + -90, + 180, + 90 + ], + "items": { + "type": "number" + }, + "maxItems": 6, + "minItems": 4, + "type": "array" + } + }, + "required": [ + "spatial" + ], + "type": "object" + }, + "featureCollectionGeoJSON": { + "properties": { + "features": { + "items": { + "$ref": "#/components/schemas/featureGeoJSON" + }, + "type": "array" + }, + "links": { + "items": { + "$ref": "#/components/schemas/link" + }, + "type": "array" + }, + "numberMatched": { + "minimum": 0, + "type": "integer" + }, + "numberReturned": { + "minimum": 0, + "type": "integer" + }, + "timeStamp": { + "format": "dateTime", + "type": "string" + }, + "type": { + "enum": [ + "FeatureCollection" + ], + "type": "string" + } + }, + "required": [ + "features", + "type" + ], + "type": "object" + }, + "featureGeoJSON": { + "properties": { + "geometry": { + "$ref": "#/components/schemas/geometryGeoJSON" + }, + "id": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "integer" + } + ] + }, + "properties": { + "nullable": true, + "type": "object" + }, + "type": { + "enum": [ + "Feature" + ], + "type": "string" + } + }, + "required": [ + "geometry", + "properties", + "type" + ], + "type": "object" + }, + "geometryGeoJSON": { + "properties": { + "type": { + "enum": [ + "Point", + "MultiPoint", + "LineString", + "MultiLineString", + "Polygon", + "MultiPolygon", + "GeometryCollection" + ], + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + "link": { + "properties": { + "href": { + "example": "http://data.example.com/buildings/123", + "type": "string" + }, + "hreflang": { + "example": "en", + "type": "string" + }, + "rel": { + "example": "prev", + "type": "string" + }, + "type": { + "example": "application/geo+json", + "type": "string" + } + }, + "required": [ + "href" + ], + "type": "object" + }, + "req-classes": { + "properties": { + "conformsTo": { + "example": [ + "http://www.opengis.net/spec/wfs-1/3.0/req/core", + "http://www.opengis.net/spec/wfs-1/3.0/req/oas30", + "http://www.opengis.net/spec/wfs-1/3.0/req/html", + "http://www.opengis.net/spec/wfs-1/3.0/req/geojson" + ], + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "conformsTo" + ], + "type": "object" + }, + "root": { + "properties": { + "links": { + "example": [ + { + "href": "http://data.example.org/", + "rel": "self", + "title": "this document", + "type": "application/json" + }, + { + "href": "http://data.example.org/api", + "rel": "service", + "title": "the API definition", + "type": "application/openapi+json;version=3.0" + }, + { + "href": "http://data.example.org/conformance", + "rel": "conformance", + "title": "WFS 3.0 conformance classes implemented by this server", + "type": "application/json" + }, + { + "href": "http://data.example.org/collections", + "rel": "data", + "title": "Metadata about the feature collections", + "type": "application/json" + } + ], + "items": { + "$ref": "#/components/schemas/link" + }, + "type": "array" + } + }, + "required": [ + "links" + ], + "type": "object" + } + } + }, + "info": { + "contact": { + "email": "elpaso@itopen.it", + "name": "Alessandro Pasotti", + "url": "" + }, + "description": "Some UTF8 text èòù", + "license": { + "name": "" + }, + "title": "QGIS TestProject", + "version": "" + }, + "links": [ + { + "href": "http://server.qgis.org/wfs3/api.openapi3", + "rel": "self", + "title": "API definition as OPENAPI3", + "type": "application/openapi+json;version=3.0" + }, + { + "href": "http://server.qgis.org/wfs3/api.html", + "rel": "alternate", + "title": "API definition as HTML", + "type": "text/html" + } + ], + "openapi": "3.0.1", + "paths": { + "/wfs3": { + "get": { + "description": "The landing page provides links to the API definition, the Conformance statements and the metadata about the feature data in this dataset.", + "operationId": "getLandingPage", + "responses": [ + [ + "200", + { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/root" + } + }, + "text/html": { + "schema": { + "type": "string" + } + } + }, + "description": "The landing page provides links to the API definition, the Conformance statements and the metadata about the feature data in this dataset." + } + ], + { + "default": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/exception" + }, + "text/html": { + "schema": { + "type": "string" + } + } + } + }, + "description": "An error occurred." + } + } + ], + "summary": "WFS 3.0 Landing Page", + "tags": "Capabilities" + } + }, + "/wfs3/collections": { + "get": { + "description": "Describe the feature collections in the dataset statements and the metadata about the feature data in this dataset.", + "operationId": "describeCollections", + "responses": [ + [ + "200", + { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/content" + } + }, + "text/html": { + "schema": { + "type": "string" + } + } + }, + "description": "Describe the feature collections in the dataset statements and the metadata about the feature data in this dataset." + } + ], + { + "default": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/exception" + }, + "text/html": { + "schema": { + "type": "string" + } + } + } + }, + "description": "An error occurred." + } + } + ], + "summary": "Metadata about the feature collections shared by this API.", + "tags": "Capabilities" + } + }, + "/wfs3/collections/testlayer èé/items": { + "get": { + "description": "Every feature in a dataset belongs to a collection. A dataset may consist of multiple feature collections. A feature collection is often a collection of features of a similar type, based on a common schema. Use content negotiation or specify a file extension to request HTML (.html) or GeoJSON (.json).", + "operationId": "getFeatures_testlayer èé", + "parameters": [ + [ + { + "$ref": "#/components/parameters/limit" + }, + { + "$ref": "#/components/parameters/offset" + }, + { + "$ref": "#/components/parameters/resultType" + }, + { + "$ref": "#/components/parameters/bbox" + }, + { + "$ref": "#/components/parameters/bbox-crs" + } + ], + { + "description": "Filter the collection by 'id'", + "explode": false, + "in": "query", + "name": "id", + "required": false, + "schema": { + "type": "integer" + }, + "style": "form" + }, + { + "description": "Filter the collection by 'name'", + "explode": false, + "in": "query", + "name": "name", + "required": false, + "schema": { + "type": "string" + }, + "style": "form" + }, + { + "description": "Filter the collection by 'utf8nameè'", + "explode": false, + "in": "query", + "name": "utf8nameè", + "required": false, + "schema": { + "type": "string" + }, + "style": "form" + } + ], + "responses": [ + [ + "200", + { + "content": { + "application/geo+json": { + "schema": { + "$ref": "#/components/schemas/featureCollectionGeoJSON" + } + }, + "text/html": { + "schema": { + "type": "string" + } + } + }, + "description": "Metadata about the collection 'A test vector layer' shared by this API." + } + ], + { + "default": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/exception" + }, + "text/html": { + "schema": { + "type": "string" + } + } + } + }, + "description": "An error occurred." + } + } + ], + "summary": "Retrieve features of 'A test vector layer' feature collection", + "tags": "Features" + } + }, + "/wfs3/conformance": { + "get": { + "description": "List all requirements classes specified in a standard (e.g., WFS 3.0 Part 1: Core) that the server conforms to", + "operationId": "getRequirementClasses", + "responses": [ + [ + "200", + { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/root" + } + }, + "text/html": { + "schema": { + "type": "string" + } + } + }, + "description": "List all requirements classes specified in a standard (e.g., WFS 3.0 Part 1: Core) that the server conforms to" + } + ], + { + "default": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/exception" + }, + "text/html": { + "schema": { + "type": "string" + } + } + } + }, + "description": "An error occurred." + } + } + ], + "summary": "Information about standards that this API conforms to", + "tags": "Capabilities" + } + }, + "/wfs3api": { + "get": { + "description": "The formal documentation of this API according to the OpenAPI specification, version 3.0. I.e., this document.", + "operationId": "getApiDescription", + "responses": [ + [ + "200", + { + "content": { + "application/openapi+json;version=3.0": { + "schema": { + "type": "object" + } + }, + "text/html": { + "schema": { + "type": "string" + } + } + }, + "description": "The formal documentation of this API according to the OpenAPI specification, version 3.0. I.e., this document." + } + ], + { + "default": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/exception" + }, + "text/html": { + "schema": { + "type": "string" + } + } + } + }, + "description": "An error occurred." + } + } + ], + "summary": "The API definition", + "tags": "Capabilities" + } + }, + "/wfs3collections/testlayer èé": { + "get": { + "description": "Metadata about a feature collection.", + "operationId": "describeCollection_testlayer èé", + "responses": [ + [ + "200", + { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/collectionInfo" + } + }, + "text/html": { + "schema": { + "type": "string" + } + } + }, + "description": "Metadata about the collection 'A test vector layer' shared by this API." + } + ], + { + "default": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/exception" + }, + "text/html": { + "schema": { + "type": "string" + } + } + } + }, + "description": "An error occurred." + } + } + ], + "summary": "Describe the 'A test vector layer' feature collection", + "tags": "Capabilities" + } + }, + "/wfs3collections/testlayer èé/items/{featureId}": { + "get": { + "description": "Retrieve a feature; use content negotiation or specify a file extension to request HTML (.html or GeoJSON (.json)", + "operationId": "getFeature_testlayer èé", + "responses": [ + [ + "200", + { + "content": { + "application/geo+json": { + "schema": { + "$ref": "#/components/schemas/featureGeoJSON" + } + }, + "text/html": { + "schema": { + "type": "string" + } + } + }, + "description": "Retrieve a 'A test vector layer' feature by 'featureId'." + } + ], + { + "default": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/exception" + }, + "text/html": { + "schema": { + "type": "string" + } + } + } + }, + "description": "An error occurred." + } + } + ], + "summary": "Retrieve a single feature from the 'A test vector layer' feature collection", + "tags": "Features" + } + } + }, + "servers": [ + { + "url": "http://server.qgis.org/wfs3" + } + ], + "tags": [ + { + "description": "Essential characteristics of this API including information about the data.", + "name": "Capabilities" + }, + { + "description": "Access to data (features).", + "name": "Features" + } + ], + "timeStamp": "2019-07-30T09:17:49Z" +} \ No newline at end of file diff --git a/tests/testdata/qgis_server/api/test_wfs3_collections_empty.html b/tests/testdata/qgis_server/api/test_wfs3_collections_empty.html new file mode 100644 index 00000000000..efdf7213db1 --- /dev/null +++ b/tests/testdata/qgis_server/api/test_wfs3_collections_empty.html @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + Feature collections + + + +
+ + + +

Collections

+ + + + + +
+ + + + + + + + + + + + + diff --git a/tests/testdata/qgis_server/api/test_wfs3_collections_empty.json b/tests/testdata/qgis_server/api/test_wfs3_collections_empty.json new file mode 100644 index 00000000000..a4bbcadfcf7 --- /dev/null +++ b/tests/testdata/qgis_server/api/test_wfs3_collections_empty.json @@ -0,0 +1,23 @@ +Content-Type: application/json + +{ + "collections": [], + "crs": [ + "http://www.opengis.net/def/crs/OGC/1.3/CRS84" + ], + "links": [ + { + "href": "http://server.qgis.org/wfs3/collections.json", + "rel": "self", + "title": "Feature collections as JSON", + "type": "application/json" + }, + { + "href": "http://server.qgis.org/wfs3/collections.html", + "rel": "alternate", + "title": "Feature collections as HTML", + "type": "text/html" + } + ], + "timeStamp": "2019-07-30T09:17:49Z" +} \ No newline at end of file diff --git a/tests/testdata/qgis_server/api/test_wfs3_collections_items_testlayer3_name_eq_tw_star.json b/tests/testdata/qgis_server/api/test_wfs3_collections_items_testlayer3_name_eq_tw_star.json new file mode 100644 index 00000000000..3f3e33de6a8 --- /dev/null +++ b/tests/testdata/qgis_server/api/test_wfs3_collections_items_testlayer3_name_eq_tw_star.json @@ -0,0 +1,40 @@ +Content-Type: application/geo+json + +{ + "features": [ + { + "geometry": { + "coordinates": [ + 8.203547, + 44.901436 + ], + "type": "Point" + }, + "id": 1, + "properties": { + "id": 2, + "name": "two", + "utf8nameè": "two àò" + }, + "type": "Feature" + } + ], + "links": [ + { + "href": "http://server.qgis.org/wfs3/collections/testlayer3/items.geojson?name=tw*", + "rel": "self", + "title": "Retrieve the features of the collection as GEOJSON", + "type": "application/geo+json" + }, + { + "href": "http://server.qgis.org/wfs3/collections/testlayer3/items.html?name=tw*", + "rel": "alternate", + "title": "Retrieve the features of the collection as HTML", + "type": "text/html" + } + ], + "numberMatched": 1, + "numberReturned": 1, + "timeStamp": "2019-07-30T09:17:49Z", + "type": "FeatureCollection" +} \ No newline at end of file diff --git a/tests/testdata/qgis_server/api/test_wfs3_collections_items_testlayer3_name_eq_two.json b/tests/testdata/qgis_server/api/test_wfs3_collections_items_testlayer3_name_eq_two.json new file mode 100644 index 00000000000..05f280c43fe --- /dev/null +++ b/tests/testdata/qgis_server/api/test_wfs3_collections_items_testlayer3_name_eq_two.json @@ -0,0 +1,40 @@ +Content-Type: application/geo+json + +{ + "features": [ + { + "geometry": { + "coordinates": [ + 8.203547, + 44.901436 + ], + "type": "Point" + }, + "id": 1, + "properties": { + "id": 2, + "name": "two", + "utf8nameè": "two àò" + }, + "type": "Feature" + } + ], + "links": [ + { + "href": "http://server.qgis.org/wfs3/collections/testlayer3/items.geojson?name=two", + "rel": "self", + "title": "Retrieve the features of the collection as GEOJSON", + "type": "application/geo+json" + }, + { + "href": "http://server.qgis.org/wfs3/collections/testlayer3/items.html?name=two", + "rel": "alternate", + "title": "Retrieve the features of the collection as HTML", + "type": "text/html" + } + ], + "numberMatched": 1, + "numberReturned": 1, + "timeStamp": "2019-07-30T09:17:49Z", + "type": "FeatureCollection" +} \ No newline at end of file diff --git a/tests/testdata/qgis_server/api/test_wfs3_collections_items_testlayer_èé.json b/tests/testdata/qgis_server/api/test_wfs3_collections_items_testlayer_èé.json new file mode 100644 index 00000000000..d5acbcd5c03 --- /dev/null +++ b/tests/testdata/qgis_server/api/test_wfs3_collections_items_testlayer_èé.json @@ -0,0 +1,72 @@ +Content-Type: application/geo+json + +{ + "features": [ + { + "geometry": { + "coordinates": [ + 8.203496, + 44.901483 + ], + "type": "Point" + }, + "id": 0, + "properties": { + "id": 1, + "name": "one", + "utf8nameè": "one èé" + }, + "type": "Feature" + }, + { + "geometry": { + "coordinates": [ + 8.203547, + 44.901436 + ], + "type": "Point" + }, + "id": 1, + "properties": { + "id": 2, + "name": "two", + "utf8nameè": "two àò" + }, + "type": "Feature" + }, + { + "geometry": { + "coordinates": [ + 8.203459, + 44.901395 + ], + "type": "Point" + }, + "id": 2, + "properties": { + "id": 3, + "name": "three", + "utf8nameè": "three èé↓" + }, + "type": "Feature" + } + ], + "links": [ + { + "href": "http://server.qgis.org/wfs3/collections/testlayer%20%C3%A8%C3%A9/items.geojson", + "rel": "self", + "title": "Retrieve the features of the collection as GEOJSON", + "type": "application/geo+json" + }, + { + "href": "http://server.qgis.org/wfs3/collections/testlayer%20%C3%A8%C3%A9/items.html", + "rel": "alternate", + "title": "Retrieve the features of the collection as HTML", + "type": "text/html" + } + ], + "numberMatched": 3, + "numberReturned": 3, + "timeStamp": "2019-07-30T09:17:49Z", + "type": "FeatureCollection" +} \ No newline at end of file diff --git a/tests/testdata/qgis_server/api/test_wfs3_collections_items_testlayer_èé_bbox.json b/tests/testdata/qgis_server/api/test_wfs3_collections_items_testlayer_èé_bbox.json new file mode 100644 index 00000000000..b827b562291 --- /dev/null +++ b/tests/testdata/qgis_server/api/test_wfs3_collections_items_testlayer_èé_bbox.json @@ -0,0 +1,40 @@ +Content-Type: application/geo+json + +{ + "features": [ + { + "geometry": { + "coordinates": [ + 8.203496, + 44.901483 + ], + "type": "Point" + }, + "id": 0, + "properties": { + "id": 1, + "name": "one", + "utf8nameè": "one èé" + }, + "type": "Feature" + } + ], + "links": [ + { + "href": "http://server.qgis.org/wfs3/collections/testlayer%20%C3%A8%C3%A9/items.geojson?bbox=8.203495,44.901482,8.203497,44.901484", + "rel": "self", + "title": "Retrieve the features of the collection as GEOJSON", + "type": "application/geo+json" + }, + { + "href": "http://server.qgis.org/wfs3/collections/testlayer%20%C3%A8%C3%A9/items.html?bbox=8.203495,44.901482,8.203497,44.901484", + "rel": "alternate", + "title": "Retrieve the features of the collection as HTML", + "type": "text/html" + } + ], + "numberMatched": 1, + "numberReturned": 1, + "timeStamp": "2019-07-30T09:17:49Z", + "type": "FeatureCollection" +} \ No newline at end of file diff --git a/tests/testdata/qgis_server/api/test_wfs3_collections_items_testlayer_èé_bbox_3857.json b/tests/testdata/qgis_server/api/test_wfs3_collections_items_testlayer_èé_bbox_3857.json new file mode 100644 index 00000000000..869a491cdab --- /dev/null +++ b/tests/testdata/qgis_server/api/test_wfs3_collections_items_testlayer_èé_bbox_3857.json @@ -0,0 +1,56 @@ +Content-Type: application/geo+json + +{ + "features": [ + { + "geometry": { + "coordinates": [ + 8.203496, + 44.901483 + ], + "type": "Point" + }, + "id": 0, + "properties": { + "id": 1, + "name": "one", + "utf8nameè": "one èé" + }, + "type": "Feature" + }, + { + "geometry": { + "coordinates": [ + 8.203547, + 44.901436 + ], + "type": "Point" + }, + "id": 1, + "properties": { + "id": 2, + "name": "two", + "utf8nameè": "two àò" + }, + "type": "Feature" + } + ], + "links": [ + { + "href": "http://server.qgis.org/wfs3/collections/testlayer%20%C3%A8%C3%A9/items.geojson?bbox=913191,5606014,913234,5606029&bbox-crs=http%3A%2F%2Fwww.opengis.net%2Fdef%2Fcrs%2FEPSG%2F9.6.2%2F3857", + "rel": "self", + "title": "Retrieve the features of the collection as GEOJSON", + "type": "application/geo+json" + }, + { + "href": "http://server.qgis.org/wfs3/collections/testlayer%20%C3%A8%C3%A9/items.html?bbox=913191,5606014,913234,5606029&bbox-crs=http%3A%2F%2Fwww.opengis.net%2Fdef%2Fcrs%2FEPSG%2F9.6.2%2F3857", + "rel": "alternate", + "title": "Retrieve the features of the collection as HTML", + "type": "text/html" + } + ], + "numberMatched": 2, + "numberReturned": 2, + "timeStamp": "2019-07-30T09:17:49Z", + "type": "FeatureCollection" +} \ No newline at end of file diff --git a/tests/testdata/qgis_server/api/test_wfs3_collections_items_testlayer_èé_crs_3857.json b/tests/testdata/qgis_server/api/test_wfs3_collections_items_testlayer_èé_crs_3857.json new file mode 100644 index 00000000000..ce8da5830ca --- /dev/null +++ b/tests/testdata/qgis_server/api/test_wfs3_collections_items_testlayer_èé_crs_3857.json @@ -0,0 +1,72 @@ +Content-Type: application/geo+json + +{ + "features": [ + { + "geometry": { + "coordinates": [ + 913209.035793, + 5606025.237304 + ], + "type": "Point" + }, + "id": 0, + "properties": { + "id": 1, + "name": "one", + "utf8nameè": "one èé" + }, + "type": "Feature" + }, + { + "geometry": { + "coordinates": [ + 913214.67407, + 5606017.874258 + ], + "type": "Point" + }, + "id": 1, + "properties": { + "id": 2, + "name": "two", + "utf8nameè": "two àò" + }, + "type": "Feature" + }, + { + "geometry": { + "coordinates": [ + 913204.912803, + 5606011.456473 + ], + "type": "Point" + }, + "id": 2, + "properties": { + "id": 3, + "name": "three", + "utf8nameè": "three èé↓" + }, + "type": "Feature" + } + ], + "links": [ + { + "href": "http://server.qgis.org/wfs3/collections/testlayer%20%C3%A8%C3%A9/items.geojson?crs=http%3A%2F%2Fwww.opengis.net%2Fdef%2Fcrs%2FEPSG%2F9.6.2%2F3857", + "rel": "self", + "title": "Retrieve the features of the collection as GEOJSON", + "type": "application/geo+json" + }, + { + "href": "http://server.qgis.org/wfs3/collections/testlayer%20%C3%A8%C3%A9/items.html?crs=http%3A%2F%2Fwww.opengis.net%2Fdef%2Fcrs%2FEPSG%2F9.6.2%2F3857", + "rel": "alternate", + "title": "Retrieve the features of the collection as HTML", + "type": "text/html" + } + ], + "numberMatched": 3, + "numberReturned": 3, + "timeStamp": "2019-07-30T09:17:49Z", + "type": "FeatureCollection" +} \ No newline at end of file diff --git a/tests/testdata/qgis_server/api/test_wfs3_collections_items_testlayer_èé_limit_1.json b/tests/testdata/qgis_server/api/test_wfs3_collections_items_testlayer_èé_limit_1.json new file mode 100644 index 00000000000..ca8f3ea11c4 --- /dev/null +++ b/tests/testdata/qgis_server/api/test_wfs3_collections_items_testlayer_èé_limit_1.json @@ -0,0 +1,47 @@ +Content-Type: application/geo+json + +{ + "features": [ + { + "geometry": { + "coordinates": [ + 8.203496, + 44.901483 + ], + "type": "Point" + }, + "id": 0, + "properties": { + "id": 1, + "name": "one", + "utf8nameè": "one èé" + }, + "type": "Feature" + } + ], + "links": [ + { + "href": "http://server.qgis.org/wfs3/collections/testlayer%20%C3%A8%C3%A9/items.geojson?limit=1", + "rel": "self", + "title": "Retrieve the features of the collection as GEOJSON", + "type": "application/geo+json" + }, + { + "href": "http://server.qgis.org/wfs3/collections/testlayer%20%C3%A8%C3%A9/items.html?limit=1", + "rel": "alternate", + "title": "Retrieve the features of the collection as HTML", + "type": "text/html" + }, + { + "href": "http://server.qgis.org/wfs3/collections/testlayer èé/items?&offset=1&limit=1", + "name": "Next page", + "rel": "next", + "title": "Retrieve the features of the collection as GEOJSON", + "type": "application/geo+json" + } + ], + "numberMatched": 3, + "numberReturned": 1, + "timeStamp": "2019-07-30T09:17:49Z", + "type": "FeatureCollection" +} \ No newline at end of file diff --git a/tests/testdata/qgis_server/api/test_wfs3_collections_items_testlayer_èé_limit_1_offset_1.json b/tests/testdata/qgis_server/api/test_wfs3_collections_items_testlayer_èé_limit_1_offset_1.json new file mode 100644 index 00000000000..d3f1499d8e2 --- /dev/null +++ b/tests/testdata/qgis_server/api/test_wfs3_collections_items_testlayer_èé_limit_1_offset_1.json @@ -0,0 +1,54 @@ +Content-Type: application/geo+json + +{ + "features": [ + { + "geometry": { + "coordinates": [ + 8.203547, + 44.901436 + ], + "type": "Point" + }, + "id": 1, + "properties": { + "id": 2, + "name": "two", + "utf8nameè": "two àò" + }, + "type": "Feature" + } + ], + "links": [ + { + "href": "http://server.qgis.org/wfs3/collections/testlayer%20%C3%A8%C3%A9/items.geojson?limit=1&offset=1", + "rel": "self", + "title": "Retrieve the features of the collection as GEOJSON", + "type": "application/geo+json" + }, + { + "href": "http://server.qgis.org/wfs3/collections/testlayer%20%C3%A8%C3%A9/items.html?limit=1&offset=1", + "rel": "alternate", + "title": "Retrieve the features of the collection as HTML", + "type": "text/html" + }, + { + "href": "http://server.qgis.org/wfs3/collections/testlayer èé/items?&offset=0&limit=1", + "name": "Previous page", + "rel": "prev", + "title": "Retrieve the features of the collection as GEOJSON", + "type": "application/geo+json" + }, + { + "href": "http://server.qgis.org/wfs3/collections/testlayer èé/items?&offset=2&limit=1", + "name": "Next page", + "rel": "next", + "title": "Retrieve the features of the collection as GEOJSON", + "type": "application/geo+json" + } + ], + "numberMatched": 3, + "numberReturned": 1, + "timeStamp": "2019-07-30T09:17:49Z", + "type": "FeatureCollection" +} \ No newline at end of file diff --git a/tests/testdata/qgis_server/api/test_wfs3_collections_project.html b/tests/testdata/qgis_server/api/test_wfs3_collections_project.html new file mode 100644 index 00000000000..20826b35f77 --- /dev/null +++ b/tests/testdata/qgis_server/api/test_wfs3_collections_project.html @@ -0,0 +1,106 @@ + + + + + + + + + + + + + + + + + Feature collections + + + +
+ + + +

Collections

+ + +

A test vector layer

+ + +

testlayer3

+ + +

testlayer2

+ + +

A Layer with a short name

+ + +

A test vector layer

+ + +

A test vector layer

+ + + + + +
+ + + + + + + + + + + + + diff --git a/tests/testdata/qgis_server/api/test_wfs3_collections_project.json b/tests/testdata/qgis_server/api/test_wfs3_collections_project.json new file mode 100644 index 00000000000..ca2c98a087d --- /dev/null +++ b/tests/testdata/qgis_server/api/test_wfs3_collections_project.json @@ -0,0 +1,236 @@ +Content-Type: application/json + +{ + "collections": [ + { + "crs": [ + "http://www.opengis.net/def/crs/OGC/1.3/CRS84", + "http://www.opengis.net/def/crs/EPSG/9.6.2/4326", + "http://www.opengis.net/def/crs/EPSG/9.6.2/3857" + ], + "description": "A test vector layer with unicode òà", + "extent": { + "crs": "http://www.opengis.net/def/crs/OGC/1.3/CRS84", + "spatial": [ + [ + 8.203459307036344, + 44.90139483904469, + 8.203546993993488, + 44.901482526001836 + ] + ] + }, + "links": [ + { + "href": "http://server.qgis.org/wfs3/collections/testlayer%20%C3%A8%C3%A9/items.json", + "rel": "item", + "title": "A test vector layer as GeoJSON", + "type": "application/geo+json" + }, + { + "href": "http://server.qgis.org/wfs3/collections/testlayer%20%C3%A8%C3%A9/items.html", + "rel": "item", + "title": "A test vector layer as HTML", + "type": "text/html" + } + ], + "name": "testlayer èé", + "title": "A test vector layer" + }, + { + "crs": [ + "http://www.opengis.net/def/crs/OGC/1.3/CRS84", + "http://www.opengis.net/def/crs/EPSG/9.6.2/4326", + "http://www.opengis.net/def/crs/EPSG/9.6.2/3857" + ], + "description": "", + "extent": { + "crs": "http://www.opengis.net/def/crs/OGC/1.3/CRS84", + "spatial": [ + [ + 8.203459307036344, + 44.90139483904469, + 8.203546993993488, + 44.901482526001836 + ] + ] + }, + "links": [ + { + "href": "http://server.qgis.org/wfs3/collections/testlayer3/items.json", + "rel": "item", + "title": "testlayer3 as GeoJSON", + "type": "application/geo+json" + }, + { + "href": "http://server.qgis.org/wfs3/collections/testlayer3/items.html", + "rel": "item", + "title": "testlayer3 as HTML", + "type": "text/html" + } + ], + "name": "testlayer3", + "title": "testlayer3" + }, + { + "crs": [ + "http://www.opengis.net/def/crs/OGC/1.3/CRS84", + "http://www.opengis.net/def/crs/EPSG/9.6.2/4326", + "http://www.opengis.net/def/crs/EPSG/9.6.2/3857" + ], + "description": "", + "extent": { + "crs": "http://www.opengis.net/def/crs/OGC/1.3/CRS84", + "spatial": [ + [ + 8.203459307036344, + 44.90139483904469, + 8.203546993993488, + 44.901482526001836 + ] + ] + }, + "links": [ + { + "href": "http://server.qgis.org/wfs3/collections/testlayer2/items.json", + "rel": "item", + "title": "testlayer2 as GeoJSON", + "type": "application/geo+json" + }, + { + "href": "http://server.qgis.org/wfs3/collections/testlayer2/items.html", + "rel": "item", + "title": "testlayer2 as HTML", + "type": "text/html" + } + ], + "name": "testlayer2", + "title": "testlayer2" + }, + { + "crs": [ + "http://www.opengis.net/def/crs/OGC/1.3/CRS84", + "http://www.opengis.net/def/crs/EPSG/9.6.2/4326", + "http://www.opengis.net/def/crs/EPSG/9.6.2/3857" + ], + "description": "A Layer with an abstract", + "extent": { + "crs": "http://www.opengis.net/def/crs/OGC/1.3/CRS84", + "spatial": [ + [ + 8.203459307036344, + 44.90139483904469, + 8.203546993993488, + 44.901482526001836 + ] + ] + }, + "links": [ + { + "href": "http://server.qgis.org/wfs3/collections/layer_with_short_name/items.json", + "rel": "item", + "title": "A Layer with a short name as GeoJSON", + "type": "application/geo+json" + }, + { + "href": "http://server.qgis.org/wfs3/collections/layer_with_short_name/items.html", + "rel": "item", + "title": "A Layer with a short name as HTML", + "type": "text/html" + } + ], + "name": "layer_with_short_name", + "title": "A Layer with a short name" + }, + { + "crs": [ + "http://www.opengis.net/def/crs/OGC/1.3/CRS84", + "http://www.opengis.net/def/crs/EPSG/9.6.2/4326", + "http://www.opengis.net/def/crs/EPSG/9.6.2/3857" + ], + "description": "A test vector layer with unicode òà", + "extent": { + "crs": "http://www.opengis.net/def/crs/OGC/1.3/CRS84", + "spatial": [ + [ + 8.203459307036344, + 44.90139483904469, + 8.203546993993488, + 44.901482526001836 + ] + ] + }, + "links": [ + { + "href": "http://server.qgis.org/wfs3/collections/exclude_attribute/items.json", + "rel": "item", + "title": "A test vector layer as GeoJSON", + "type": "application/geo+json" + }, + { + "href": "http://server.qgis.org/wfs3/collections/exclude_attribute/items.html", + "rel": "item", + "title": "A test vector layer as HTML", + "type": "text/html" + } + ], + "name": "exclude_attribute", + "title": "A test vector layer" + }, + { + "crs": [ + "http://www.opengis.net/def/crs/OGC/1.3/CRS84", + "http://www.opengis.net/def/crs/EPSG/9.6.2/4326", + "http://www.opengis.net/def/crs/EPSG/9.6.2/3857" + ], + "description": "A test vector layer with unicode òà", + "extent": { + "crs": "http://www.opengis.net/def/crs/OGC/1.3/CRS84", + "spatial": [ + [ + 8.203459307036344, + 44.90139483904469, + 8.203546993993488, + 44.901482526001836 + ] + ] + }, + "links": [ + { + "href": "http://server.qgis.org/wfs3/collections/fields_alias/items.json", + "rel": "item", + "title": "A test vector layer as GeoJSON", + "type": "application/geo+json" + }, + { + "href": "http://server.qgis.org/wfs3/collections/fields_alias/items.html", + "rel": "item", + "title": "A test vector layer as HTML", + "type": "text/html" + } + ], + "name": "fields_alias", + "title": "A test vector layer" + } + ], + "crs": [ + "http://www.opengis.net/def/crs/OGC/1.3/CRS84", + "http://www.opengis.net/def/crs/EPSG/9.6.2/4326", + "http://www.opengis.net/def/crs/EPSG/9.6.2/3857" + ], + "links": [ + { + "href": "http://server.qgis.org/wfs3/collections.json", + "rel": "self", + "title": "Feature collections as JSON", + "type": "application/json" + }, + { + "href": "http://server.qgis.org/wfs3/collections.html", + "rel": "alternate", + "title": "Feature collections as HTML", + "type": "text/html" + } + ], + "timeStamp": "2019-07-30T09:17:49Z" +} \ No newline at end of file diff --git a/tests/testdata/qgis_server/api/test_wfs3_conformance.json b/tests/testdata/qgis_server/api/test_wfs3_conformance.json new file mode 100644 index 00000000000..5f1154b01a6 --- /dev/null +++ b/tests/testdata/qgis_server/api/test_wfs3_conformance.json @@ -0,0 +1,26 @@ +Content-Type: application/json + +{ + "conformsTo": [ + "http://www.opengis.net/spec/wfs-1/3.0/req/core", + "http://www.opengis.net/spec/wfs-1/3.0/req/oas30", + "http://www.opengis.net/spec/wfs-1/3.0/req/html", + "http://www.opengis.net/spec/wfs-1/3.0/req/gmlsf2", + "http://www.opengis.net/spec/wfs-1/3.0/req/geojson" + ], + "links": [ + { + "href": "http://server.qgis.org/wfs3/conformance.json", + "rel": "self", + "title": "WFS 3.0 conformance classes as JSON", + "type": "application/json" + }, + { + "href": "http://server.qgis.org/wfs3/conformance.html", + "rel": "alternate", + "title": "WFS 3.0 conformance classes as HTML", + "type": "text/html" + } + ], + "timeStamp": "2019-07-30T09:17:49Z" +} \ No newline at end of file diff --git a/tests/testdata/qgis_server/api/test_wfs3_landing_page.html b/tests/testdata/qgis_server/api/test_wfs3_landing_page.html new file mode 100644 index 00000000000..e326ef8ebdd --- /dev/null +++ b/tests/testdata/qgis_server/api/test_wfs3_landing_page.html @@ -0,0 +1,100 @@ + + + + + + + + + + + + + + + + + Landing page + + + +
+ + + +

QGIS Server

+ +

Available services

+ + + +
+ + + + + + + + + + + + + diff --git a/tests/testdata/qgis_server/api/test_wfs3_landing_page.json b/tests/testdata/qgis_server/api/test_wfs3_landing_page.json new file mode 100644 index 00000000000..49ac5abe238 --- /dev/null +++ b/tests/testdata/qgis_server/api/test_wfs3_landing_page.json @@ -0,0 +1,37 @@ +Content-Type: application/json + +{ + "links": [ + { + "href": "http://server.qgis.org/wfs3.json", + "rel": "self", + "title": "Landing page as JSON", + "type": "application/json" + }, + { + "href": "http://server.qgis.org/wfs3.html", + "rel": "alternate", + "title": "Landing page as HTML", + "type": "text/html" + }, + { + "href": "http://server.qgis.org/wfs3/collections", + "rel": "data", + "title": "Feature collections", + "type": "application/json" + }, + { + "href": "http://server.qgis.org/wfs3/conformance", + "rel": "conformance", + "title": "WFS 3.0 conformance classes", + "type": "application/json" + }, + { + "href": "http://server.qgis.org/wfs3/api", + "rel": "service_desc", + "title": "API definition", + "type": "application/openapi+json;version=3.0" + } + ], + "timeStamp": "2019-07-30T09:17:49Z" +} \ No newline at end of file