diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 01bcfd468..aa49a528c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -155,6 +155,8 @@ jobs: run: | pfsense-vshell --host ${{ matrix.PFSENSE_VERSION }}.jaredhendrickson.com -u admin -p pfsense -c 'pfSsh.php playback enablesshd' -k pfsense-vshell --host ${{ matrix.PFSENSE_VERSION }}.jaredhendrickson.com -u admin -p pfsense -c "mkdir /root/.ssh/ && echo $(cat ~/.ssh/id_rsa.pub) > /root/.ssh/authorized_keys" -k + pfsense-vshell --host ${{ matrix.PFSENSE_VERSION }}.jaredhendrickson.com -u admin -p pfsense -c "pkill ntpd" -k + pfsense-vshell --host ${{ matrix.PFSENSE_VERSION }}.jaredhendrickson.com -u admin -p pfsense -c "ntpdate pool.ntp.org" -k scp -o StrictHostKeyChecking=no pfSense-pkg-RESTAPI-${{ env.BUILD_VERSION }}-${{ matrix.FREEBSD_ID }}.pkg/pfSense-pkg-RESTAPI-${{ env.BUILD_VERSION }}-${{ matrix.FREEBSD_ID }}.pkg admin@${{ matrix.PFSENSE_VERSION }}.jaredhendrickson.com:/tmp/ - name: Install pfSense-pkg-RESTAPI on pfSense diff --git a/composer.lock b/composer.lock index 3212d3e8e..6bcbe8621 100644 --- a/composer.lock +++ b/composer.lock @@ -1,156 +1,146 @@ { - "_readme": [ - "This file locks the dependencies of your project to a known state", - "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", - "This file is @generated automatically" - ], - "content-hash": "a32ab4a8fc071e68a251a9446caf15b9", - "packages": [ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "a32ab4a8fc071e68a251a9446caf15b9", + "packages": [ + { + "name": "firebase/php-jwt", + "version": "v6.11.0", + "source": { + "type": "git", + "url": "https://github.com/firebase/php-jwt.git", + "reference": "8f718f4dfc9c5d5f0c994cdfd103921b43592712" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/firebase/php-jwt/zipball/8f718f4dfc9c5d5f0c994cdfd103921b43592712", + "reference": "8f718f4dfc9c5d5f0c994cdfd103921b43592712", + "shasum": "" + }, + "require": { + "php": "^8.0" + }, + "require-dev": { + "guzzlehttp/guzzle": "^7.4", + "phpspec/prophecy-phpunit": "^2.0", + "phpunit/phpunit": "^9.5", + "psr/cache": "^2.0||^3.0", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.0" + }, + "suggest": { + "ext-sodium": "Support EdDSA (Ed25519) signatures", + "paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present" + }, + "type": "library", + "autoload": { + "psr-4": { + "Firebase\\JWT\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": ["BSD-3-Clause"], + "authors": [ { - "name": "firebase/php-jwt", - "version": "v6.11.0", - "source": { - "type": "git", - "url": "https://github.com/firebase/php-jwt.git", - "reference": "8f718f4dfc9c5d5f0c994cdfd103921b43592712" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/firebase/php-jwt/zipball/8f718f4dfc9c5d5f0c994cdfd103921b43592712", - "reference": "8f718f4dfc9c5d5f0c994cdfd103921b43592712", - "shasum": "" - }, - "require": { - "php": "^8.0" - }, - "require-dev": { - "guzzlehttp/guzzle": "^7.4", - "phpspec/prophecy-phpunit": "^2.0", - "phpunit/phpunit": "^9.5", - "psr/cache": "^2.0||^3.0", - "psr/http-client": "^1.0", - "psr/http-factory": "^1.0" - }, - "suggest": { - "ext-sodium": "Support EdDSA (Ed25519) signatures", - "paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present" - }, - "type": "library", - "autoload": { - "psr-4": { - "Firebase\\JWT\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Neuman Vong", - "email": "neuman+pear@twilio.com", - "role": "Developer" - }, - { - "name": "Anant Narayanan", - "email": "anant@php.net", - "role": "Developer" - } - ], - "description": "A simple library to encode and decode JSON Web Tokens (JWT) in PHP. Should conform to the current spec.", - "homepage": "https://github.com/firebase/php-jwt", - "keywords": [ - "jwt", - "php" - ], - "support": { - "issues": "https://github.com/firebase/php-jwt/issues", - "source": "https://github.com/firebase/php-jwt/tree/v6.11.0" - }, - "time": "2025-01-23T05:11:06+00:00" + "name": "Neuman Vong", + "email": "neuman+pear@twilio.com", + "role": "Developer" }, { - "name": "webonyx/graphql-php", - "version": "v15.19.1", - "source": { - "type": "git", - "url": "https://github.com/webonyx/graphql-php.git", - "reference": "fa01712b1a170ddc1d92047011b2f4c2bdfa8234" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/webonyx/graphql-php/zipball/fa01712b1a170ddc1d92047011b2f4c2bdfa8234", - "reference": "fa01712b1a170ddc1d92047011b2f4c2bdfa8234", - "shasum": "" - }, - "require": { - "ext-json": "*", - "ext-mbstring": "*", - "php": "^7.4 || ^8" - }, - "require-dev": { - "amphp/amp": "^2.6", - "amphp/http-server": "^2.1", - "dms/phpunit-arraysubset-asserts": "dev-master", - "ergebnis/composer-normalize": "^2.28", - "friendsofphp/php-cs-fixer": "3.65.0", - "mll-lab/php-cs-fixer-config": "^5.9.2", - "nyholm/psr7": "^1.5", - "phpbench/phpbench": "^1.2", - "phpstan/extension-installer": "^1.1", - "phpstan/phpstan": "1.12.12", - "phpstan/phpstan-phpunit": "1.4.1", - "phpstan/phpstan-strict-rules": "1.6.1", - "phpunit/phpunit": "^9.5 || ^10.5.21 || ^11", - "psr/http-message": "^1 || ^2", - "react/http": "^1.6", - "react/promise": "^2.0 || ^3.0", - "rector/rector": "^1.0", - "symfony/polyfill-php81": "^1.23", - "symfony/var-exporter": "^5 || ^6 || ^7", - "thecodingmachine/safe": "^1.3 || ^2" - }, - "suggest": { - "amphp/http-server": "To leverage async resolving with webserver on AMPHP platform", - "psr/http-message": "To use standard GraphQL server", - "react/promise": "To leverage async resolving on React PHP platform" - }, - "type": "library", - "autoload": { - "psr-4": { - "GraphQL\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "description": "A PHP port of GraphQL reference implementation", - "homepage": "https://github.com/webonyx/graphql-php", - "keywords": [ - "api", - "graphql" - ], - "support": { - "issues": "https://github.com/webonyx/graphql-php/issues", - "source": "https://github.com/webonyx/graphql-php/tree/v15.19.1" - }, - "funding": [ - { - "url": "https://opencollective.com/webonyx-graphql-php", - "type": "open_collective" - } - ], - "time": "2024-12-19T10:52:18+00:00" + "name": "Anant Narayanan", + "email": "anant@php.net", + "role": "Developer" + } + ], + "description": "A simple library to encode and decode JSON Web Tokens (JWT) in PHP. Should conform to the current spec.", + "homepage": "https://github.com/firebase/php-jwt", + "keywords": ["jwt", "php"], + "support": { + "issues": "https://github.com/firebase/php-jwt/issues", + "source": "https://github.com/firebase/php-jwt/tree/v6.11.0" + }, + "time": "2025-01-23T05:11:06+00:00" + }, + { + "name": "webonyx/graphql-php", + "version": "v15.19.1", + "source": { + "type": "git", + "url": "https://github.com/webonyx/graphql-php.git", + "reference": "fa01712b1a170ddc1d92047011b2f4c2bdfa8234" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/webonyx/graphql-php/zipball/fa01712b1a170ddc1d92047011b2f4c2bdfa8234", + "reference": "fa01712b1a170ddc1d92047011b2f4c2bdfa8234", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-mbstring": "*", + "php": "^7.4 || ^8" + }, + "require-dev": { + "amphp/amp": "^2.6", + "amphp/http-server": "^2.1", + "dms/phpunit-arraysubset-asserts": "dev-master", + "ergebnis/composer-normalize": "^2.28", + "friendsofphp/php-cs-fixer": "3.65.0", + "mll-lab/php-cs-fixer-config": "^5.9.2", + "nyholm/psr7": "^1.5", + "phpbench/phpbench": "^1.2", + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan": "1.12.12", + "phpstan/phpstan-phpunit": "1.4.1", + "phpstan/phpstan-strict-rules": "1.6.1", + "phpunit/phpunit": "^9.5 || ^10.5.21 || ^11", + "psr/http-message": "^1 || ^2", + "react/http": "^1.6", + "react/promise": "^2.0 || ^3.0", + "rector/rector": "^1.0", + "symfony/polyfill-php81": "^1.23", + "symfony/var-exporter": "^5 || ^6 || ^7", + "thecodingmachine/safe": "^1.3 || ^2" + }, + "suggest": { + "amphp/http-server": "To leverage async resolving with webserver on AMPHP platform", + "psr/http-message": "To use standard GraphQL server", + "react/promise": "To leverage async resolving on React PHP platform" + }, + "type": "library", + "autoload": { + "psr-4": { + "GraphQL\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": ["MIT"], + "description": "A PHP port of GraphQL reference implementation", + "homepage": "https://github.com/webonyx/graphql-php", + "keywords": ["api", "graphql"], + "support": { + "issues": "https://github.com/webonyx/graphql-php/issues", + "source": "https://github.com/webonyx/graphql-php/tree/v15.19.1" + }, + "funding": [ + { + "url": "https://opencollective.com/webonyx-graphql-php", + "type": "open_collective" } - ], - "packages-dev": [], - "aliases": [], - "minimum-stability": "stable", - "stability-flags": [], - "prefer-stable": false, - "prefer-lowest": false, - "platform": [], - "platform-dev": [], - "plugin-api-version": "2.3.0" + ], + "time": "2024-12-19T10:52:18+00:00" + } + ], + "packages-dev": [], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": [], + "prefer-stable": false, + "prefer-lowest": false, + "platform": [], + "platform-dev": [], + "plugin-api-version": "2.3.0" } diff --git a/docs/COMMON_CONTROL_PARAMETERS.md b/docs/COMMON_CONTROL_PARAMETERS.md index e88efce96..823f67a2a 100644 --- a/docs/COMMON_CONTROL_PARAMETERS.md +++ b/docs/COMMON_CONTROL_PARAMETERS.md @@ -102,6 +102,21 @@ parameters you can use: behavior of this parameter varies based on the request method and endpoint type. Refer to the [Sorting](QUERIES_FILTERS_AND_SORTING.md#sorting) section for more information. +## sort_flags + +- Type: String +- Default: `SORT_REGULAR` +- Choices: + - `SORT_REGULAR` + - `SORT_NUMERIC` + - `SORT_STRING` + - `SORT_NATURAL` + - `SORT_FLAG_CASE` + - `SORT_FLAG_LOCALE_STRING` +- Description: This parameter allows you to control the sorting behavior of the objects related to the endpoint. The + behavior of this parameter varies based on the request method and endpoint type. Refer to the + [Sorting](QUERIES_FILTERS_AND_SORTING.md#sorting) section for more information. + ## sort_order - Type: String diff --git a/docs/QUERIES_FILTERS_AND_SORTING.md b/docs/QUERIES_FILTERS_AND_SORTING.md index 1f9944aa4..a98a63dc2 100644 --- a/docs/QUERIES_FILTERS_AND_SORTING.md +++ b/docs/QUERIES_FILTERS_AND_SORTING.md @@ -122,8 +122,8 @@ For advanced users, the REST API's framework allows for custom query filter clas ## Sorting Sorting can be used to order the data that is returned from the API based on specific criteria, as well as sorting the -objects written to the pfSense configuration. Sorting is controlled by two common control parameters: -[`sort_by`](COMMON_CONTROL_PARAMETERS.md#sort_by) and [`sort_order`](COMMON_CONTROL_PARAMETERS.md#sort_order). +objects written to the pfSense configuration. Sorting is controlled by three common control parameters: +[`sort_by`](COMMON_CONTROL_PARAMETERS.md#sort_by), [`sort_flags`](COMMON_CONTROL_PARAMETERS.md#sort_flags), and [`sort_order`](COMMON_CONTROL_PARAMETERS.md#sort_order). !!! Note - Sorting is only available for model objects that allow many instances, meaning multiple objects of its type can diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/Endpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/Endpoint.inc index 6b6e26557..ccbc0482d 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/Endpoint.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/Endpoint.inc @@ -316,7 +316,7 @@ class Endpoint { public bool $reverse = false; /** - * @var string|null $sort_by + * @var string|array|null $sort_by * Sets the default value(s) for the `sort_by` field in the request data. This value is used to control the sorting of * the Model objects returned by this Endpoint. This value can be overridden by the client in the request data. Use * caution when assigning this value as it may force objects to be sorted in this order when they are written to the @@ -333,6 +333,15 @@ class Endpoint { */ public string $sort_order = 'SORT_ASC'; + /** + * @var string|null $sort_flags + * Sets the default value for the `sort_flags` field in the request data. This value is used to control the sorting + * flags of the Model objects returned by this Endpoint. This value can be overridden by the client in the request + * data. This value only takes effect when the `sort_by` field is also set. This value must be the name of a valid + * PHP sort flags constant (e.g. 'SORT_REGULAR', 'SORT_NATURAL') + */ + public string|null $sort_flags = null; + /** * @var bool $append * Sets the default value for the `append` field in the request data. This value is used to control how Model objects @@ -734,7 +743,7 @@ class Endpoint { $this->check_decode_content_handler_supported($content_handler); # Decode the request data - $this->request_data = $content_handler->decode(); + $this->request_data = $content_handler->decode() ?? []; $this->validate_endpoint_fields(); } @@ -854,6 +863,47 @@ class Endpoint { } } + /** + * Validates the $sort_flags common control parameter for this request. + * @note If the `sort_flags` field was not provided in the request data, the request will default to the + * Endpoint's assigned $sort_flags property value. + * @throws ValidationError When the `sort_flags` field is not a string or is not a valid sort flags constant. + */ + private function validate_sort_flags(): void { + # Valid sort_flags options + $flag_opts = [ + 'SORT_REGULAR', + 'SORT_NUMERIC', + 'SORT_STRING', + 'SORT_LOCALE_STRING', + 'SORT_NATURAL', + 'SORT_FLAG_CASE', + ]; + + # Only validate this field if the client specifically requested it in the request data + if (isset($this->request_data['sort_flags'])) { + # Ensure value is a string + if (!is_string($this->request_data['sort_flags'])) { + throw new ValidationError( + message: 'Field `sort_flags` must be of type `string`.', + response_id: 'ENDPOINT_SORT_FLAGS_FIELD_INVALID_TYPE', + ); + } + + # Ensure the field is a valid sort flags constant + if (!in_array($this->request_data['sort_flags'], $flag_opts)) { + throw new ValidationError( + message: 'Field `sort_flags` must be one of: ' . json_encode($flag_opts), + response_id: 'ENDPOINT_SORT_FLAGS_FIELD_UNKNOWN_SORT_FLAGS', + ); + } + + # Update the sort_flags property to use the client's requested value and remove it from the request data + $this->sort_flags = $this->request_data['sort_flags']; + unset($this->request_data['sort_flags']); + } + } + /** * Validates the $append common control parameter for this request. * @note If the `append` field was not provided in the request data, the request will default to the Endpoint's assigned @@ -964,6 +1014,7 @@ class Endpoint { $this->validate_reverse(); $this->validate_sort_by(); $this->validate_sort_order(); + $this->validate_sort_flags(); $this->validate_append(); $this->validate_remove(); $this->validate_limit(); @@ -1082,6 +1133,7 @@ class Endpoint { reverse: $this->reverse, sort_by: $this->sort_by, sort_order: constant($this->sort_order), + sort_flags: $this->sort_flags ? constant($this->sort_flags) : $this->model->sort_flags, ); } # For GET requests on many Endpoints, obtain all objects from the assigned Model. @@ -1105,6 +1157,7 @@ class Endpoint { # Allow the endpoint/client to override the Model's sort_by and sort_order properties $this->model->sort_by = $this->sort_by ?? $this->model->sort_by; $this->model->sort_order = constant($this->sort_order) ?? $this->model->sort_order; + $this->model->sort_flags = $this->sort_flags ? constant($this->sort_flags) : $this->model->sort_flags; # Create the object and return it return $this->model->create(apply: $this->request_data['apply'] === true); @@ -1128,6 +1181,7 @@ class Endpoint { # Allow the endpoint/client to override the Model's sort_by and sort_order properties $this->model->sort_by = $this->sort_by ?? $this->model->sort_by; $this->model->sort_order = constant($this->sort_order) ?? $this->model->sort_order; + $this->model->sort_flags = $this->sort_flags ? constant($this->sort_flags) : $this->model->sort_flags; # Update the object and return it return $this->model->update( diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/Model.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/Model.inc index c8c4caf3f..921ff495d 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/Model.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/Model.inc @@ -214,13 +214,22 @@ class Model { public int|null $placement = null; /** - * @var int|null $sort_order + * @var int $sort_order * For $many enabled Models, this property can be used to set the PHP sort option used when writing Model objects to * config. This property is only applicable if the $sort_by_field property is also defined. This property only applies * to Models with a $config_path defined. For valid value options for this property, refer to: * https://www.php.net/manual/en/function.array-multisort.php */ - public int|null $sort_order = null; + public int $sort_order = SORT_ASC; + + /** + * @var int $sort_flags + * For $many enabled Models, this property can be used to set the PHP sort flags used when writing Model objects to + * config. This property is only applicable if the $sort_by_field property is also defined. This property only applies + * to Models with a $config_path defined. For valid value options for this property, refer to: + * https://www.php.net/manual/en/function.array-multisort.php + */ + public int $sort_flags = SORT_REGULAR; /** * @var array|null $sort_by @@ -1689,8 +1698,8 @@ class Model { * internal objects must be written in a specific order. */ protected function sort(): void { - # Do not sort if there is no `sort_order` or `sort_by` set - if (!$this->sort_order or !$this->sort_by) { + # Do not sort if there is no `sort_by` fields set + if (!$this->sort_by) { return; } @@ -1703,7 +1712,12 @@ class Model { } # Obtain all Model objects for this Model and sort them by the requested criteria - $modelset = $this->query(parent_id: $this->parent_id, sort_by: $this->sort_by, sort_order: $this->sort_order); + $modelset = $this->query( + parent_id: $this->parent_id, + sort_by: $this->sort_by, + sort_order: $this->sort_order, + sort_flags: $this->sort_flags, + ); # Loop through the sorted object and assign it's internal value $internal_objects = []; @@ -1915,6 +1929,7 @@ class Model { bool $reverse = false, ?array $sort_by = null, int $sort_order = SORT_ASC, + int $sort_flags = SORT_REGULAR, ...$vl_query_params, ): ModelSet { # Merge the $query_params and any provided variable-length arguments into a single variable @@ -1929,7 +1944,9 @@ class Model { $modelset = self::read_all(parent_id: $parent_id)->query(query_params: $query_params, excluded: $excluded); # Sort the set if a sort field was provided - $modelset = $sort_by ? $modelset->sort(fields: $sort_by, order: $sort_order, retain_ids: true) : $modelset; + if ($sort_by) { + $modelset = $modelset->sort(fields: $sort_by, order: $sort_order, flags: $sort_flags, retain_ids: true); + } # Apply pagination to limit the number of objects returned and/or reverse the order if requested $modelset->model_objects = self::paginate($modelset->model_objects, $limit, $offset); diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/ModelSet.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/ModelSet.inc index ff824c07e..5a7e572c8 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/ModelSet.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/ModelSet.inc @@ -83,40 +83,44 @@ class ModelSet { * Sorts the Model objects in this ModelSet by a specific field and order. * @param string|array $fields The field(s) to sort the Model objects by. * @param int $order The order to sort the Model objects by. This must be a PHP sort order constant. + * @param int $flags The flags to use when sorting the Model objects. This must be a PHP sort flags constant. * @param bool $retain_ids Retain the original Model object IDs when sorting. * @return ModelSet A new ModelSet object with the Model objects sorted by the specified field and order. */ - public function sort(string|array $fields, int $order = SORT_ASC, bool $retain_ids = false): ModelSet { - # Variables - $model_objects = $this->model_objects; + public function sort( + string|array $fields, + int $order = SORT_ASC, + int $flags = SORT_REGULAR, + bool $retain_ids = false, + ): ModelSet { + # Ensure fields is an array $fields = is_array($fields) ? $fields : [$fields]; - $sort_criteria = []; - - # Loop through each Model object and add the field values to the sort criteria - foreach ($this->model_objects as $model_object) { - # Loop variables - $sort_values = []; - # Loop through each field to sort by and extract it's value - foreach ($fields as $field) { - $sort_values[] = $field === 'id' ? $model_object->id : $model_object->$field->value; - } - - # Add the sort values to the sort criteria - $sort_criteria[] = $sort_values; + # Extract sorting values for each field + $sort_criteria = []; + foreach ($fields as $field) { + $sort_criteria[] = array_map( + fn($model) => $field === 'id' ? $model->id : $model->$field->value, + $this->model_objects, + ); + $sort_criteria[] = $order; + $sort_criteria[] = $flags; } - # Sort using array_multisort - array_multisort($sort_criteria, $order, $model_objects); + # Append the actual model objects array for sorting + $sort_criteria[] = &$this->model_objects; + + # Sort the array + array_multisort(...$sort_criteria); - # Re-assign the model object IDs if they should not be retained + # Re-assign model object IDs if they should not be retained if (!$retain_ids) { - foreach ($model_objects as $id => $model_object) { + foreach ($this->model_objects as $id => $model_object) { $model_object->id = $id; } } - return new ModelSet($model_objects); + return new ModelSet($this->model_objects); } /** diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/IntegerField.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/IntegerField.inc index 79f8ab30e..be02ee039 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/IntegerField.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/IntegerField.inc @@ -142,8 +142,8 @@ class IntegerField extends Field { # Otherwise, the internal value cannot be represented by this Field. Throw an error. throw new RESTAPI\Responses\ServerError( - message: "Cannot parse IntegerField '$this->name' from internal because its internal value is not - a numeric value. Consider changing this field to a StringField.", + message: "Cannot parse IntegerField '$this->name' from internal because its internal value is not a " . + 'numeric value. Consider changing this field to a StringField.', response_id: 'INTEGER_FIELD_WITH_NON_INTEGER_INTERNAL_VALUE', ); } diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/DHCPServerStaticMapping.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/DHCPServerStaticMapping.inc index 5967a61d0..224eed996 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/DHCPServerStaticMapping.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/DHCPServerStaticMapping.inc @@ -42,6 +42,7 @@ class DHCPServerStaticMapping extends Model { $this->subsystem = 'dhcpd'; $this->many = true; $this->sort_by = ['ipaddr']; + $this->sort_flags = SORT_NATURAL; # Define model Fields $this->mac = new StringField( diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/FirewallAlias.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/FirewallAlias.inc index b626884b5..13fff9622 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/FirewallAlias.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/FirewallAlias.inc @@ -120,7 +120,7 @@ class FirewallAlias extends Model { $network_alias_q = $aliases->query(name: $address, type: 'network'); if ($type === 'network' and !is_subnet($address) and !is_fqdn($address) and !$network_alias_q->exists()) { throw new ValidationError( - message: "Host alias 'address' value '$address' is not a valid CIDR, FQDN, or alias.", + message: "Network alias 'address' value '$address' is not a valid CIDR, FQDN, or alias.", response_id: 'INVALID_NETWORK_ALIAS_ADDRESS', ); } diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/OpenVPNServer.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/OpenVPNServer.inc index d66183361..46bbd6a94 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/OpenVPNServer.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/OpenVPNServer.inc @@ -325,6 +325,7 @@ class OpenVPNServer extends Model { default: [], allow_empty: true, many: true, + many_maximum: 10000, conditions: ['gwredir' => false], validators: [new SubnetValidator(allow_ipv4: true, allow_ipv6: false, allow_alias: true)], help_text: 'The IPv4 networks that will be accessible from the remote endpoint. Expressed as a ' . diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/StaticRoute.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/StaticRoute.inc index e8cd13505..2b4dbbc06 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/StaticRoute.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/StaticRoute.inc @@ -221,6 +221,7 @@ class StaticRoute extends Model { */ private function serialize_route(): void { global $g; + $old_network = []; # Include pending route changes that haven't been applied yet, or simply initialize our pending change var if (file_exists("{$g['tmp_path']}/.system_routes.apply")) { diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Schemas/GraphQLSchema.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Schemas/GraphQLSchema.inc index 7ef312e2f..10b9d9e64 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Schemas/GraphQLSchema.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Schemas/GraphQLSchema.inc @@ -216,7 +216,11 @@ class GraphQLSchema extends Schema { foreach ($model->get_fields() as $field) { # For NestedModelFields, ensure we pass the Type for the nested Model if ($model->$field instanceof NestedModelField) { - $nested_model_type = $this->get_model_object_type($model->$field->model_class); + if ($input) { + $nested_model_type = $this->get_model_input_object_type($model->$field->model_class); + } else { + $nested_model_type = $this->get_model_object_type($model->$field->model_class); + } $fields[$field]['type'] = $this->field_to_type(field: $model->$field, type: $nested_model_type); $fields[$field]['description'] = $model->$field->help_text; continue; diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Schemas/OpenAPISchema.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Schemas/OpenAPISchema.inc index 4a67f54b5..cb06afe2a 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Schemas/OpenAPISchema.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Schemas/OpenAPISchema.inc @@ -336,13 +336,30 @@ class OpenAPISchema extends Schema { [ 'in' => 'query', 'name' => 'sort_order', - 'description' => 'Sort the response data in descending order.', + 'description' => 'The order to sort response data by.', 'schema' => [ 'type' => 'string', 'enum' => ['SORT_ASC', 'SORT_DESC'], 'nullable' => true, ], ], + [ + 'in' => 'query', + 'name' => 'sort_flags', + 'description' => 'The sort flag to use to customize the behavior of the sort.', + 'schema' => [ + 'type' => 'string', + 'nullable' => true, + 'enum' => [ + 'SORT_REGULAR', + 'SORT_NUMERIC', + 'SORT_STRING', + 'SORT_LOCALE_STRING', + 'SORT_NATURAL', + 'SORT_FLAG_CASE', + ], + ], + ], [ 'in' => 'query', 'name' => 'query', diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APICoreEndpointTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APICoreEndpointTestCase.inc index d502b13cc..33dde5703 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APICoreEndpointTestCase.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APICoreEndpointTestCase.inc @@ -810,4 +810,77 @@ class APICoreEndpointTestCase extends TestCase { $this->assert_equals($resp->response_id, 'ENDPOINT_SORT_ORDER_FIELD_UNKNOWN_SORT_ORDER'); $this->assert_equals($resp->code, 400); } + + /** + * Ensure the 'sort_flags' common control parameter must be a valid PHP sort constant. + */ + public function test_sort_flags_must_be_php_sort_constant(): void { + # Use a GET request to request a non-string sort order + $json_resp = \RESTAPI\Core\Tools\http_request( + url: 'https://localhost/api/v2/firewall/aliases', + method: 'GET', + data: ['sort_by' => 'name', 'sort_flags' => 12345], + headers: ['Content-Type' => 'application/json'], + username: 'admin', + password: 'pfsense', + validate_certs: false, + ); + # Ensure the response threw a ENDPOINT_SORT_FLAGS_FIELD_INVALID_TYPE error + $resp = json_decode($json_resp); + $this->assert_equals($resp->response_id, 'ENDPOINT_SORT_FLAGS_FIELD_INVALID_TYPE'); + $this->assert_equals($resp->code, 400); + + # Make another request to sort order that is not known + $json_resp = \RESTAPI\Core\Tools\http_request( + url: 'https://localhost/api/v2/firewall/aliases', + method: 'GET', + data: ['sort_by' => 'name', 'sort_flags' => 'unknown_sort_flags'], + headers: ['Content-Type' => 'application/json'], + username: 'admin', + password: 'pfsense', + validate_certs: false, + ); + + # Ensure the response threw a ENDPOINT_SORT_FLAGS_FIELD_UNKNOWN_SORT_FLAGS error + $resp = json_decode($json_resp); + $this->assert_equals($resp->response_id, 'ENDPOINT_SORT_FLAGS_FIELD_UNKNOWN_SORT_FLAGS'); + $this->assert_equals($resp->code, 400); + } + + /** + * Ensure clients can specify the 'sort_flags' common control parameter to sort the returned data. + */ + public function test_sort_flags(): void { + # Use a PUT request to create new aliases + \RESTAPI\Core\Tools\http_request( + url: 'https://localhost/api/v2/firewall/aliases', + method: 'PUT', + data: [ + ['name' => 'alias_a', 'type' => 'host', 'descr' => '127.0.0.22'], + ['name' => 'alias_b', 'type' => 'host', 'descr' => '127.0.0.2'], + ['name' => 'alias_c', 'type' => 'host', 'descr' => '127.0.0.100'], + ['name' => 'alias_d', 'type' => 'host', 'descr' => '127.0.0.10'], + ], + headers: ['Content-Type' => 'application/json'], + username: 'admin', + password: 'pfsense', + validate_certs: false, + ); + + # Ensure we can make a successful GET request to a `many` endpoint with the 'sort_flags' control parameter + $json_resp = \RESTAPI\Core\Tools\http_request( + url: 'https://localhost/api/v2/firewall/aliases', + method: 'GET', + data: ['sort_by' => 'descr', 'sort_flags' => 'SORT_NATURAL'], + headers: ['Content-Type' => 'application/json'], + username: 'admin', + password: 'pfsense', + validate_certs: false, + ); + $resp = json_decode($json_resp); + $this->assert_equals($resp->data[0]->name, 'alias_b'); + $this->assert_equals($resp->data[1]->name, 'alias_d'); + $this->assert_equals($resp->data[2]->name, 'alias_a'); + $this->assert_equals($resp->data[3]->name, 'alias_c'); + } } diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APICoreModelSetTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APICoreModelSetTestCase.inc index c8389fc2d..b406f6c72 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APICoreModelSetTestCase.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APICoreModelSetTestCase.inc @@ -435,4 +435,36 @@ class APICoreModelSetTestCase extends RESTAPI\Core\TestCase { $host_9, ]); } + + /** + * Checks that ModelSets can be sorted using the requested sort flags + */ + public function test_sort_flags(): void { + # Create test firewall aliases with descriptions set to IP addresses + $alias_a = new FirewallAlias(name: 'alias_a', type: 'host', descr: '127.0.0.22'); + $alias_b = new FirewallAlias(name: 'alias_b', type: 'host', descr: '127.0.0.200'); + $alias_c = new FirewallAlias(name: 'alias_c', type: 'host', descr: '127.0.0.100'); + $alias_a->create(); + $alias_b->create(); + $alias_c->create(); + + # Read the firewall aliases and use regular sorting to ensure the order is correct + # This should sort the IP address out of logical order since SORT_REGULAR does not sort numerically + $modelset = FirewallAlias::read_all()->sort(['descr'], order: SORT_ASC, flags: SORT_REGULAR); + $this->assert_equals($modelset->model_objects[0]->name->value, 'alias_c'); + $this->assert_equals($modelset->model_objects[1]->name->value, 'alias_b'); + $this->assert_equals($modelset->model_objects[2]->name->value, 'alias_a'); + + # Read the firewall aliases and use natural sorting to ensure the order is correct + # This should sort the IP address in numerical order since SORT_NATURAL does sort numerically + $modelset = FirewallAlias::read_all()->sort(['descr'], order: SORT_ASC, flags: SORT_NATURAL); + $this->assert_equals($modelset->model_objects[0]->name->value, 'alias_a'); + $this->assert_equals($modelset->model_objects[1]->name->value, 'alias_c'); + $this->assert_equals($modelset->model_objects[2]->name->value, 'alias_b'); + + # Delete the firewall aliases + $alias_a->delete(); + $alias_b->delete(); + $alias_c->delete(); + } } diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsStaticRouteTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsStaticRouteTestCase.inc index 2a9c9c5d8..5914d1b7b 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsStaticRouteTestCase.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsStaticRouteTestCase.inc @@ -341,4 +341,31 @@ class APIModelsStaticRouteTestCase extends TestCase { # Delete the gateway used for testing $test_gw->delete(apply: true); } + + /** + * Checks that we can update a newly created static route that has not yet been applied. Regression test for #654. + */ + public function test_update_static_route_before_its_applied(): void { + # Create a gateway to use for testing + $test_gw = new RoutingGateway( + name: 'TESTGW', + interface: 'lan', + ipprotocol: 'inet', + gateway: '192.168.1.10', + async: false, + ); + $test_gw->create(); + + # Create a static route using the gateway above, but do not apply it! + $test_route = new StaticRoute(gateway: $test_gw->name->value, network: '1.2.3.4/32'); + $test_route->create(); + + # Ensure we can update the static route before applying it + $test_route->network->value = '4.3.2.1/32'; + $test_route->update(); + + # Remove the static route and gateway + $test_route->delete(); + $test_gw->delete(apply: true); + } }