From fb573f7dc1df6c152b9b06b49e697349e44f69c6 Mon Sep 17 00:00:00 2001 From: Vojtech Kloda Date: Wed, 14 May 2025 14:51:37 +0200 Subject: [PATCH 01/20] added validator tests --- tests/Validation/Validators.phpt | 247 +++++++++++++++++++++++++++++++ 1 file changed, 247 insertions(+) create mode 100644 tests/Validation/Validators.phpt diff --git a/tests/Validation/Validators.phpt b/tests/Validation/Validators.phpt new file mode 100644 index 000000000..832c3652f --- /dev/null +++ b/tests/Validation/Validators.phpt @@ -0,0 +1,247 @@ +container = $container; + } + + private static function getAssertionFailedMessage( + BaseValidator $validator, + mixed $value, + bool $expectedValid, + bool $strict + ): string { + $classTokens = explode("\\", get_class($validator)); + $class = $classTokens[array_key_last($classTokens)]; + $strictString = $strict ? "strict" : "permissive"; + $expectedString = $expectedValid ? "valid" : "invalid"; + $valueString = json_encode($value); + return "Asserts that the value <$valueString> using $strictString validator <$class> is $expectedString"; + } + + private static function assertAllValid(BaseValidator $validator, array $values, bool $strict) + { + foreach ($values as $value) { + $failMessage = self::getAssertionFailedMessage($validator, $value, true, $strict); + Assert::true($validator->validate($value), $failMessage); + } + } + + private static function assertAllInvalid(BaseValidator $validator, array $values, bool $strict) + { + foreach ($values as $value) { + $failMessage = self::getAssertionFailedMessage($validator, $value, false, $strict); + Assert::false($validator->validate($value), $failMessage); + } + } + + /** + * Test a validator against a set of input values. The strictness mode is set automatically by the method. + * @param App\Helpers\MetaFormats\Validators\BaseValidator $validator The validator to be tested. + * @param array $strictValid Valid values in the strict mode. + * @param array $strictInvalid Invalid values in the strict mode. + * @param array $permissiveValid Valid values in the permissive mode. + * @param array $permissiveInvalid Invalid values in the permissive mode. + */ + private static function validatorTester( + BaseValidator $validator, + array $strictValid, + array $strictInvalid, + array $permissiveValid, + array $permissiveInvalid + ): void { + // test strict + $validator->setStrict(true); + self::assertAllValid($validator, $strictValid, true); + self::assertAllInvalid($validator, $strictInvalid, true); + // all invalid values in the permissive mode have to be invalid in the strict mode + self::assertAllInvalid($validator, $permissiveInvalid, true); + + // test permissive + $validator->setStrict(false); + self::assertAllValid($validator, $permissiveValid, false); + self::assertAllInvalid($validator, $permissiveInvalid, false); + // all valid values in the strict mode have to be valid in the permissive mode + self::assertAllValid($validator, $strictValid, false); + } + + public function testVBool() + { + $validator = new VBool(); + $strictValid = [true, false]; + $strictInvalid = [0, 1, -1, [], "0", "1", "true", "false", "", "text"]; + $permissiveValid = [true, false, 0, 1, "0", "1", "true", "false"]; + $permissiveInvalid = [-1, [], "", "text"]; + self::validatorTester($validator, $strictValid, $strictInvalid, $permissiveValid, $permissiveInvalid); + } + + public function testVInt() + { + $validator = new VInt(); + $strictValid = [0, 1, -1]; + $strictInvalid = [0.0, 2.5, "0", "1", "-1", "0.0", "", false, []]; + $permissiveValid = [0, 1, -1, 0.0, "0", "1", "-1", "0.0"]; + $permissiveInvalid = ["", 2.5, false, []]; + self::validatorTester($validator, $strictValid, $strictInvalid, $permissiveValid, $permissiveInvalid); + } + + public function testVTimestamp() + { + // timestamps are just ints (unix timestamps, timestamps can be negative) + $validator = new VTimestamp(); + $strictValid = [0, 1, -1]; + $strictInvalid = [0.0, 2.5, "0", "1", "-1", "0.0", "", false, []]; + $permissiveValid = [0, 1, -1, 0.0, "0", "1", "-1", "0.0"]; + $permissiveInvalid = ["", 2.5, false, []]; + self::validatorTester($validator, $strictValid, $strictInvalid, $permissiveValid, $permissiveInvalid); + } + + public function testVDouble() + { + $validator = new VDouble(); + $strictValid = [0, 1, -1, 0.0, 2.5]; + $strictInvalid = ["0", "1", "-1", "0.0", "2.5", "", false, []]; + $permissiveValid = [0, 1, -1, 0.0, 2.5, "0", "1", "-1", "0.0", "2.5"]; + $permissiveInvalid = ["", false, []]; + self::validatorTester($validator, $strictValid, $strictInvalid, $permissiveValid, $permissiveInvalid); + } + + public function testVArrayShallow() + { + // no nested validators, strictness has no effect + $validator = new VArray(); + $valid = [[], [[]], [0], [[], 0]]; + $invalid = ["[]", 0, false, ""]; + self::validatorTester($validator, $valid, $invalid, $valid, $invalid); + } + + public function testVArrayNested() + { + // nested array validator, strictness has no effect + $validator = new VArray(new VArray()); + $valid = [[[]], []]; // an array without any nested arrays is still valid (it just has 0 elements) + $invalid = [[0], [[], 0], "[]", 0, false, ""]; + self::validatorTester($validator, $valid, $invalid, $valid, $invalid); + } + + public function testVArrayNestedInt() + { + // nested int validator, strictness affects int validation + $validator = new VArray(new VInt()); + $strictValid = [[], [0]]; + $strictInvalid = [["0"], [0.0], [[]], [[], 0], "[]", 0, false, ""]; + $permissiveValid = [[], [0], ["0"], [0.0]]; + $permissiveInvalid = [[[]], [[], 0], "[]", 0, false, ""]; + self::validatorTester($validator, $strictValid, $strictInvalid, $permissiveValid, $permissiveInvalid); + } + + public function testVArrayDoublyNestedInt() + { + // doubly nested int validator, strictness affects int validation through the middle array validator + $validator = new VArray(new VArray(new VInt())); + $strictValid = [[], [[]], [[0]]]; + $strictInvalid = [[0], [["0"]], [[0.0]], [[], 0], "[]", 0, false, ""]; + $permissiveValid = [[], [[]], [[0]], [["0"]], [[0.0]]]; + $permissiveInvalid = [[0], [[], 0], "[]", 0, false, ""]; + self::validatorTester($validator, $strictValid, $strictInvalid, $permissiveValid, $permissiveInvalid); + } + + public function testVStringBasic() + { + // strictness does not affect strings + $validator = new VString(); + $valid = ["", "text"]; + $invalid = [0, false, []]; + self::validatorTester($validator, $valid, $invalid, $valid, $invalid); + } + + public function testVStringLength() + { + // strictness does not affect strings + $validator = new VString(minLength: 2); + $valid = ["ab", "text"]; + $invalid = ["", "a", 0, false, []]; + self::validatorTester($validator, $valid, $invalid, $valid, $invalid); + + $validator = new VString(maxLength: 2); + $valid = ["", "a", "ab"]; + $invalid = ["abc", "text", 0, false, []]; + self::validatorTester($validator, $valid, $invalid, $valid, $invalid); + + $validator = new VString(minLength: 2, maxLength: 3); + $valid = ["ab", "abc"]; + $invalid = ["", "a", "text", 0, false, []]; + self::validatorTester($validator, $valid, $invalid, $valid, $invalid); + } + + public function testVStringRegex() + { + // strictness does not affect strings + $validator = new VString(regex: "/^A[0-9a-f]{2}$/"); + $valid = ["A2c", "Add", "A00"]; + $invalid = ["2c", "a2c", "A2g", "A2cc", "", 0, false, []]; + self::validatorTester($validator, $valid, $invalid, $valid, $invalid); + } + + public function testVStringComplex() + { + // strictness does not affect strings + $validator = new VString(minLength: 1, maxLength: 2, regex: "/^[0-9a-f]*$/"); + $valid = ["a", "aa", "0a"]; + $invalid = ["", "g", "aaa", 0, false, []]; + self::validatorTester($validator, $valid, $invalid, $valid, $invalid); + } + + public function testVUuid() + { + // strictness does not affect strings + $validator = new VUuid(); + $valid = ["10000000-2000-4000-8000-160000000000"]; + $invalid = ["g0000000-2000-4000-8000-160000000000", "010000000-2000-4000-8000-160000000000", 0, false, []]; + self::validatorTester($validator, $valid, $invalid, $valid, $invalid); + } + + public function testVMixed() + { + // accepts everything + $validator = new VMixed(); + $valid = [0, 1.2, -1, "", false, [], new VMixed()]; + $invalid = []; + self::validatorTester($validator, $valid, $invalid, $valid, $invalid); + } + + public function testVObject() + { + // accepts all formats (content is not validated, that is done with the checkedAssign method) + $validator = new VObject(UserFormat::class); + $valid = [new UserFormat()]; + $invalid = [0, 1.2, -1, "", false, [], new VMixed()]; + self::validatorTester($validator, $valid, $invalid, $valid, $invalid); + } +} + +(new TestValidators())->run(); From 0d31158e3bf36576e8bf8a5093125006e1c0277a Mon Sep 17 00:00:00 2001 From: Vojtech Kloda Date: Wed, 14 May 2025 22:55:14 +0200 Subject: [PATCH 02/20] MetaFormat::validate now throws instead of returning bool, fixed bad error annotations --- app/V1Module/presenters/base/BasePresenter.php | 5 ++++- app/helpers/MetaFormats/MetaFormat.php | 16 +++++++--------- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/app/V1Module/presenters/base/BasePresenter.php b/app/V1Module/presenters/base/BasePresenter.php index 59242990f..8a5a600e1 100644 --- a/app/V1Module/presenters/base/BasePresenter.php +++ b/app/V1Module/presenters/base/BasePresenter.php @@ -12,6 +12,7 @@ use App\Exceptions\WrongHttpMethodException; use App\Exceptions\NotImplementedException; use App\Exceptions\InternalServerException; +use App\Exceptions\InvalidApiArgumentException; use App\Exceptions\FrontendErrorMappings; use App\Security\AccessManager; use App\Security\Authorizator; @@ -219,6 +220,7 @@ private function processParams(ReflectionMethod $reflection) /** * Processes loose parameters. Request parameters are validated, no new data is created. + * @throws InvalidApiArgumentException Thrown when the request parameter values do not conform to the definition. * @param array $paramData Parameter data to be validated. */ private function processParamsLoose(array $paramData) @@ -240,7 +242,8 @@ private function processParamsLoose(array $paramData) * from here instead of the request object. Format validation ignores parameter type (path, query or post). * A top-level format will be created if null. * @throws InternalServerException Thrown when the format definition is corrupted/absent. - * @throws BadRequestException Thrown when the request parameter values do not conform to the definition. + * @throws BadRequestException Thrown when the request parameter values do not meet the structural constraints. + * @throws InvalidApiArgumentException Thrown when the request parameter values do not conform to the definition. * @return MetaFormat Returns a format instance with values filled from the request object. */ private function processParamsFormat(string $format, ?array $valueDictionary): MetaFormat diff --git a/app/helpers/MetaFormats/MetaFormat.php b/app/helpers/MetaFormats/MetaFormat.php index 48f4dcf98..8138eff92 100644 --- a/app/helpers/MetaFormats/MetaFormat.php +++ b/app/helpers/MetaFormats/MetaFormat.php @@ -2,6 +2,7 @@ namespace App\Helpers\MetaFormats; +use App\Exceptions\BadRequestException; use App\Exceptions\InternalServerException; use App\Exceptions\InvalidApiArgumentException; @@ -42,29 +43,26 @@ public function checkedAssign(string $fieldName, mixed $value) /** * Validates the given format. - * @return bool Returns whether the format and all nested formats are valid. + * @throws InvalidApiArgumentException Thrown when a value is not assignable. + * @throws BadRequestException Thrown when the structural constraints were not met. */ public function validate() { // check whether all higher level contracts hold if (!$this->validateStructure()) { - return false; + throw new BadRequestException("The structural constraints of the format were not met."); } // go through all fields and check whether they were assigned properly $fieldFormats = FormatCache::getFieldDefinitions(get_class($this)); foreach ($fieldFormats as $fieldName => $fieldFormat) { - if (!$this->checkIfAssignable($fieldName, $this->$fieldName)) { - return false; - } + $this->checkIfAssignable($fieldName, $this->$fieldName); // check nested formats recursively - if ($this->$fieldName instanceof MetaFormat && !$this->$fieldName->validate()) { - return false; + if ($this->$fieldName instanceof MetaFormat) { + $this->$fieldName->validate(); } } - - return true; } /** From 9213a9e8249a74c54cead79d82770b73b0500c4b Mon Sep 17 00:00:00 2001 From: Vojtech Kloda Date: Wed, 14 May 2025 22:55:30 +0200 Subject: [PATCH 03/20] added format tests --- tests/Validation/Formats.phpt | 246 ++++++++++++++++++++++++++++++++++ 1 file changed, 246 insertions(+) create mode 100644 tests/Validation/Formats.phpt diff --git a/tests/Validation/Formats.phpt b/tests/Validation/Formats.phpt new file mode 100644 index 000000000..de0a8d20f --- /dev/null +++ b/tests/Validation/Formats.phpt @@ -0,0 +1,246 @@ +query == 1; + } +} + +/** + * @testCase + */ +class TestFormats extends Tester\TestCase +{ + /** @var Nette\DI\Container */ + protected $container; + + public function __construct() + { + global $container; + $this->container = $container; + } + + private function injectFormat(string $format) + { + // initialize the cache + FormatCache::getFormatToFieldDefinitionsMap(); + FormatCache::getFormatNamesHashSet(); + + // inject the format name + $hashSetReflector = new ReflectionProperty(FormatCache::class, "formatNamesHashSet"); + $hashSetReflector->setAccessible(true); + $formatNamesHashSet = $hashSetReflector->getValue(); + $formatNamesHashSet[$format] = true; + $hashSetReflector->setValue(null, $formatNamesHashSet); + + // inject the format definitions + $formatMapReflector = new ReflectionProperty(FormatCache::class, "formatToFieldFormatsMap"); + $formatMapReflector->setAccessible(true); + $formatToFieldFormatsMap = $formatMapReflector->getValue(); + $formatToFieldFormatsMap[$format] = MetaFormatHelper::createNameToFieldDefinitionsMap($format); + $formatMapReflector->setValue(null, $formatToFieldFormatsMap); + + Assert::notNull(FormatCache::getFieldDefinitions($format), "Tests whether a format was injected successfully."); + } + + public function testInvalidFieldName() + { + self::injectFormat(RequiredNullabilityTestFormat::class); + + Assert::throws( + function () { + try { + $format = new RequiredNullabilityTestFormat(); + $format->checkedAssign("invalidIdentifier", null); + } catch (Exception $e) { + Assert::true(strlen($e->getMessage()) > 0); + throw $e; + } + }, + InternalServerException::class + ); + } + + public function testRequiredNotNullable() + { + self::injectFormat(RequiredNullabilityTestFormat::class); + $fieldName = "requiredNotNullable"; + + // it is not nullable so this has to throw + Assert::throws( + function () use ($fieldName) { + try { + $format = new RequiredNullabilityTestFormat(); + $format->checkedAssign($fieldName, null); + } catch (Exception $e) { + Assert::true(strlen($e->getMessage()) > 0); + throw $e; + } + }, + InvalidApiArgumentException::class + ); + + // assign 1 + $format = new RequiredNullabilityTestFormat(); + $format->checkedAssign($fieldName, 1); + Assert::equal($format->$fieldName, 1); + } + + public function testNullAssign() + { + self::injectFormat(RequiredNullabilityTestFormat::class); + $format = new RequiredNullabilityTestFormat(); + + // not required and not nullable fields can contain null (not required overrides not nullable) + foreach (["requiredNullable", "notRequiredNullable", "notRequiredNotNullable"] as $fieldName) { + // assign 1 + $format->checkedAssign($fieldName, 1); + Assert::equal($format->$fieldName, 1); + + // assign null + $format->checkedAssign($fieldName, null); + Assert::equal($format->$fieldName, null); + } + } + + public function testIndividualParamValidation() + { + self::injectFormat(ValidationTestFormat::class); + $format = new ValidationTestFormat(); + + // path and query parameters do not have strict validation + $format->checkedAssign("query", "1"); + $format->checkedAssign("query", 1); + $format->checkedAssign("path", "1"); + $format->checkedAssign("path", 1); + + // post parameters have strict validation, assigning a string will throw + $format->checkedAssign("post", 1); + Assert::throws( + function () use ($format) { + try { + $format->checkedAssign("post", "1"); + } catch (Exception $e) { + Assert::true(strlen($e->getMessage()) > 0); + throw $e; + } + }, + InvalidApiArgumentException::class + ); + + // null cannot be assigned unless the parameter is nullable or not required + $format->checkedAssign("queryOptional", null); + Assert::throws( + function () use ($format) { + try { + $format->checkedAssign("query", null); + } catch (Exception $e) { + Assert::true(strlen($e->getMessage()) > 0); + throw $e; + } + }, + InvalidApiArgumentException::class + ); + } + + public function testAggregateParamValidation() + { + self::injectFormat(ValidationTestFormat::class); + $format = new ValidationTestFormat(); + + $format->checkedAssign("query", 1); + $format->checkedAssign("path", 1); + $format->checkedAssign("post", 1); + $format->checkedAssign("queryOptional", null); + $format->validate(); + + // invalidate a format field + Assert::throws( + function () use ($format) { + try { + // bypass the checkedAssign + $format->path = null; + $format->validate(); + } catch (Exception $e) { + Assert::true(strlen($e->getMessage()) > 0); + throw $e; + } + }, + InvalidApiArgumentException::class + ); + + // assign valid values to all fields, but fail the structural constraint of $query == 1 + $format->checkedAssign("path", 1); + $format->checkedAssign("query", 2); + Assert::throws( + function () use ($format) { + try { + $format->validate(); + } catch (Exception $e) { + Assert::true(strlen($e->getMessage()) > 0); + throw $e; + } + }, + BadRequestException::class + ); + } + + ///TODO: nested formats, loose format +} + +(new TestFormats())->run(); From 0daa9d48350d83c59861ccf317f7b57ea2a74165 Mon Sep 17 00:00:00 2001 From: Vojtech Kloda Date: Fri, 16 May 2025 17:20:09 +0200 Subject: [PATCH 04/20] made many methods accept a request object instead of fetching it themselves --- .../presenters/base/BasePresenter.php | 49 ++++++++++--------- 1 file changed, 26 insertions(+), 23 deletions(-) diff --git a/app/V1Module/presenters/base/BasePresenter.php b/app/V1Module/presenters/base/BasePresenter.php index 8a5a600e1..daf7ad933 100644 --- a/app/V1Module/presenters/base/BasePresenter.php +++ b/app/V1Module/presenters/base/BasePresenter.php @@ -27,6 +27,7 @@ use App\Responses\StorageFileResponse; use App\Responses\ZipFilesResponse; use Nette\Application\Application; +use Nette\Application\Request; use Nette\Http\IResponse; use Tracy\ILogger; use ReflectionClass; @@ -129,7 +130,7 @@ public function startup() $this->tryCall($this->formatPermissionCheckMethod($this->getAction()), $this->params); Validators::init(); - $this->processParams($actionReflection); + $this->processParams($this->getRequest(), $actionReflection); } protected function isRequestJson(): bool @@ -205,29 +206,30 @@ public function getFormatInstance(): MetaFormat return $this->requestFormatInstance; } - private function processParams(ReflectionMethod $reflection) + private function processParams(Request $request, ReflectionMethod $reflection) { // use a method specialized for formats if there is a format available $format = MetaFormatHelper::extractFormatFromAttribute($reflection); if ($format !== null) { - $this->requestFormatInstance = $this->processParamsFormat($format, null); + $this->requestFormatInstance = $this->processParamsFormat($request, $format, null); } // handle loose parameters $paramData = MetaFormatHelper::extractRequestParamData($reflection); - $this->processParamsLoose($paramData); + $this->processParamsLoose($request, $paramData); } /** * Processes loose parameters. Request parameters are validated, no new data is created. * @throws InvalidApiArgumentException Thrown when the request parameter values do not conform to the definition. + * @param Request $request Request object holding the request data. * @param array $paramData Parameter data to be validated. */ - private function processParamsLoose(array $paramData) + private function processParamsLoose(Request $request, array $paramData) { // validate each param foreach ($paramData as $param) { - $paramValue = $this->getValueFromParamData($param); + $paramValue = $this->getValueFromParamData($request, $param); // this throws when it does not conform $param->conformsToDefinition($paramValue); @@ -237,6 +239,7 @@ private function processParamsLoose(array $paramData) /** * Processes parameters defined by a format. Request parameters are validated and a format instance with * parameter values created. + * @param Request $request Request object holding the request data. * @param string $format The format defining the parameters. * @param ?array $valueDictionary If not null, a nested format instance will be created. The values will be taken * from here instead of the request object. Format validation ignores parameter type (path, query or post). @@ -246,7 +249,7 @@ private function processParamsLoose(array $paramData) * @throws InvalidApiArgumentException Thrown when the request parameter values do not conform to the definition. * @return MetaFormat Returns a format instance with values filled from the request object. */ - private function processParamsFormat(string $format, ?array $valueDictionary): MetaFormat + private function processParamsFormat(Request $request, string $format, ?array $valueDictionary): MetaFormat { // get the parsed attribute data from the format fields $formatToFieldDefinitionsMap = FormatCache::getFormatToFieldDefinitionsMap(); @@ -262,7 +265,7 @@ private function processParamsFormat(string $format, ?array $valueDictionary): M $value = null; // top-level format if ($valueDictionary === null) { - $value = $this->getValueFromParamData($requestParamData); + $value = $this->getValueFromParamData($request, $requestParamData); // nested format } else { // Instead of retrieving the values with the getRequest call, use the provided $valueDictionary. @@ -278,7 +281,7 @@ private function processParamsFormat(string $format, ?array $valueDictionary): M // replace the value dictionary stored in $value with a format instance $nestedFormatName = $requestParamData->getFormatName(); if ($nestedFormatName !== null) { - $value = $this->processParamsFormat($nestedFormatName, $value); + $value = $this->processParamsFormat($request, $nestedFormatName, $value); } // this throws if the value is invalid @@ -295,37 +298,37 @@ private function processParamsFormat(string $format, ?array $valueDictionary): M /** * Calls either getPostField, getQueryField or getPathField based on the provided metadata. + * @param Request $request Request object holding the request data. * @param \App\Helpers\MetaFormats\RequestParamData $paramData Metadata of the request parameter. * @throws \App\Exceptions\InternalServerException Thrown when an unexpected parameter location was set. * @return mixed Returns the value from the request. */ - private function getValueFromParamData(RequestParamData $paramData): mixed + private function getValueFromParamData(Request $request, RequestParamData $paramData): mixed { switch ($paramData->type) { case Type::Post: - return $this->getPostField($paramData->name, required: $paramData->required); + return $this->getPostField($request, $paramData->name, required: $paramData->required); case Type::Query: - return $this->getQueryField($paramData->name, required: $paramData->required); + return $this->getQueryField($request, $paramData->name, required: $paramData->required); case Type::Path: - return $this->getPathField($paramData->name); + return $this->getPathField($request, $paramData->name); default: throw new InternalServerException("Unknown parameter type: {$paramData->type->name}"); } } - private function getPostField($param, $required = true) + private function getPostField(Request $request, $param, $required = true) { - $req = $this->getRequest(); - $post = $req->getPost(); + $post = $request->getPost(); - if ($req->isMethod("POST")) { + if ($request->isMethod("POST")) { // nothing to see here... } else { - if ($req->isMethod("PUT") || $req->isMethod("DELETE")) { + if ($request->isMethod("PUT") || $request->isMethod("DELETE")) { parse_str(file_get_contents('php://input'), $post); } else { throw new WrongHttpMethodException( - "Cannot get the post parameters in method '" . $req->getMethod() . "'." + "Cannot get the post parameters in method '" . $request->getMethod() . "'." ); } } @@ -341,18 +344,18 @@ private function getPostField($param, $required = true) } } - private function getQueryField($param, $required = true) + private function getQueryField(Request $request, $param, $required = true) { - $value = $this->getRequest()->getParameter($param); + $value = $request->getParameter($param); if ($value === null && $required) { throw new BadRequestException("Missing required query field $param"); } return $value; } - private function getPathField($param) + private function getPathField(Request $request, $param) { - $value = $this->getParameter($param); + $value = $request->getParameter($param); if ($value === null) { throw new BadRequestException("Missing required path field $param"); } From eaac5b85dafbbd1bfbb0e047be156778f5dc9188 Mon Sep 17 00:00:00 2001 From: Vojtech Kloda Date: Fri, 16 May 2025 19:36:42 +0200 Subject: [PATCH 05/20] added nested format tests --- tests/Validation/Formats.phpt | 73 ++++++++++++++++++++++++++++++++++- 1 file changed, 71 insertions(+), 2 deletions(-) diff --git a/tests/Validation/Formats.phpt b/tests/Validation/Formats.phpt index de0a8d20f..79aedae90 100644 --- a/tests/Validation/Formats.phpt +++ b/tests/Validation/Formats.phpt @@ -3,6 +3,7 @@ use App\Exceptions\BadRequestException; use App\Exceptions\InternalServerException; use App\Exceptions\InvalidApiArgumentException; +use App\Helpers\MetaFormats\Attributes\Format; use App\Helpers\MetaFormats\Attributes\FPath; use App\Helpers\MetaFormats\Attributes\FPost; use App\Helpers\MetaFormats\Attributes\FQuery; @@ -61,6 +62,33 @@ class ValidationTestFormat extends MetaFormat } } +#[Format(ParentFormat::class)] +class ParentFormat extends MetaFormat +{ + #[FQuery(new VInt(), required: true, nullable: false)] + public ?int $field; + + #[FPost(new VObject(NestedFormat::class), required: true, nullable: false)] + public NestedFormat $nested; + + public function validateStructure() + { + return $this->field == 1; + } +} + +#[Format(NestedFormat::class)] +class NestedFormat extends MetaFormat +{ + #[FQuery(new VInt(), required: true, nullable: false)] + public ?int $field; + + public function validateStructure() + { + return $this->field == 2; + } +} + /** * @testCase */ @@ -75,7 +103,7 @@ class TestFormats extends Tester\TestCase $this->container = $container; } - private function injectFormat(string $format) + private static function injectFormat(string $format) { // initialize the cache FormatCache::getFormatToFieldDefinitionsMap(); @@ -227,6 +255,7 @@ class TestFormats extends Tester\TestCase // assign valid values to all fields, but fail the structural constraint of $query == 1 $format->checkedAssign("path", 1); $format->checkedAssign("query", 2); + Assert::false($format->validateStructure()); Assert::throws( function () use ($format) { try { @@ -240,7 +269,47 @@ class TestFormats extends Tester\TestCase ); } - ///TODO: nested formats, loose format + public function testNestedFormat() + { + self::injectFormat(NestedFormat::class); + self::injectFormat(ParentFormat::class); + $nested = new NestedFormat(); + $parent = new ParentFormat(); + + // assign valid values that do not pass structural validation + // (both fields need to be 1 to pass) + $nested->checkedAssign("field", 0); + $parent->checkedAssign("field", 0); + $parent->checkedAssign("nested", $nested); + + Assert::false($nested->validateStructure()); + Assert::false($parent->validateStructure()); + + Assert::throws( + function () use ($nested) { + $nested->validate(); + }, + BadRequestException::class + ); + Assert::throws( + function () use ($parent) { + $parent->validate(); + }, + BadRequestException::class + ); + + // fix the structural constain in the parent + $parent->checkedAssign("field", 1); + Assert::true($parent->validateStructure()); + + // make sure that the structural error in the nested format propagates to the parent + Assert::throws( + function () use ($parent) { + $parent->validate(); + }, + BadRequestException::class + ); + } } (new TestFormats())->run(); From 81cc0c4caba8e05733d5cf0b1f3557323cf2ad23 Mon Sep 17 00:00:00 2001 From: Vojtech Kloda Date: Fri, 16 May 2025 19:37:03 +0200 Subject: [PATCH 06/20] added BasePresenter tests --- tests/Validation/BasePresenter.phpt | 309 ++++++++++++++++++++++++++++ 1 file changed, 309 insertions(+) create mode 100644 tests/Validation/BasePresenter.phpt diff --git a/tests/Validation/BasePresenter.phpt b/tests/Validation/BasePresenter.phpt new file mode 100644 index 000000000..b32aa0067 --- /dev/null +++ b/tests/Validation/BasePresenter.phpt @@ -0,0 +1,309 @@ +query == 1; + } +} + +class TestPresenter extends BasePresenter +{ + #[Post("post", new VInt())] + #[Query("query", new VInt())] + #[Path("path", new VInt())] + public function actionTestLoose() + { + } + + #[Format(PresenterTestFormat::class)] + public function actionTestFormat() + { + } + + #[Format(PresenterTestFormat::class)] + #[Post("loose", new VInt())] + public function actionTestCombined() + { + } +} + +/** + * @testCase + */ +class TestBasePresenter extends Tester\TestCase +{ + /** @var Nette\DI\Container */ + protected $container; + + public function __construct() + { + global $container; + $this->container = $container; + } + + private static function injectFormat(string $format) + { + // initialize the cache + FormatCache::getFormatToFieldDefinitionsMap(); + FormatCache::getFormatNamesHashSet(); + + // inject the format name + $hashSetReflector = new ReflectionProperty(FormatCache::class, "formatNamesHashSet"); + $hashSetReflector->setAccessible(true); + $formatNamesHashSet = $hashSetReflector->getValue(); + $formatNamesHashSet[$format] = true; + $hashSetReflector->setValue(null, $formatNamesHashSet); + + // inject the format definitions + $formatMapReflector = new ReflectionProperty(FormatCache::class, "formatToFieldFormatsMap"); + $formatMapReflector->setAccessible(true); + $formatToFieldFormatsMap = $formatMapReflector->getValue(); + $formatToFieldFormatsMap[$format] = MetaFormatHelper::createNameToFieldDefinitionsMap($format); + $formatMapReflector->setValue(null, $formatToFieldFormatsMap); + + Assert::notNull(FormatCache::getFieldDefinitions($format), "Tests whether a format was injected successfully."); + } + + private static function getMethod(BasePresenter $presenter, string $methodName): ReflectionMethod + { + $presenterReflection = new ReflectionObject($presenter); + $methodReflection = $presenterReflection->getMethod($methodName); + $methodReflection->setAccessible(true); + return $methodReflection; + } + + public function testLooseValid() + { + $presenter = new TestPresenter(); + $action = self::getMethod($presenter, "actionTestLoose"); + $processParams = self::getMethod($presenter, "processParams"); + + // create a request object and invoke the actionTestLoose method + $request = new Request("name", method: "POST", params: ["path" => "1", "query" => "1"], post: ["post" => 1]); + $processParams->invoke($presenter, $request, $action); + + // check that the previous row did not throw + Assert::true(true); + } + + public function testLooseInvalid() + { + $presenter = new TestPresenter(); + $action = self::getMethod($presenter, "actionTestLoose"); + $processParams = self::getMethod($presenter, "processParams"); + + // set an invalid parameter value and assert that the validation fails + $request = new Request( + "name", + method: "POST", + params: ["path" => "string", "query" => "1"], + post: ["post" => 1] + ); + Assert::throws( + function () use ($processParams, $presenter, $request, $action) { + $processParams->invoke($presenter, $request, $action); + }, + InvalidApiArgumentException::class + ); + } + + public function testFormatValid() + { + self::injectFormat(PresenterTestFormat::class); + $presenter = new TestPresenter(); + $action = self::getMethod($presenter, "actionTestFormat"); + $processParams = self::getMethod($presenter, "processParams"); + + // create a valid request object + $request = new Request("name", method: "POST", params: ["path" => "1", "query" => "1"], post: ["post" => 1]); + $processParams->invoke($presenter, $request, $action); + + // the presenter should automatically create a valid format object + /** @var PresenterTestFormat */ + $format = $presenter->getFormatInstance(); + Assert::notNull($format); + $format->validate(); + + // check if the values match + Assert::equal($format->path, 1); + Assert::equal($format->query, 1); + Assert::equal($format->post, 1); + } + + public function testFormatInvalidField() + { + self::injectFormat(PresenterTestFormat::class); + $presenter = new TestPresenter(); + $action = self::getMethod($presenter, "actionTestFormat"); + $processParams = self::getMethod($presenter, "processParams"); + + // create a request object with invalid fields + $request = new Request( + "name", + method: "POST", + params: ["path" => "string", "query" => "1"], + post: ["post" => 1] + ); + Assert::throws( + function () use ($processParams, $presenter, $request, $action) { + $processParams->invoke($presenter, $request, $action); + }, + InvalidApiArgumentException::class + ); + } + + public function testFormatInvalidStructure() + { + self::injectFormat(PresenterTestFormat::class); + $presenter = new TestPresenter(); + $action = self::getMethod($presenter, "actionTestFormat"); + $processParams = self::getMethod($presenter, "processParams"); + + // create a request object with invalid structure + $request = new Request("name", method: "POST", params: ["path" => "1", "query" => "0"], post: ["post" => 1]); + Assert::throws( + function () use ($processParams, $presenter, $request, $action) { + $processParams->invoke($presenter, $request, $action); + }, + BadRequestException::class + ); + } + + public function testCombinedValid() + { + self::injectFormat(PresenterTestFormat::class); + $presenter = new TestPresenter(); + $action = self::getMethod($presenter, "actionTestCombined"); + $processParams = self::getMethod($presenter, "processParams"); + + // create a valid request object + $request = new Request( + "name", + method: "POST", + params: ["path" => "1", "query" => "1"], + post: ["post" => 1, "loose" => 1] + ); + $processParams->invoke($presenter, $request, $action); + + // the presenter should automatically create a valid format object + /** @var PresenterTestFormat */ + $format = $presenter->getFormatInstance(); + Assert::notNull($format); + $format->validate(); + + // check if the values match + Assert::equal($format->path, 1); + Assert::equal($format->query, 1); + Assert::equal($format->post, 1); + } + + public function testCombinedInvalidFormatFields() + { + self::injectFormat(PresenterTestFormat::class); + $presenter = new TestPresenter(); + $action = self::getMethod($presenter, "actionTestCombined"); + $processParams = self::getMethod($presenter, "processParams"); + + // create a request object with invalid fields + $request = new Request( + "name", + method: "POST", + params: ["path" => "string", "query" => "1"], + post: ["post" => 1, "loose" => 1] + ); + Assert::throws( + function () use ($processParams, $presenter, $request, $action) { + $processParams->invoke($presenter, $request, $action); + }, + InvalidApiArgumentException::class + ); + } + + public function testCombinedInvalidStructure() + { + self::injectFormat(PresenterTestFormat::class); + $presenter = new TestPresenter(); + $action = self::getMethod($presenter, "actionTestCombined"); + $processParams = self::getMethod($presenter, "processParams"); + + // create a request object with invalid structure + $request = new Request( + "name", + method: "POST", + params: ["path" => "1", "query" => "0"], + post: ["post" => 1, "loose" => 1] + ); + Assert::throws( + function () use ($processParams, $presenter, $request, $action) { + $processParams->invoke($presenter, $request, $action); + }, + BadRequestException::class + ); + } + + public function testCombinedInvalidLooseParam() + { + self::injectFormat(PresenterTestFormat::class); + $presenter = new TestPresenter(); + $action = self::getMethod($presenter, "actionTestCombined"); + $processParams = self::getMethod($presenter, "processParams"); + + // create a request object with an invalid loose parameter + $request = new Request( + "name", + method: "POST", + params: ["path" => "1", "query" => "1"], + post: ["post" => 1, "loose" => "string"] + ); + Assert::throws( + function () use ($processParams, $presenter, $request, $action) { + $processParams->invoke($presenter, $request, $action); + }, + InvalidApiArgumentException::class + ); + } +} + +(new TestBasePresenter())->run(); From b639c9e8e2f50bf8d10f466a35933454e3e41423 Mon Sep 17 00:00:00 2001 From: Vojtech Kloda Date: Fri, 16 May 2025 19:49:00 +0200 Subject: [PATCH 07/20] improved format tests --- tests/Validation/Formats.phpt | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/tests/Validation/Formats.phpt b/tests/Validation/Formats.phpt index 79aedae90..b9e947943 100644 --- a/tests/Validation/Formats.phpt +++ b/tests/Validation/Formats.phpt @@ -186,7 +186,7 @@ class TestFormats extends Tester\TestCase } } - public function testIndividualParamValidation() + public function testIndividualParamValidationPermissive() { self::injectFormat(ValidationTestFormat::class); $format = new ValidationTestFormat(); @@ -197,6 +197,15 @@ class TestFormats extends Tester\TestCase $format->checkedAssign("path", "1"); $format->checkedAssign("path", 1); + // make sure that the above assignments did not throw + Assert::true(true); + } + + public function testIndividualParamValidationStrict() + { + self::injectFormat(ValidationTestFormat::class); + $format = new ValidationTestFormat(); + // post parameters have strict validation, assigning a string will throw $format->checkedAssign("post", 1); Assert::throws( @@ -210,6 +219,12 @@ class TestFormats extends Tester\TestCase }, InvalidApiArgumentException::class ); + } + + public function testIndividualParamValidationNullable() + { + self::injectFormat(ValidationTestFormat::class); + $format = new ValidationTestFormat(); // null cannot be assigned unless the parameter is nullable or not required $format->checkedAssign("queryOptional", null); @@ -231,6 +246,7 @@ class TestFormats extends Tester\TestCase self::injectFormat(ValidationTestFormat::class); $format = new ValidationTestFormat(); + // assign valid values and validate $format->checkedAssign("query", 1); $format->checkedAssign("path", 1); $format->checkedAssign("post", 1); @@ -285,6 +301,7 @@ class TestFormats extends Tester\TestCase Assert::false($nested->validateStructure()); Assert::false($parent->validateStructure()); + // invalid structure should throw during validation Assert::throws( function () use ($nested) { $nested->validate(); From 88c953f0655b0e1c7989513aa1eed8446e70532a Mon Sep 17 00:00:00 2001 From: Vojtech Kloda Date: Thu, 5 Jun 2025 16:08:16 +0200 Subject: [PATCH 08/20] WIP adding mocks --- .../presenters/base/BasePresenter.php | 54 +++++++++---------- app/helpers/Mocks/MockHelper.php | 44 +++++++++++++++ app/helpers/Mocks/MockTemplate.php | 25 +++++++++ app/helpers/Mocks/MockTemplateFactory.php | 17 ++++++ app/helpers/Mocks/MockUserStorage.php | 26 +++++++++ tests/Validation/BasePresenter.phpt | 38 +++++++------ 6 files changed, 154 insertions(+), 50 deletions(-) create mode 100644 app/helpers/Mocks/MockHelper.php create mode 100644 app/helpers/Mocks/MockTemplate.php create mode 100644 app/helpers/Mocks/MockTemplateFactory.php create mode 100644 app/helpers/Mocks/MockUserStorage.php diff --git a/app/V1Module/presenters/base/BasePresenter.php b/app/V1Module/presenters/base/BasePresenter.php index daf7ad933..59242990f 100644 --- a/app/V1Module/presenters/base/BasePresenter.php +++ b/app/V1Module/presenters/base/BasePresenter.php @@ -12,7 +12,6 @@ use App\Exceptions\WrongHttpMethodException; use App\Exceptions\NotImplementedException; use App\Exceptions\InternalServerException; -use App\Exceptions\InvalidApiArgumentException; use App\Exceptions\FrontendErrorMappings; use App\Security\AccessManager; use App\Security\Authorizator; @@ -27,7 +26,6 @@ use App\Responses\StorageFileResponse; use App\Responses\ZipFilesResponse; use Nette\Application\Application; -use Nette\Application\Request; use Nette\Http\IResponse; use Tracy\ILogger; use ReflectionClass; @@ -130,7 +128,7 @@ public function startup() $this->tryCall($this->formatPermissionCheckMethod($this->getAction()), $this->params); Validators::init(); - $this->processParams($this->getRequest(), $actionReflection); + $this->processParams($actionReflection); } protected function isRequestJson(): bool @@ -206,30 +204,28 @@ public function getFormatInstance(): MetaFormat return $this->requestFormatInstance; } - private function processParams(Request $request, ReflectionMethod $reflection) + private function processParams(ReflectionMethod $reflection) { // use a method specialized for formats if there is a format available $format = MetaFormatHelper::extractFormatFromAttribute($reflection); if ($format !== null) { - $this->requestFormatInstance = $this->processParamsFormat($request, $format, null); + $this->requestFormatInstance = $this->processParamsFormat($format, null); } // handle loose parameters $paramData = MetaFormatHelper::extractRequestParamData($reflection); - $this->processParamsLoose($request, $paramData); + $this->processParamsLoose($paramData); } /** * Processes loose parameters. Request parameters are validated, no new data is created. - * @throws InvalidApiArgumentException Thrown when the request parameter values do not conform to the definition. - * @param Request $request Request object holding the request data. * @param array $paramData Parameter data to be validated. */ - private function processParamsLoose(Request $request, array $paramData) + private function processParamsLoose(array $paramData) { // validate each param foreach ($paramData as $param) { - $paramValue = $this->getValueFromParamData($request, $param); + $paramValue = $this->getValueFromParamData($param); // this throws when it does not conform $param->conformsToDefinition($paramValue); @@ -239,17 +235,15 @@ private function processParamsLoose(Request $request, array $paramData) /** * Processes parameters defined by a format. Request parameters are validated and a format instance with * parameter values created. - * @param Request $request Request object holding the request data. * @param string $format The format defining the parameters. * @param ?array $valueDictionary If not null, a nested format instance will be created. The values will be taken * from here instead of the request object. Format validation ignores parameter type (path, query or post). * A top-level format will be created if null. * @throws InternalServerException Thrown when the format definition is corrupted/absent. - * @throws BadRequestException Thrown when the request parameter values do not meet the structural constraints. - * @throws InvalidApiArgumentException Thrown when the request parameter values do not conform to the definition. + * @throws BadRequestException Thrown when the request parameter values do not conform to the definition. * @return MetaFormat Returns a format instance with values filled from the request object. */ - private function processParamsFormat(Request $request, string $format, ?array $valueDictionary): MetaFormat + private function processParamsFormat(string $format, ?array $valueDictionary): MetaFormat { // get the parsed attribute data from the format fields $formatToFieldDefinitionsMap = FormatCache::getFormatToFieldDefinitionsMap(); @@ -265,7 +259,7 @@ private function processParamsFormat(Request $request, string $format, ?array $v $value = null; // top-level format if ($valueDictionary === null) { - $value = $this->getValueFromParamData($request, $requestParamData); + $value = $this->getValueFromParamData($requestParamData); // nested format } else { // Instead of retrieving the values with the getRequest call, use the provided $valueDictionary. @@ -281,7 +275,7 @@ private function processParamsFormat(Request $request, string $format, ?array $v // replace the value dictionary stored in $value with a format instance $nestedFormatName = $requestParamData->getFormatName(); if ($nestedFormatName !== null) { - $value = $this->processParamsFormat($request, $nestedFormatName, $value); + $value = $this->processParamsFormat($nestedFormatName, $value); } // this throws if the value is invalid @@ -298,37 +292,37 @@ private function processParamsFormat(Request $request, string $format, ?array $v /** * Calls either getPostField, getQueryField or getPathField based on the provided metadata. - * @param Request $request Request object holding the request data. * @param \App\Helpers\MetaFormats\RequestParamData $paramData Metadata of the request parameter. * @throws \App\Exceptions\InternalServerException Thrown when an unexpected parameter location was set. * @return mixed Returns the value from the request. */ - private function getValueFromParamData(Request $request, RequestParamData $paramData): mixed + private function getValueFromParamData(RequestParamData $paramData): mixed { switch ($paramData->type) { case Type::Post: - return $this->getPostField($request, $paramData->name, required: $paramData->required); + return $this->getPostField($paramData->name, required: $paramData->required); case Type::Query: - return $this->getQueryField($request, $paramData->name, required: $paramData->required); + return $this->getQueryField($paramData->name, required: $paramData->required); case Type::Path: - return $this->getPathField($request, $paramData->name); + return $this->getPathField($paramData->name); default: throw new InternalServerException("Unknown parameter type: {$paramData->type->name}"); } } - private function getPostField(Request $request, $param, $required = true) + private function getPostField($param, $required = true) { - $post = $request->getPost(); + $req = $this->getRequest(); + $post = $req->getPost(); - if ($request->isMethod("POST")) { + if ($req->isMethod("POST")) { // nothing to see here... } else { - if ($request->isMethod("PUT") || $request->isMethod("DELETE")) { + if ($req->isMethod("PUT") || $req->isMethod("DELETE")) { parse_str(file_get_contents('php://input'), $post); } else { throw new WrongHttpMethodException( - "Cannot get the post parameters in method '" . $request->getMethod() . "'." + "Cannot get the post parameters in method '" . $req->getMethod() . "'." ); } } @@ -344,18 +338,18 @@ private function getPostField(Request $request, $param, $required = true) } } - private function getQueryField(Request $request, $param, $required = true) + private function getQueryField($param, $required = true) { - $value = $request->getParameter($param); + $value = $this->getRequest()->getParameter($param); if ($value === null && $required) { throw new BadRequestException("Missing required query field $param"); } return $value; } - private function getPathField(Request $request, $param) + private function getPathField($param) { - $value = $request->getParameter($param); + $value = $this->getParameter($param); if ($value === null) { throw new BadRequestException("Missing required path field $param"); } diff --git a/app/helpers/Mocks/MockHelper.php b/app/helpers/Mocks/MockHelper.php new file mode 100644 index 000000000..89c9f46a1 --- /dev/null +++ b/app/helpers/Mocks/MockHelper.php @@ -0,0 +1,44 @@ +application = $application; + + $factory = new MockTemplateFactory(); + + $presenter->injectPrimary($httpRequest, $httpResponse, user: $user, templateFactory: $factory); + } +} diff --git a/app/helpers/Mocks/MockTemplate.php b/app/helpers/Mocks/MockTemplate.php new file mode 100644 index 000000000..ed5ca7c01 --- /dev/null +++ b/app/helpers/Mocks/MockTemplate.php @@ -0,0 +1,25 @@ +sendSuccessResponse("OK"); } #[Format(PresenterTestFormat::class)] public function actionTestFormat() { + $this->sendSuccessResponse("OK"); } #[Format(PresenterTestFormat::class)] #[Post("loose", new VInt())] public function actionTestCombined() { + $this->sendSuccessResponse("OK"); } } @@ -106,44 +110,38 @@ class TestBasePresenter extends Tester\TestCase Assert::notNull(FormatCache::getFieldDefinitions($format), "Tests whether a format was injected successfully."); } - private static function getMethod(BasePresenter $presenter, string $methodName): ReflectionMethod - { - $presenterReflection = new ReflectionObject($presenter); - $methodReflection = $presenterReflection->getMethod($methodName); - $methodReflection->setAccessible(true); - return $methodReflection; - } - public function testLooseValid() { $presenter = new TestPresenter(); - $action = self::getMethod($presenter, "actionTestLoose"); - $processParams = self::getMethod($presenter, "processParams"); + MockHelper::initPresenter($presenter); - // create a request object and invoke the actionTestLoose method - $request = new Request("name", method: "POST", params: ["path" => "1", "query" => "1"], post: ["post" => 1]); - $processParams->invoke($presenter, $request, $action); + // create a request object + $request = new Request( + "name", + method: "POST", + params: ["action" => "testLoose", "path" => "1", "query" => "1"], + post: ["post" => 1] + ); - // check that the previous row did not throw - Assert::true(true); + $response = $presenter->run($request); + Assert::equal("OK", $response->getPayload()["payload"]); } public function testLooseInvalid() { $presenter = new TestPresenter(); - $action = self::getMethod($presenter, "actionTestLoose"); - $processParams = self::getMethod($presenter, "processParams"); + MockHelper::initPresenter($presenter); // set an invalid parameter value and assert that the validation fails $request = new Request( "name", method: "POST", - params: ["path" => "string", "query" => "1"], + params: ["action" => "testLoose", "path" => "string", "query" => "1"], post: ["post" => 1] ); Assert::throws( - function () use ($processParams, $presenter, $request, $action) { - $processParams->invoke($presenter, $request, $action); + function () use ($presenter, $request) { + $presenter->run($request); }, InvalidApiArgumentException::class ); From 685be463ea883676a11964b6c4ba1f3afba13174 Mon Sep 17 00:00:00 2001 From: Vojtech Kloda Date: Thu, 5 Jun 2025 17:15:51 +0200 Subject: [PATCH 09/20] fixed tests with mocks --- tests/Validation/BasePresenter.phpt | 83 ++++++++++++++--------------- 1 file changed, 39 insertions(+), 44 deletions(-) diff --git a/tests/Validation/BasePresenter.phpt b/tests/Validation/BasePresenter.phpt index 0dd1c09fc..a7162cfa1 100644 --- a/tests/Validation/BasePresenter.phpt +++ b/tests/Validation/BasePresenter.phpt @@ -1,7 +1,6 @@ "1", "query" => "1"], post: ["post" => 1]); - $processParams->invoke($presenter, $request, $action); + $request = new Request( + "name", + method: "POST", + params: ["action" => "testFormat", "path" => "1", "query" => "1"], + post: ["post" => 1] + ); + + $response = $presenter->run($request); + Assert::equal("OK", $response->getPayload()["payload"]); // the presenter should automatically create a valid format object /** @var PresenterTestFormat */ @@ -174,19 +169,18 @@ class TestBasePresenter extends Tester\TestCase { self::injectFormat(PresenterTestFormat::class); $presenter = new TestPresenter(); - $action = self::getMethod($presenter, "actionTestFormat"); - $processParams = self::getMethod($presenter, "processParams"); + MockHelper::initPresenter($presenter); // create a request object with invalid fields $request = new Request( "name", method: "POST", - params: ["path" => "string", "query" => "1"], + params: ["action" => "testFormat", "path" => "string", "query" => "1"], post: ["post" => 1] ); Assert::throws( - function () use ($processParams, $presenter, $request, $action) { - $processParams->invoke($presenter, $request, $action); + function () use ($presenter, $request) { + $presenter->run($request); }, InvalidApiArgumentException::class ); @@ -196,14 +190,18 @@ class TestBasePresenter extends Tester\TestCase { self::injectFormat(PresenterTestFormat::class); $presenter = new TestPresenter(); - $action = self::getMethod($presenter, "actionTestFormat"); - $processParams = self::getMethod($presenter, "processParams"); + MockHelper::initPresenter($presenter); // create a request object with invalid structure - $request = new Request("name", method: "POST", params: ["path" => "1", "query" => "0"], post: ["post" => 1]); + $request = new Request( + "name", + method: "POST", + params: ["action" => "testFormat", "path" => "1", "query" => "0"], + post: ["post" => 1] + ); Assert::throws( - function () use ($processParams, $presenter, $request, $action) { - $processParams->invoke($presenter, $request, $action); + function () use ($presenter, $request) { + $presenter->run($request); }, BadRequestException::class ); @@ -213,17 +211,17 @@ class TestBasePresenter extends Tester\TestCase { self::injectFormat(PresenterTestFormat::class); $presenter = new TestPresenter(); - $action = self::getMethod($presenter, "actionTestCombined"); - $processParams = self::getMethod($presenter, "processParams"); + MockHelper::initPresenter($presenter); // create a valid request object $request = new Request( "name", method: "POST", - params: ["path" => "1", "query" => "1"], + params: ["action" => "testCombined", "path" => "1", "query" => "1"], post: ["post" => 1, "loose" => 1] ); - $processParams->invoke($presenter, $request, $action); + $response = $presenter->run($request); + Assert::equal("OK", $response->getPayload()["payload"]); // the presenter should automatically create a valid format object /** @var PresenterTestFormat */ @@ -241,19 +239,18 @@ class TestBasePresenter extends Tester\TestCase { self::injectFormat(PresenterTestFormat::class); $presenter = new TestPresenter(); - $action = self::getMethod($presenter, "actionTestCombined"); - $processParams = self::getMethod($presenter, "processParams"); + MockHelper::initPresenter($presenter); // create a request object with invalid fields $request = new Request( "name", method: "POST", - params: ["path" => "string", "query" => "1"], + params: ["action" => "testCombined", "path" => "string", "query" => "1"], post: ["post" => 1, "loose" => 1] ); Assert::throws( - function () use ($processParams, $presenter, $request, $action) { - $processParams->invoke($presenter, $request, $action); + function () use ($presenter, $request) { + $presenter->run($request); }, InvalidApiArgumentException::class ); @@ -263,19 +260,18 @@ class TestBasePresenter extends Tester\TestCase { self::injectFormat(PresenterTestFormat::class); $presenter = new TestPresenter(); - $action = self::getMethod($presenter, "actionTestCombined"); - $processParams = self::getMethod($presenter, "processParams"); + MockHelper::initPresenter($presenter); // create a request object with invalid structure $request = new Request( "name", method: "POST", - params: ["path" => "1", "query" => "0"], + params: ["action" => "testCombined", "path" => "1", "query" => "0"], post: ["post" => 1, "loose" => 1] ); Assert::throws( - function () use ($processParams, $presenter, $request, $action) { - $processParams->invoke($presenter, $request, $action); + function () use ($presenter, $request) { + $presenter->run($request); }, BadRequestException::class ); @@ -285,19 +281,18 @@ class TestBasePresenter extends Tester\TestCase { self::injectFormat(PresenterTestFormat::class); $presenter = new TestPresenter(); - $action = self::getMethod($presenter, "actionTestCombined"); - $processParams = self::getMethod($presenter, "processParams"); + MockHelper::initPresenter($presenter); // create a request object with an invalid loose parameter $request = new Request( "name", method: "POST", - params: ["path" => "1", "query" => "1"], + params: ["action" => "testCombined", "path" => "1", "query" => "1"], post: ["post" => 1, "loose" => "string"] ); Assert::throws( - function () use ($processParams, $presenter, $request, $action) { - $processParams->invoke($presenter, $request, $action); + function () use ($presenter, $request) { + $presenter->run($request); }, InvalidApiArgumentException::class ); From 2328fe9d24637bf6f21d231ebd366afb36a3e76e Mon Sep 17 00:00:00 2001 From: Vojtech Kloda Date: Mon, 9 Jun 2025 15:14:24 +0200 Subject: [PATCH 10/20] fixed missing constraints in parameters --- .../Swagger/AnnotationParameterData.php | 5 + docs/swagger.yaml | 170 ++++++++++++++++++ 2 files changed, 175 insertions(+) diff --git a/app/helpers/Swagger/AnnotationParameterData.php b/app/helpers/Swagger/AnnotationParameterData.php index 6ee06bd27..1d4599a3b 100644 --- a/app/helpers/Swagger/AnnotationParameterData.php +++ b/app/helpers/Swagger/AnnotationParameterData.php @@ -116,6 +116,11 @@ private function generateSchemaAnnotation(): string $body->addKeyValue("type", $this->swaggerType); $body->addKeyValue("nullable", $this->nullable); + + if ($this->swaggerType !== "array") { + $this->constraints?->addConstraints($body); + } + $this->addArrayItemsIfArray($body); return $head . $body->toString(); diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 226e9fd7e..b821b6812 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -232,6 +232,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false responses: '200': @@ -248,6 +249,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false requestBody: content: @@ -512,6 +514,8 @@ paths: required: false schema: type: string + maxLength: 32 + minLength: 1 nullable: false - name: force @@ -537,6 +541,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false responses: '200': @@ -553,6 +558,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false requestBody: content: @@ -626,6 +632,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false responses: '200': @@ -643,6 +650,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false requestBody: content: @@ -673,6 +681,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false requestBody: content: @@ -702,6 +711,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false - name: archived @@ -727,6 +737,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false requestBody: content: @@ -757,6 +768,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false - name: groupId @@ -781,6 +793,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false - name: groupId @@ -806,6 +819,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false - name: name @@ -814,6 +828,8 @@ paths: required: true schema: type: string + maxLength: 32 + minLength: 1 nullable: false responses: '200': @@ -830,6 +846,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false - name: name @@ -855,6 +872,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false requestBody: content: @@ -885,6 +903,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false requestBody: content: @@ -916,6 +935,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false requestBody: content: @@ -946,6 +966,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false requestBody: content: @@ -976,6 +997,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false responses: '200': @@ -992,6 +1014,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false requestBody: content: @@ -1021,6 +1044,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false - name: fileId @@ -1046,6 +1070,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false responses: '200': @@ -1063,6 +1088,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false responses: '200': @@ -1079,6 +1105,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false requestBody: content: @@ -1108,6 +1135,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false - name: fileId @@ -1133,6 +1161,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false responses: '200': @@ -1150,6 +1179,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false responses: '200': @@ -1166,6 +1196,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false requestBody: content: @@ -1196,6 +1227,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false responses: '200': @@ -1212,6 +1244,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false requestBody: content: @@ -1242,6 +1275,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false responses: '200': @@ -1258,6 +1292,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false requestBody: content: @@ -1288,6 +1323,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false requestBody: content: @@ -1324,6 +1360,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false - name: runtimeEnvironmentId @@ -1356,6 +1393,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false - name: runtimeEnvironmentId @@ -1401,6 +1439,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false - name: runtimeEnvironmentId @@ -1434,6 +1473,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false responses: '200': @@ -1450,6 +1490,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false requestBody: content: @@ -1480,6 +1521,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false responses: '200': @@ -1496,6 +1538,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false requestBody: content: @@ -1556,6 +1599,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false responses: '200': @@ -1572,6 +1616,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false requestBody: content: @@ -1720,6 +1765,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false responses: '200': @@ -1737,6 +1783,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false responses: '200': @@ -1754,6 +1801,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false responses: '200': @@ -1771,6 +1819,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false responses: '200': @@ -1788,6 +1837,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false - name: userId @@ -1813,6 +1863,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false - name: userId @@ -1838,6 +1889,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false requestBody: content: @@ -1868,6 +1920,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false responses: '200': @@ -1885,6 +1938,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false - name: userId @@ -1910,6 +1964,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false requestBody: content: @@ -1959,6 +2014,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false responses: '200': @@ -1975,6 +2031,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false responses: '200': @@ -1992,6 +2049,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false - name: userId @@ -2030,6 +2088,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false responses: '200': @@ -2206,6 +2265,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false responses: '200': @@ -2222,6 +2282,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false requestBody: content: @@ -2282,6 +2343,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false responses: '200': @@ -2299,6 +2361,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false responses: '200': @@ -2316,6 +2379,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false requestBody: content: @@ -2346,6 +2410,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false requestBody: content: @@ -2376,6 +2441,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false requestBody: content: @@ -2415,6 +2481,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false responses: '200': @@ -2432,6 +2499,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false - name: examId @@ -2457,6 +2525,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false - name: newParentId @@ -2482,6 +2551,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false responses: '200': @@ -2499,6 +2569,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false - name: userId @@ -2523,6 +2594,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false - name: userId @@ -2547,6 +2619,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false - name: userId @@ -2572,6 +2645,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false - name: userId @@ -2597,6 +2671,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false - name: userId @@ -2621,6 +2696,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false - name: userId @@ -2646,6 +2722,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false responses: '200': @@ -2663,6 +2740,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false - name: userId @@ -2701,6 +2779,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false - name: userId @@ -2726,6 +2805,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false responses: '200': @@ -2743,6 +2823,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false responses: '200': @@ -2811,6 +2892,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false responses: '200': @@ -2827,6 +2909,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false requestBody: content: @@ -2859,6 +2942,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false responses: '200': @@ -2876,6 +2960,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false responses: '200': @@ -2957,6 +3042,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false responses: '200': @@ -3013,6 +3099,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false responses: '200': @@ -3029,6 +3116,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false requestBody: content: @@ -3056,6 +3144,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false responses: '200': @@ -3073,6 +3162,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false responses: '200': @@ -3089,6 +3179,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false requestBody: content: @@ -3364,6 +3455,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false requestBody: content: @@ -3409,6 +3501,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false responses: '200': @@ -3540,6 +3633,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false responses: '200': @@ -3556,6 +3650,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false requestBody: content: @@ -3586,6 +3681,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false responses: '200': @@ -3603,6 +3699,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false requestBody: content: @@ -3637,6 +3734,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false responses: '200': @@ -3654,6 +3752,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false - name: flag @@ -3692,6 +3791,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false requestBody: content: @@ -3720,6 +3820,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false responses: '200': @@ -3737,6 +3838,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false responses: '200': @@ -3821,6 +3923,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false responses: '200': @@ -3837,6 +3940,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false requestBody: content: @@ -3866,6 +3970,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false responses: '200': @@ -3883,6 +3988,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false requestBody: content: @@ -3938,6 +4044,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false - name: commentId @@ -3987,6 +4094,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false - name: commentId @@ -4012,6 +4120,7 @@ paths: required: false schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false - name: groupId @@ -4020,6 +4129,7 @@ paths: required: false schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false - name: userId @@ -4028,6 +4138,7 @@ paths: required: false schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false responses: '200': @@ -4061,6 +4172,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false responses: '200': @@ -4078,6 +4190,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false requestBody: content: @@ -4143,6 +4256,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false - name: offset @@ -4167,6 +4281,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false responses: '200': @@ -4183,6 +4298,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false responses: '200': @@ -4208,6 +4324,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false responses: '200': @@ -4225,6 +4342,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false responses: '200': @@ -4242,6 +4360,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false - name: entry @@ -4250,6 +4369,7 @@ paths: required: false schema: type: string + minLength: 1 nullable: false - name: similarSolutionId @@ -4258,6 +4378,7 @@ paths: required: false schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false responses: '200': @@ -4275,6 +4396,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false - name: entry @@ -4283,6 +4405,7 @@ paths: required: false schema: type: string + minLength: 1 nullable: false - name: similarSolutionId @@ -4291,6 +4414,7 @@ paths: required: false schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false responses: '200': @@ -4308,6 +4432,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false responses: '200': @@ -4634,6 +4759,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false responses: '200': @@ -4650,6 +4776,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false requestBody: content: @@ -4721,6 +4848,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false responses: '200': @@ -4738,6 +4866,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false responses: '200': @@ -4755,6 +4884,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false responses: '200': @@ -4772,6 +4902,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false responses: '200': @@ -4789,6 +4920,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false responses: '200': @@ -4806,6 +4938,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false requestBody: content: @@ -4889,6 +5022,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false requestBody: content: @@ -4924,6 +5058,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false responses: '200': @@ -4941,6 +5076,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false requestBody: content: @@ -4972,6 +5108,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false - name: service @@ -5011,6 +5148,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false - name: service @@ -5036,6 +5174,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false responses: '200': @@ -5052,6 +5191,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false responses: '200': @@ -5069,6 +5209,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false responses: '200': @@ -5086,6 +5227,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false responses: '200': @@ -5275,6 +5417,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false requestBody: content: @@ -5303,6 +5446,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false responses: '200': @@ -5319,6 +5463,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false requestBody: content: @@ -5374,6 +5519,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false responses: '200': @@ -5391,6 +5537,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false responses: '200': @@ -5408,6 +5555,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false requestBody: content: @@ -5438,6 +5586,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false responses: '200': @@ -5454,6 +5603,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false requestBody: content: @@ -5483,6 +5633,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false - name: fileId @@ -5508,6 +5659,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false responses: '200': @@ -5933,6 +6085,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false responses: '200': @@ -5949,6 +6102,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false requestBody: content: @@ -6012,6 +6166,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false responses: '200': @@ -6049,6 +6204,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false requestBody: content: @@ -6079,6 +6235,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false requestBody: content: @@ -6261,6 +6418,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false requestBody: content: @@ -6321,6 +6479,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false responses: '200': @@ -6355,6 +6514,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false responses: '200': @@ -6397,6 +6557,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false responses: '200': @@ -6422,6 +6583,8 @@ paths: required: false schema: type: string + maxLength: 255 + minLength: 1 nullable: false - name: solutionId @@ -6430,6 +6593,7 @@ paths: required: false schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false responses: '200': @@ -6475,6 +6639,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false responses: '200': @@ -6491,6 +6656,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false requestBody: content: @@ -6524,6 +6690,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false - name: solutionId @@ -6548,6 +6715,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false - name: solutionId @@ -6628,6 +6796,8 @@ paths: required: false schema: type: string + maxLength: 2 + minLength: 2 nullable: false - name: return From 804d6c75b737f470d86c740e971505adbdae5f43 Mon Sep 17 00:00:00 2001 From: Vojtech Kloda Date: Thu, 12 Jun 2025 14:53:07 +0200 Subject: [PATCH 11/20] removed unused imports --- app/helpers/Mocks/MockHelper.php | 11 +---------- app/helpers/Mocks/MockTemplate.php | 3 --- app/helpers/Mocks/MockTemplateFactory.php | 2 -- tests/Validation/BasePresenter.phpt | 2 +- tests/Validation/Formats.phpt | 9 --------- 5 files changed, 2 insertions(+), 25 deletions(-) diff --git a/app/helpers/Mocks/MockHelper.php b/app/helpers/Mocks/MockHelper.php index 89c9f46a1..ce1b93ea4 100644 --- a/app/helpers/Mocks/MockHelper.php +++ b/app/helpers/Mocks/MockHelper.php @@ -3,29 +3,20 @@ namespace App\Helpers\Mocks; use App\Helpers\Mocks\MockUserStorage; -use App\Security\UserStorage; use App\V1Module\Presenters\BasePresenter; -use App\V1Module\Presenters\RegistrationPresenter; use Nette\Application\Application; use Nette\Application\PresenterFactory; -use Nette\Application\Request; -use Nette\Application\Responses\JsonResponse; use Nette\Application\Routers\RouteList; use Nette\Http\Response; use Nette\Http\UrlScript; use Nette\Security\User; -use Symfony\Component\Console\Command\Command; -use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Output\OutputInterface; -use App\Exceptions\InvalidAccessTokenException; -use App\Exceptions\InvalidArgumentException; use Nette; -use Nette\Security\IIdentity; class MockHelper { /** * Initializes a presenter object with empty http request, response, and user objects. + * This is intended to be called right after presenter instantiation and before calling the Presenter::run method. * @param BasePresenter $presenter The presenter to be initialized. */ public static function initPresenter(BasePresenter $presenter) diff --git a/app/helpers/Mocks/MockTemplate.php b/app/helpers/Mocks/MockTemplate.php index ed5ca7c01..1caf655fe 100644 --- a/app/helpers/Mocks/MockTemplate.php +++ b/app/helpers/Mocks/MockTemplate.php @@ -2,10 +2,7 @@ namespace App\Helpers\Mocks; -use Nette; use Nette\Application\UI\Template; -use Nette\Application\UI\TemplateFactory; -use Nette\Security\IIdentity; class MockTemplate implements Template { diff --git a/app/helpers/Mocks/MockTemplateFactory.php b/app/helpers/Mocks/MockTemplateFactory.php index 56f8af4b4..b5bc53243 100644 --- a/app/helpers/Mocks/MockTemplateFactory.php +++ b/app/helpers/Mocks/MockTemplateFactory.php @@ -2,11 +2,9 @@ namespace App\Helpers\Mocks; -use Nette; use Nette\Application\UI\Control; use Nette\Application\UI\Template; use Nette\Application\UI\TemplateFactory; -use Nette\Security\IIdentity; class MockTemplateFactory implements TemplateFactory { diff --git a/tests/Validation/BasePresenter.phpt b/tests/Validation/BasePresenter.phpt index a7162cfa1..8795d35f2 100644 --- a/tests/Validation/BasePresenter.phpt +++ b/tests/Validation/BasePresenter.phpt @@ -65,7 +65,7 @@ class TestPresenter extends BasePresenter /** * @testCase */ -class TestBasePresenter extends Tester\TestCase +class TestPresenter extends Tester\TestCase { /** @var Nette\DI\Container */ protected $container; diff --git a/tests/Validation/Formats.phpt b/tests/Validation/Formats.phpt index b9e947943..3f0ddfb1b 100644 --- a/tests/Validation/Formats.phpt +++ b/tests/Validation/Formats.phpt @@ -8,19 +8,10 @@ use App\Helpers\MetaFormats\Attributes\FPath; use App\Helpers\MetaFormats\Attributes\FPost; use App\Helpers\MetaFormats\Attributes\FQuery; use App\Helpers\MetaFormats\FormatCache; -use App\Helpers\MetaFormats\FormatDefinitions\UserFormat; use App\Helpers\MetaFormats\MetaFormat; use App\Helpers\MetaFormats\MetaFormatHelper; -use App\Helpers\MetaFormats\Validators\BaseValidator; -use App\Helpers\MetaFormats\Validators\VArray; -use App\Helpers\MetaFormats\Validators\VBool; -use App\Helpers\MetaFormats\Validators\VDouble; use App\Helpers\MetaFormats\Validators\VInt; -use App\Helpers\MetaFormats\Validators\VMixed; use App\Helpers\MetaFormats\Validators\VObject; -use App\Helpers\MetaFormats\Validators\VString; -use App\Helpers\MetaFormats\Validators\VTimestamp; -use App\Helpers\MetaFormats\Validators\VUuid; use Tester\Assert; $container = require_once __DIR__ . "/../bootstrap.php"; From ef0840eac735b1af56380f904610ab5bf1c61d95 Mon Sep 17 00:00:00 2001 From: Vojtech Kloda Date: Thu, 12 Jun 2025 17:20:25 +0200 Subject: [PATCH 12/20] added more tests and comments --- app/helpers/Mocks/MockHelper.php | 29 +++++ tests/Validation/BasePresenter.phpt | 193 +++++++++++++++++++++------- tests/Validation/Formats.phpt | 110 +++++++++++----- tests/Validation/Validators.phpt | 13 +- 4 files changed, 268 insertions(+), 77 deletions(-) diff --git a/app/helpers/Mocks/MockHelper.php b/app/helpers/Mocks/MockHelper.php index ce1b93ea4..a0fd66b37 100644 --- a/app/helpers/Mocks/MockHelper.php +++ b/app/helpers/Mocks/MockHelper.php @@ -2,6 +2,8 @@ namespace App\Helpers\Mocks; +use App\Helpers\MetaFormats\FormatCache; +use App\Helpers\MetaFormats\MetaFormatHelper; use App\Helpers\Mocks\MockUserStorage; use App\V1Module\Presenters\BasePresenter; use Nette\Application\Application; @@ -11,6 +13,7 @@ use Nette\Http\UrlScript; use Nette\Security\User; use Nette; +use ReflectionProperty; class MockHelper { @@ -32,4 +35,30 @@ public static function initPresenter(BasePresenter $presenter) $presenter->injectPrimary($httpRequest, $httpResponse, user: $user, templateFactory: $factory); } + + /** + * Injects a Format class to the FormatCache. + * This method must not be used outside of testing, normal Format classes are discovered automatically. + * @param string $format The Format class name. + */ + public static function injectFormat(string $format) + { + // make sure the cache is initialized (it uses lazy loading) + FormatCache::getFormatToFieldDefinitionsMap(); + FormatCache::getFormatNamesHashSet(); + + // inject the format name + $hashSetReflector = new ReflectionProperty(FormatCache::class, "formatNamesHashSet"); + $hashSetReflector->setAccessible(true); + $formatNamesHashSet = $hashSetReflector->getValue(); + $formatNamesHashSet[$format] = true; + $hashSetReflector->setValue(null, $formatNamesHashSet); + + // inject the format definitions + $formatMapReflector = new ReflectionProperty(FormatCache::class, "formatToFieldFormatsMap"); + $formatMapReflector->setAccessible(true); + $formatToFieldFormatsMap = $formatMapReflector->getValue(); + $formatToFieldFormatsMap[$format] = MetaFormatHelper::createNameToFieldDefinitionsMap($format); + $formatMapReflector->setValue(null, $formatToFieldFormatsMap); + } } diff --git a/tests/Validation/BasePresenter.phpt b/tests/Validation/BasePresenter.phpt index 8795d35f2..a4fad886c 100644 --- a/tests/Validation/BasePresenter.phpt +++ b/tests/Validation/BasePresenter.phpt @@ -20,6 +20,9 @@ use Tester\Assert; $container = require_once __DIR__ . "/../bootstrap.php"; +/** + * A Format class used to test FPost, FPath, FQuery and structure validation. + */ #[Format(ValidationTestFormat::class)] class PresenterTestFormat extends MetaFormat { @@ -32,17 +35,35 @@ class PresenterTestFormat extends MetaFormat #[FQuery(new VInt())] public ?int $query; + // The following properties will not be set in the tests, they are here to check that optional parameters + // can be omitted from the request. + #[FPost(new VInt(), required: false)] + public ?int $postOptional; + + #[FQuery(new VInt(), required: false)] + public ?int $queryOptional; + + /** + * This class requires the query property to be 1. + */ public function validateStructure() { return $this->query == 1; } } +/** + * A Presenter used to test loose attributes, Format attributes, and a combination of both. + */ class TestPresenter extends BasePresenter { #[Post("post", new VInt())] #[Query("query", new VInt())] #[Path("path", new VInt())] + // The following parameters will not be set in the tests, they are here to check that optional parameters + // can be omitted from the request. + #[Post("postOptional", new VInt(), required: false)] + #[Query("queryOptional", new VInt(), required: false)] public function actionTestLoose() { $this->sendSuccessResponse("OK"); @@ -63,9 +84,12 @@ class TestPresenter extends BasePresenter } /** + * This test suite simulates a BasePresenter receiving user requests. + * The tests start by creating a presenter object, defining request data, and running the request. + * The tests include scenarios with both valid and invalid request data. * @testCase */ -class TestPresenter extends Tester\TestCase +class TestBasePresenter extends Tester\TestCase { /** @var Nette\DI\Container */ protected $container; @@ -76,26 +100,13 @@ class TestPresenter extends Tester\TestCase $this->container = $container; } - private static function injectFormat(string $format) + /** + * Injects a Format class to the FormatCache and checks whether it was injected successfully. + * @param string $format The Format class name. + */ + private static function injectFormatChecked(string $format) { - // initialize the cache - FormatCache::getFormatToFieldDefinitionsMap(); - FormatCache::getFormatNamesHashSet(); - - // inject the format name - $hashSetReflector = new ReflectionProperty(FormatCache::class, "formatNamesHashSet"); - $hashSetReflector->setAccessible(true); - $formatNamesHashSet = $hashSetReflector->getValue(); - $formatNamesHashSet[$format] = true; - $hashSetReflector->setValue(null, $formatNamesHashSet); - - // inject the format definitions - $formatMapReflector = new ReflectionProperty(FormatCache::class, "formatToFieldFormatsMap"); - $formatMapReflector->setAccessible(true); - $formatToFieldFormatsMap = $formatMapReflector->getValue(); - $formatToFieldFormatsMap[$format] = MetaFormatHelper::createNameToFieldDefinitionsMap($format); - $formatMapReflector->setValue(null, $formatToFieldFormatsMap); - + MockHelper::injectFormat($format); Assert::notNull(FormatCache::getFieldDefinitions($format), "Tests whether a format was injected successfully."); } @@ -121,13 +132,14 @@ class TestPresenter extends Tester\TestCase $presenter = new TestPresenter(); MockHelper::initPresenter($presenter); - // set an invalid parameter value and assert that the validation fails + // set an invalid parameter value and assert that the validation fails ("path" should be an int) $request = new Request( "name", method: "POST", params: ["action" => "testLoose", "path" => "string", "query" => "1"], post: ["post" => 1] ); + Assert::throws( function () use ($presenter, $request) { $presenter->run($request); @@ -136,9 +148,30 @@ class TestPresenter extends Tester\TestCase ); } + public function testLooseMissing() + { + $presenter = new TestPresenter(); + MockHelper::initPresenter($presenter); + + // create a request object + $request = new Request( + "name", + method: "POST", + params: ["action" => "testLoose", "path" => "1", "query" => "1"], + post: [] // missing path parameter + ); + + Assert::throws( + function () use ($presenter, $request) { + $presenter->run($request); + }, + BadRequestException::class + ); + } + public function testFormatValid() { - self::injectFormat(PresenterTestFormat::class); + self::injectFormatChecked(PresenterTestFormat::class); $presenter = new TestPresenter(); MockHelper::initPresenter($presenter); @@ -146,38 +179,41 @@ class TestPresenter extends Tester\TestCase $request = new Request( "name", method: "POST", - params: ["action" => "testFormat", "path" => "1", "query" => "1"], - post: ["post" => 1] + params: ["action" => "testFormat", "path" => "2", "query" => "1"], + post: ["post" => 3] ); $response = $presenter->run($request); Assert::equal("OK", $response->getPayload()["payload"]); - + // the presenter should automatically create a valid format object /** @var PresenterTestFormat */ $format = $presenter->getFormatInstance(); Assert::notNull($format); + + // throws when invalid $format->validate(); // check if the values match - Assert::equal($format->path, 1); + Assert::equal($format->path, 2); Assert::equal($format->query, 1); - Assert::equal($format->post, 1); + Assert::equal($format->post, 3); } - public function testFormatInvalidField() + public function testFormatInvalidParameter() { - self::injectFormat(PresenterTestFormat::class); + self::injectFormatChecked(PresenterTestFormat::class); $presenter = new TestPresenter(); MockHelper::initPresenter($presenter); - // create a request object with invalid fields + // create a request object with invalid parameters ("path" should be an int) $request = new Request( "name", method: "POST", params: ["action" => "testFormat", "path" => "string", "query" => "1"], post: ["post" => 1] ); + Assert::throws( function () use ($presenter, $request) { $presenter->run($request); @@ -186,19 +222,41 @@ class TestPresenter extends Tester\TestCase ); } + public function testFormatMissingParameter() + { + self::injectFormatChecked(PresenterTestFormat::class); + $presenter = new TestPresenter(); + MockHelper::initPresenter($presenter); + + $request = new Request( + "name", + method: "POST", + params: ["action" => "testFormat", "query" => "1"], // missing path + post: ["post" => 3] + ); + + Assert::throws( + function () use ($presenter, $request) { + $presenter->run($request); + }, + BadRequestException::class + ); + } + public function testFormatInvalidStructure() { - self::injectFormat(PresenterTestFormat::class); + self::injectFormatChecked(PresenterTestFormat::class); $presenter = new TestPresenter(); MockHelper::initPresenter($presenter); - // create a request object with invalid structure + // create a request object with invalid structure ("query" has to be 1) $request = new Request( "name", method: "POST", params: ["action" => "testFormat", "path" => "1", "query" => "0"], post: ["post" => 1] ); + Assert::throws( function () use ($presenter, $request) { $presenter->run($request); @@ -209,7 +267,7 @@ class TestPresenter extends Tester\TestCase public function testCombinedValid() { - self::injectFormat(PresenterTestFormat::class); + self::injectFormatChecked(PresenterTestFormat::class); $presenter = new TestPresenter(); MockHelper::initPresenter($presenter); @@ -217,8 +275,8 @@ class TestPresenter extends Tester\TestCase $request = new Request( "name", method: "POST", - params: ["action" => "testCombined", "path" => "1", "query" => "1"], - post: ["post" => 1, "loose" => 1] + params: ["action" => "testCombined", "path" => "2", "query" => "1"], + post: ["post" => 3, "loose" => 4] ); $response = $presenter->run($request); Assert::equal("OK", $response->getPayload()["payload"]); @@ -227,27 +285,30 @@ class TestPresenter extends Tester\TestCase /** @var PresenterTestFormat */ $format = $presenter->getFormatInstance(); Assert::notNull($format); + + // throws when invalid $format->validate(); // check if the values match - Assert::equal($format->path, 1); + Assert::equal($format->path, 2); Assert::equal($format->query, 1); - Assert::equal($format->post, 1); + Assert::equal($format->post, 3); } - public function testCombinedInvalidFormatFields() + public function testCombinedInvalidFormatParameters() { - self::injectFormat(PresenterTestFormat::class); + self::injectFormatChecked(PresenterTestFormat::class); $presenter = new TestPresenter(); MockHelper::initPresenter($presenter); - // create a request object with invalid fields + // create a request object with invalid parameters ("path" should be an int) $request = new Request( "name", method: "POST", params: ["action" => "testCombined", "path" => "string", "query" => "1"], post: ["post" => 1, "loose" => 1] ); + Assert::throws( function () use ($presenter, $request) { $presenter->run($request); @@ -258,17 +319,18 @@ class TestPresenter extends Tester\TestCase public function testCombinedInvalidStructure() { - self::injectFormat(PresenterTestFormat::class); + self::injectFormatChecked(PresenterTestFormat::class); $presenter = new TestPresenter(); MockHelper::initPresenter($presenter); - // create a request object with invalid structure + // create a request object with invalid structure ("query" has to be 1) $request = new Request( "name", method: "POST", params: ["action" => "testCombined", "path" => "1", "query" => "0"], post: ["post" => 1, "loose" => 1] ); + Assert::throws( function () use ($presenter, $request) { $presenter->run($request); @@ -279,17 +341,18 @@ class TestPresenter extends Tester\TestCase public function testCombinedInvalidLooseParam() { - self::injectFormat(PresenterTestFormat::class); + self::injectFormatChecked(PresenterTestFormat::class); $presenter = new TestPresenter(); MockHelper::initPresenter($presenter); - // create a request object with an invalid loose parameter + // create a request object with an invalid loose parameter (it should be an int) $request = new Request( "name", method: "POST", params: ["action" => "testCombined", "path" => "1", "query" => "1"], post: ["post" => 1, "loose" => "string"] ); + Assert::throws( function () use ($presenter, $request) { $presenter->run($request); @@ -297,6 +360,48 @@ class TestPresenter extends Tester\TestCase InvalidApiArgumentException::class ); } + + public function testCombinedMissingLooseParam() + { + self::injectFormatChecked(PresenterTestFormat::class); + $presenter = new TestPresenter(); + MockHelper::initPresenter($presenter); + + $request = new Request( + "name", + method: "POST", + params: ["action" => "testCombined", "path" => "1", "query" => "1"], + post: ["post" => 1] // missing loose parameter + ); + + Assert::throws( + function () use ($presenter, $request) { + $presenter->run($request); + }, + BadRequestException::class + ); + } + + public function testCombinedMissingFormatParam() + { + self::injectFormatChecked(PresenterTestFormat::class); + $presenter = new TestPresenter(); + MockHelper::initPresenter($presenter); + + $request = new Request( + "name", + method: "POST", + params: ["action" => "testCombined", "path" => "1", "query" => "1"], + post: ["loose" => 1] // missing post parameter + ); + + Assert::throws( + function () use ($presenter, $request) { + $presenter->run($request); + }, + BadRequestException::class + ); + } } (new TestBasePresenter())->run(); diff --git a/tests/Validation/Formats.phpt b/tests/Validation/Formats.phpt index 3f0ddfb1b..8d13a433f 100644 --- a/tests/Validation/Formats.phpt +++ b/tests/Validation/Formats.phpt @@ -12,10 +12,14 @@ use App\Helpers\MetaFormats\MetaFormat; use App\Helpers\MetaFormats\MetaFormatHelper; use App\Helpers\MetaFormats\Validators\VInt; use App\Helpers\MetaFormats\Validators\VObject; +use App\Helpers\Mocks\MockHelper; use Tester\Assert; $container = require_once __DIR__ . "/../bootstrap.php"; +/** + * Format used to test nullability and required flags. + */ #[Format(RequiredNullabilityTestFormat::class)] class RequiredNullabilityTestFormat extends MetaFormat { @@ -32,6 +36,9 @@ class RequiredNullabilityTestFormat extends MetaFormat public ?int $notRequiredNullable; } +/** + * Format used to test the Param attributes and structural validation. + */ #[Format(ValidationTestFormat::class)] class ValidationTestFormat extends MetaFormat { @@ -53,6 +60,9 @@ class ValidationTestFormat extends MetaFormat } } +/** + * Format used to test nested Formats. + */ #[Format(ParentFormat::class)] class ParentFormat extends MetaFormat { @@ -68,6 +78,9 @@ class ParentFormat extends MetaFormat } } +/** + * Format used to test nested Formats. + */ #[Format(NestedFormat::class)] class NestedFormat extends MetaFormat { @@ -94,32 +107,23 @@ class TestFormats extends Tester\TestCase $this->container = $container; } - private static function injectFormat(string $format) + /** + * Injects a Format class to the FormatCache and checks whether it was injected successfully. + * @param string $format The Format class name. + */ + private static function injectFormatChecked(string $format) { - // initialize the cache - FormatCache::getFormatToFieldDefinitionsMap(); - FormatCache::getFormatNamesHashSet(); - - // inject the format name - $hashSetReflector = new ReflectionProperty(FormatCache::class, "formatNamesHashSet"); - $hashSetReflector->setAccessible(true); - $formatNamesHashSet = $hashSetReflector->getValue(); - $formatNamesHashSet[$format] = true; - $hashSetReflector->setValue(null, $formatNamesHashSet); - - // inject the format definitions - $formatMapReflector = new ReflectionProperty(FormatCache::class, "formatToFieldFormatsMap"); - $formatMapReflector->setAccessible(true); - $formatToFieldFormatsMap = $formatMapReflector->getValue(); - $formatToFieldFormatsMap[$format] = MetaFormatHelper::createNameToFieldDefinitionsMap($format); - $formatMapReflector->setValue(null, $formatToFieldFormatsMap); - + MockHelper::injectFormat($format); Assert::notNull(FormatCache::getFieldDefinitions($format), "Tests whether a format was injected successfully."); } + /** + * Tests that assigning an unknown Format property throws. + * @return void + */ public function testInvalidFieldName() { - self::injectFormat(RequiredNullabilityTestFormat::class); + self::injectFormatChecked(RequiredNullabilityTestFormat::class); Assert::throws( function () { @@ -135,9 +139,13 @@ class TestFormats extends Tester\TestCase ); } + /** + * Tests that assigning null to a non-nullable property throws. + * @return void + */ public function testRequiredNotNullable() { - self::injectFormat(RequiredNullabilityTestFormat::class); + self::injectFormatChecked(RequiredNullabilityTestFormat::class); $fieldName = "requiredNotNullable"; // it is not nullable so this has to throw @@ -160,9 +168,13 @@ class TestFormats extends Tester\TestCase Assert::equal($format->$fieldName, 1); } + /** + * Tests that assigning null to not-required or nullable properties does not throw. + * @return void + */ public function testNullAssign() { - self::injectFormat(RequiredNullabilityTestFormat::class); + self::injectFormatChecked(RequiredNullabilityTestFormat::class); $format = new RequiredNullabilityTestFormat(); // not required and not nullable fields can contain null (not required overrides not nullable) @@ -177,9 +189,12 @@ class TestFormats extends Tester\TestCase } } + /** + * Test that QUERY and PATH properties use permissive validation (strings castable to ints). + */ public function testIndividualParamValidationPermissive() { - self::injectFormat(ValidationTestFormat::class); + self::injectFormatChecked(ValidationTestFormat::class); $format = new ValidationTestFormat(); // path and query parameters do not have strict validation @@ -188,17 +203,31 @@ class TestFormats extends Tester\TestCase $format->checkedAssign("path", "1"); $format->checkedAssign("path", 1); - // make sure that the above assignments did not throw - Assert::true(true); + // test that assigning an invalid type still throws (int expected) + Assert::throws( + function () use ($format) { + try { + $format->checkedAssign("query", "1.1"); + } catch (Exception $e) { + Assert::true(strlen($e->getMessage()) > 0); + throw $e; + } + }, + InvalidApiArgumentException::class + ); } + /** + * Test that PATH parameters use strict validation (strings cannot be passed instead of target types). + */ public function testIndividualParamValidationStrict() { - self::injectFormat(ValidationTestFormat::class); + self::injectFormatChecked(ValidationTestFormat::class); $format = new ValidationTestFormat(); - // post parameters have strict validation, assigning a string will throw $format->checkedAssign("post", 1); + + // post parameters have strict validation, assigning a string will throw Assert::throws( function () use ($format) { try { @@ -212,9 +241,12 @@ class TestFormats extends Tester\TestCase ); } + /** + * Test that assigning null to a non-nullable field throws. + */ public function testIndividualParamValidationNullable() { - self::injectFormat(ValidationTestFormat::class); + self::injectFormatChecked(ValidationTestFormat::class); $format = new ValidationTestFormat(); // null cannot be assigned unless the parameter is nullable or not required @@ -232,9 +264,12 @@ class TestFormats extends Tester\TestCase ); } + /** + * Test that the validate function throws with an invalid parameter or failed structural constraint. + */ public function testAggregateParamValidation() { - self::injectFormat(ValidationTestFormat::class); + self::injectFormatChecked(ValidationTestFormat::class); $format = new ValidationTestFormat(); // assign valid values and validate @@ -261,6 +296,8 @@ class TestFormats extends Tester\TestCase // assign valid values to all fields, but fail the structural constraint of $query == 1 $format->checkedAssign("path", 1); + $format->validate(); + $format->checkedAssign("query", 2); Assert::false($format->validateStructure()); Assert::throws( @@ -276,15 +313,18 @@ class TestFormats extends Tester\TestCase ); } + /** + * This test checks that errors in nested Formats propagate to the parent. + */ public function testNestedFormat() { - self::injectFormat(NestedFormat::class); - self::injectFormat(ParentFormat::class); + self::injectFormatChecked(NestedFormat::class); + self::injectFormatChecked(ParentFormat::class); $nested = new NestedFormat(); $parent = new ParentFormat(); // assign valid values that do not pass structural validation - // (both fields need to be 1 to pass) + // (the parent field needs to be 1, the nested field 2) $nested->checkedAssign("field", 0); $parent->checkedAssign("field", 0); $parent->checkedAssign("nested", $nested); @@ -299,6 +339,7 @@ class TestFormats extends Tester\TestCase }, BadRequestException::class ); + // the nested structure should also throw Assert::throws( function () use ($parent) { $parent->validate(); @@ -317,6 +358,11 @@ class TestFormats extends Tester\TestCase }, BadRequestException::class ); + + // fixing the nested structure should make both the nested and parent Format valid + $nested->checkedAssign("field", 2); + $nested->validate(); + $parent->validate(); } } diff --git a/tests/Validation/Validators.phpt b/tests/Validation/Validators.phpt index 832c3652f..5d56ac2a1 100644 --- a/tests/Validation/Validators.phpt +++ b/tests/Validation/Validators.phpt @@ -16,6 +16,9 @@ use Tester\Assert; $container = require_once __DIR__ . "/../bootstrap.php"; /** + * This test suite tests Format validators. + * All tests contain lists of valid/invalid values for both levels of strictness (if applicable), that are tested + * against a specific validator. * @testCase */ class TestValidators extends Tester\TestCase @@ -29,6 +32,14 @@ class TestValidators extends Tester\TestCase $this->container = $container; } + /** + * Helper function that returns readable error messages on failed validations. + * @param BaseValidator $validator The validator to be tested. + * @param mixed $value The value that did not pass. + * @param bool $expectedValid The expected value. + * @param bool $strict The strictness mode. + * @return string Returns an error message. + */ private static function getAssertionFailedMessage( BaseValidator $validator, mixed $value, @@ -61,7 +72,7 @@ class TestValidators extends Tester\TestCase /** * Test a validator against a set of input values. The strictness mode is set automatically by the method. - * @param App\Helpers\MetaFormats\Validators\BaseValidator $validator The validator to be tested. + * @param BaseValidator $validator The validator to be tested. * @param array $strictValid Valid values in the strict mode. * @param array $strictInvalid Invalid values in the strict mode. * @param array $permissiveValid Valid values in the permissive mode. From d7d7bbc78974346debfe138e431c60aa248d90b6 Mon Sep 17 00:00:00 2001 From: Vojtech Kloda Date: Sun, 15 Jun 2025 13:37:05 +0200 Subject: [PATCH 13/20] added file support --- .../presenters/UploadedFilesPresenter.php | 4 + .../presenters/base/BasePresenter.php | 21 ++++ app/helpers/MetaFormats/Attributes/FFile.php | 28 +++++ app/helpers/MetaFormats/Attributes/File.php | 28 +++++ app/helpers/MetaFormats/FileRequestType.php | 14 +++ app/helpers/MetaFormats/MetaFormatHelper.php | 14 ++- app/helpers/MetaFormats/RequestParamData.php | 8 ++ app/helpers/MetaFormats/Type.php | 1 + app/helpers/MetaFormats/Validators/VFile.php | 25 ++++ app/helpers/Swagger/AnnotationData.php | 111 ++++++++++++++++-- app/helpers/Swagger/AnnotationHelper.php | 4 + .../Swagger/AnnotationParameterData.php | 17 +++ docs/swagger.yaml | 19 +++ 13 files changed, 279 insertions(+), 15 deletions(-) create mode 100644 app/helpers/MetaFormats/Attributes/FFile.php create mode 100644 app/helpers/MetaFormats/Attributes/File.php create mode 100644 app/helpers/MetaFormats/FileRequestType.php create mode 100644 app/helpers/MetaFormats/Validators/VFile.php diff --git a/app/V1Module/presenters/UploadedFilesPresenter.php b/app/V1Module/presenters/UploadedFilesPresenter.php index f2bef12e5..6ffed7b9a 100644 --- a/app/V1Module/presenters/UploadedFilesPresenter.php +++ b/app/V1Module/presenters/UploadedFilesPresenter.php @@ -2,9 +2,11 @@ namespace App\V1Module\Presenters; +use App\Helpers\MetaFormats\Attributes\File; use App\Helpers\MetaFormats\Attributes\Post; use App\Helpers\MetaFormats\Attributes\Query; use App\Helpers\MetaFormats\Attributes\Path; +use App\Helpers\MetaFormats\FileRequestType; use App\Helpers\MetaFormats\Validators\VInt; use App\Helpers\MetaFormats\Validators\VString; use App\Helpers\MetaFormats\Validators\VUuid; @@ -321,6 +323,7 @@ public function checkUpload() * @throws CannotReceiveUploadedFileException * @throws InternalServerException */ + #[File(FileRequestType::FormData, "The whole file to be uploaded")] public function actionUpload() { $user = $this->getCurrentUser(); @@ -440,6 +443,7 @@ public function checkAppendPartial(string $id) */ #[Query("offset", new VInt(), "Offset of the chunk for verification", required: true)] #[Path("id", new VUuid(), "Identifier of the partial file", required: true)] + #[File(FileRequestType::OctetStream, "A chunk of the uploaded file")] public function actionAppendPartial(string $id, int $offset) { $partialFile = $this->uploadedPartialFiles->findOrThrow($id); diff --git a/app/V1Module/presenters/base/BasePresenter.php b/app/V1Module/presenters/base/BasePresenter.php index 59242990f..f9ad60d03 100644 --- a/app/V1Module/presenters/base/BasePresenter.php +++ b/app/V1Module/presenters/base/BasePresenter.php @@ -305,6 +305,8 @@ private function getValueFromParamData(RequestParamData $paramData): mixed return $this->getQueryField($paramData->name, required: $paramData->required); case Type::Path: return $this->getPathField($paramData->name); + case Type::File: + return $this->getFileField(required: $paramData->required); default: throw new InternalServerException("Unknown parameter type: {$paramData->type->name}"); } @@ -338,6 +340,25 @@ private function getPostField($param, $required = true) } } + private function getFileField($required = true) + { + $req = $this->getRequest(); + $files = $req->getFiles(); + + if (count($files) === 0) { + if ($required) { + throw new BadRequestException("No file was uploaded"); + } else { + return null; + } + } elseif (count($files) > 1) { + throw new BadRequestException("Too many files were uploaded"); + } + + $file = array_pop($files); + return $file; + } + private function getQueryField($param, $required = true) { $value = $this->getRequest()->getParameter($param); diff --git a/app/helpers/MetaFormats/Attributes/FFile.php b/app/helpers/MetaFormats/Attributes/FFile.php new file mode 100644 index 000000000..2714a7961 --- /dev/null +++ b/app/helpers/MetaFormats/Attributes/FFile.php @@ -0,0 +1,28 @@ +getAttributes(name: Path::class); $query = $reflectionMethod->getAttributes(name: Query::class); $post = $reflectionMethod->getAttributes(name: Post::class); + $file = $reflectionMethod->getAttributes(name: File::class); $param = $reflectionMethod->getAttributes(name: Param::class); - return array_merge($path, $query, $post, $param); + return array_merge($path, $query, $post, $file, $param); } /** @@ -91,7 +94,14 @@ public static function extractFormatParameterData(ReflectionProperty $reflection $pathAttributes = $reflectionObject->getAttributes(FPath::class); $queryAttributes = $reflectionObject->getAttributes(FQuery::class); $postAttributes = $reflectionObject->getAttributes(FPost::class); - $requestAttributes = array_merge($longAttributes, $pathAttributes, $queryAttributes, $postAttributes); + $fileAttributes = $reflectionObject->getAttributes(FFile::class); + $requestAttributes = array_merge( + $longAttributes, + $pathAttributes, + $queryAttributes, + $postAttributes, + $fileAttributes + ); // there should be only one attribute if (count($requestAttributes) == 0) { diff --git a/app/helpers/MetaFormats/RequestParamData.php b/app/helpers/MetaFormats/RequestParamData.php index 203d135ef..c5b87ac79 100644 --- a/app/helpers/MetaFormats/RequestParamData.php +++ b/app/helpers/MetaFormats/RequestParamData.php @@ -6,6 +6,7 @@ use App\Exceptions\InvalidApiArgumentException; use App\Helpers\MetaFormats\Validators\BaseValidator; use App\Helpers\MetaFormats\Validators\VArray; +use App\Helpers\MetaFormats\Validators\VFile; use App\Helpers\MetaFormats\Validators\VObject; use App\Helpers\Swagger\AnnotationParameterData; @@ -143,6 +144,12 @@ public function toAnnotationParameterData() }, $nestedRequestParmData); } + // get file request type if file + $fileRequestType = null; + if ($this->validators[0] instanceof VFile) { + $fileRequestType = $this->validators[0]->fileRequestType; + } + return new AnnotationParameterData( $swaggerType, $this->name, @@ -155,6 +162,7 @@ public function toAnnotationParameterData() $arrayDepth, $nestedObjectParameterData, $constraints, + $fileRequestType, ); } } diff --git a/app/helpers/MetaFormats/Type.php b/app/helpers/MetaFormats/Type.php index 32b5f06b9..a963c04fe 100644 --- a/app/helpers/MetaFormats/Type.php +++ b/app/helpers/MetaFormats/Type.php @@ -11,5 +11,6 @@ enum Type case Post; case Query; case Path; + case File; } // @codingStandardsIgnoreEnd diff --git a/app/helpers/MetaFormats/Validators/VFile.php b/app/helpers/MetaFormats/Validators/VFile.php new file mode 100644 index 000000000..051a448d4 --- /dev/null +++ b/app/helpers/MetaFormats/Validators/VFile.php @@ -0,0 +1,25 @@ +fileRequestType = $fileRequestType; + } + + public function validate(mixed $value): bool + { + return true; + } +} diff --git a/app/helpers/Swagger/AnnotationData.php b/app/helpers/Swagger/AnnotationData.php index 57d127e9f..66edff11a 100644 --- a/app/helpers/Swagger/AnnotationData.php +++ b/app/helpers/Swagger/AnnotationData.php @@ -2,7 +2,9 @@ namespace App\Helpers\Swagger; +use App\Exceptions\InternalServerException; use App\Helpers\MetaFormats\AnnotationConversion\Utils; +use App\Helpers\MetaFormats\FileRequestType; /** * A data structure for endpoint signatures that can produce annotations parsable by a swagger generator. @@ -25,6 +27,10 @@ class AnnotationData * @var AnnotationParameterData[] */ public array $bodyParams; + /** + * @var AnnotationParameterData[] + */ + public array $fileParams; public ?string $endpointDescription; public function __construct( @@ -34,6 +40,7 @@ public function __construct( array $pathParams, array $queryParams, array $bodyParams, + array $fileParams, ?string $endpointDescription = null, ) { $this->className = $className; @@ -42,12 +49,13 @@ public function __construct( $this->pathParams = $pathParams; $this->queryParams = $queryParams; $this->bodyParams = $bodyParams; + $this->fileParams = $fileParams; $this->endpointDescription = $endpointDescription; } public function getAllParams(): array { - return array_merge($this->pathParams, $this->queryParams, $this->bodyParams); + return array_merge($this->pathParams, $this->queryParams, $this->bodyParams, $this->fileParams); } /** @@ -63,24 +71,91 @@ private function getHttpMethodAnnotation(): string } /** - * Creates a JSON request body annotation string parsable by the swagger generator. - * Example: if the request body contains only the 'url' property, this method will produce: - * '@OA\RequestBody(@OA\MediaType(mediaType="application/json",@OA\Schema(@OA\Property(property="url",type="string"))))' - * @return string|null Returns the annotation string or null, if there are no body parameters. + * Creates a requestBody annotation string parsable by the swagger generator. + * Processes JSON request body and files (form-data, octet-stream). + * @return string|null Returns the annotation string or null, if there is no body. */ private function getBodyAnnotation(): string | null { - if (count($this->bodyParams) === 0) { + $head = '@OA\RequestBody'; + $body = new ParenthesesBuilder(); + + // add the json schema + $jsonSchema = $this->serializeBodyParams( + "application/json", + $this->bodyParams + ); + if ($jsonSchema !== null) { + $body->addValue($jsonSchema); + } + + // add the file schema + $fileSchema = $this->getFileAnnotation(); + if ($fileSchema !== null) { + $body->addValue($fileSchema); + } + + if ($jsonSchema === null && $fileSchema === null) { return null; } - // only json is supported due to the media type - $head = '@OA\RequestBody(@OA\MediaType(mediaType="application/json",@OA\Schema'; + return $head . $body->toString(); + } + + private function getFileAnnotation(): string | null + { + if (count($this->fileParams) === 0) { + return null; + } + + // filter file params based on type + $formParams = []; + $octetParams = []; + foreach ($this->fileParams as $fileParam) { + if ($fileParam->fileRequestType === FileRequestType::FormData) { + $formParams[] = $fileParam; + } elseif ($fileParam->fileRequestType === FileRequestType::OctetStream) { + $octetParams[] = $fileParam; + } else { + throw new InternalServerException("Unknown FileRequestType: " . $fileParam->fileRequestType->name); + } + } + + if (count($formParams) > 0 && count($octetParams) > 0) { + throw new InternalServerException("File requests cannot upload files as both form-data and octet-stream."); + } + if (count($octetParams) > 1) { + throw new InternalServerException("There can only be one octet-stream per request."); + } + + // generate a form-data or octet-stream annotation + if (count($formParams) > 0) { + return $this->serializeBodyParams("multipart/form-data", $formParams); + } else { + return $this->getOctetStreamAnnotation($octetParams[0]); + } + } + + /** + * Creates a content annotation string parsable by the swagger generator. + * Example: if a JSON request body contains only the 'url' property, this method will produce: + * '@OA\MediaType(mediaType="application/json",@OA\Schema(@OA\Property(property="url",type="string")))' + * @param string $mediaType The media type of the parameters ("application/json", "multipart/form-data"). + * @param array $bodyParams AnnotationParameterData array used to generate the annotation. + * @return string|null Returns the annotation string or null, if there are no body parameters. + */ + private function serializeBodyParams(string $mediaType, array $bodyParams): string | null + { + if (count($bodyParams) === 0) { + return null; + } + + $head = '@OA\MediaType(mediaType="' . $mediaType . '",@OA\Schema'; $body = new ParenthesesBuilder(); // list of all required properties $required = []; - foreach ($this->bodyParams as $bodyParam) { + foreach ($bodyParams as $bodyParam) { $body->addValue($bodyParam->toPropertyAnnotation()); if ($bodyParam->required) { // add quotes around the names (required by the swagger generator) @@ -95,7 +170,17 @@ private function getBodyAnnotation(): string | null $body->addValue("required=" . $requiredString); } - return $head . $body->toString() . "))"; + return $head . $body->toString() . ")"; + } + + private function getOctetStreamAnnotation(AnnotationParameterData $octetParam): string + { + $head = '@OA\MediaType(mediaType="application/octet-stream",@OA\Schema'; + $body = new ParenthesesBuilder(); + + $body->addKeyValue("type", $octetParam->swaggerType); + $body->addKeyValue("format", "binary"); + return $head . $body->toString() . ")"; } /** @@ -137,9 +222,9 @@ public function toSwaggerAnnotations(string $route) $body->addValue($queryParam->toParameterAnnotation()); } - $jsonProperties = $this->getBodyAnnotation(); - if ($jsonProperties !== null) { - $body->addValue($jsonProperties); + $bodyProperties = $this->getBodyAnnotation(); + if ($bodyProperties !== null) { + $body->addValue($bodyProperties); } ///TODO: A placeholder for the response type. This has to be replaced with the autogenerated meta-view diff --git a/app/helpers/Swagger/AnnotationHelper.php b/app/helpers/Swagger/AnnotationHelper.php index c28a12e09..2186d7150 100644 --- a/app/helpers/Swagger/AnnotationHelper.php +++ b/app/helpers/Swagger/AnnotationHelper.php @@ -294,6 +294,7 @@ private static function annotationParameterDataToAnnotationData( $pathParams = []; $queryParams = []; $bodyParams = []; + $fileParams = []; foreach ($params as $param) { if ($param->location === 'path') { @@ -302,6 +303,8 @@ private static function annotationParameterDataToAnnotationData( $queryParams[] = $param; } elseif ($param->location === 'post') { $bodyParams[] = $param; + } elseif ($param->location === 'file') { + $fileParams[] = $param; } else { throw new Exception("Error in extractAnnotationData: Unknown param location: {$param->location}"); } @@ -314,6 +317,7 @@ private static function annotationParameterDataToAnnotationData( $pathParams, $queryParams, $bodyParams, + $fileParams, $description ); } diff --git a/app/helpers/Swagger/AnnotationParameterData.php b/app/helpers/Swagger/AnnotationParameterData.php index 1d4599a3b..8d22599a1 100644 --- a/app/helpers/Swagger/AnnotationParameterData.php +++ b/app/helpers/Swagger/AnnotationParameterData.php @@ -3,6 +3,8 @@ namespace App\Helpers\Swagger; use App\Exceptions\InternalServerException; +use App\Helpers\MetaFormats\FileRequestType; +use App\Helpers\MetaFormats\Type; /** * Contains data of a single annotation parameter. @@ -21,6 +23,7 @@ class AnnotationParameterData public ?int $arrayDepth; public ?array $nestedObjectParameterData; public ?ParameterConstraints $constraints; + public ?FileRequestType $fileRequestType; public function __construct( string $swaggerType, @@ -34,6 +37,7 @@ public function __construct( ?int $arrayDepth = null, ?array $nestedObjectParameterData = null, ?ParameterConstraints $constraints = null, + ?FileRequestType $fileRequestType = null, ) { $this->swaggerType = $swaggerType; $this->name = $name; @@ -46,6 +50,7 @@ public function __construct( $this->arrayDepth = $arrayDepth; $this->nestedObjectParameterData = $nestedObjectParameterData; $this->constraints = $constraints; + $this->fileRequestType = $fileRequestType; } private function addArrayItemsIfArray(ParenthesesBuilder $container) @@ -105,6 +110,15 @@ private function addObjectParamsIfObject(ParenthesesBuilder $container) } } + private function addFileParamsIfFile(ParenthesesBuilder $container) + { + if ($this->location !== strtolower(Type::File->name)) { + return; + } + + $container->addKeyValue("format", "binary"); + } + /** * Generates swagger schema annotations based on the data type. * @return string Returns the annotation. @@ -175,6 +189,9 @@ public function toPropertyAnnotation(): string // handle objects $this->addObjectParamsIfObject($body); + // handle files + $this->addFileParamsIfFile($body); + // add example value if ($this->swaggerType !== "array" && $this->swaggerType !== "object") { if ($this->example != null) { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index b821b6812..1f3fe7a2b 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -4266,6 +4266,12 @@ paths: schema: type: integer nullable: false + requestBody: + content: + application/octet-stream: + schema: + type: string + format: binary responses: '200': description: 'The data' @@ -4308,6 +4314,19 @@ paths: summary: 'Upload a file' description: 'Upload a file' operationId: uploadedFilesPresenterActionUpload + requestBody: + content: + multipart/form-data: + schema: + required: + - file + properties: + file: + description: 'The whole file to be uploaded' + type: string + format: binary + nullable: false + type: object responses: '200': description: 'The data' From 2f9405ed7e84b845e02c0d3f492990ffe618ff6e Mon Sep 17 00:00:00 2001 From: Vojtech Kloda Date: Sun, 15 Jun 2025 13:53:22 +0200 Subject: [PATCH 14/20] loose and Format attributes are now merged when generating swagger --- app/helpers/Swagger/AnnotationHelper.php | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/app/helpers/Swagger/AnnotationHelper.php b/app/helpers/Swagger/AnnotationHelper.php index 2186d7150..6eddf5f7f 100644 --- a/app/helpers/Swagger/AnnotationHelper.php +++ b/app/helpers/Swagger/AnnotationHelper.php @@ -368,13 +368,13 @@ public static function extractAttributeData(string $className, string $methodNam $httpMethod = self::extractAnnotationHttpMethod($methodAnnotations); $reflectionMethod = self::getMethod($className, $methodName); + // extract loose attributes + $attributeData = MetaFormatHelper::extractRequestParamData($reflectionMethod); + + // if the endpoint is linked to a format, add the format class attributes $format = MetaFormatHelper::extractFormatFromAttribute($reflectionMethod); - // if the endpoint is linked to a format, use the format class if ($format !== null) { - $attributeData = FormatCache::getFieldDefinitions($format); - // otherwise use loose param attributes - } else { - $attributeData = MetaFormatHelper::extractRequestParamData($reflectionMethod); + $attributeData = array_merge($attributeData, FormatCache::getFieldDefinitions($format)); } $params = array_map(function ($data) { From 9f1263ede444f0411ad2e33ec71b8d85c854074b Mon Sep 17 00:00:00 2001 From: Vojtech Kloda Date: Sun, 15 Jun 2025 17:50:44 +0200 Subject: [PATCH 15/20] made uploaded file chunk optional so that tests do not fail --- app/V1Module/presenters/UploadedFilesPresenter.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/V1Module/presenters/UploadedFilesPresenter.php b/app/V1Module/presenters/UploadedFilesPresenter.php index 6ffed7b9a..aa5c978b9 100644 --- a/app/V1Module/presenters/UploadedFilesPresenter.php +++ b/app/V1Module/presenters/UploadedFilesPresenter.php @@ -443,7 +443,7 @@ public function checkAppendPartial(string $id) */ #[Query("offset", new VInt(), "Offset of the chunk for verification", required: true)] #[Path("id", new VUuid(), "Identifier of the partial file", required: true)] - #[File(FileRequestType::OctetStream, "A chunk of the uploaded file")] + #[File(FileRequestType::OctetStream, "A chunk of the uploaded file", required: false)] public function actionAppendPartial(string $id, int $offset) { $partialFile = $this->uploadedPartialFiles->findOrThrow($id); From 991dff758551646313866ab29a7b54ade2871bc5 Mon Sep 17 00:00:00 2001 From: Vojtech Kloda Date: Sun, 15 Jun 2025 18:07:22 +0200 Subject: [PATCH 16/20] fixed style error --- app/helpers/Swagger/AnnotationData.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/helpers/Swagger/AnnotationData.php b/app/helpers/Swagger/AnnotationData.php index 66edff11a..b0d0a2ce5 100644 --- a/app/helpers/Swagger/AnnotationData.php +++ b/app/helpers/Swagger/AnnotationData.php @@ -116,6 +116,8 @@ private function getFileAnnotation(): string | null $formParams[] = $fileParam; } elseif ($fileParam->fileRequestType === FileRequestType::OctetStream) { $octetParams[] = $fileParam; + } elseif ($fileParam->fileRequestType === null) { + throw new InternalServerException("The FileRequestType is null"); } else { throw new InternalServerException("Unknown FileRequestType: " . $fileParam->fileRequestType->name); } From d07da17c03ba39454578740a84512c588dd8a6c9 Mon Sep 17 00:00:00 2001 From: Vojtech Kloda Date: Sun, 6 Jul 2025 17:41:11 +0200 Subject: [PATCH 17/20] added caching mechanism for loose attributes --- .../presenters/base/BasePresenter.php | 9 +++++- app/helpers/MetaFormats/FormatCache.php | 31 +++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/app/V1Module/presenters/base/BasePresenter.php b/app/V1Module/presenters/base/BasePresenter.php index f9ad60d03..d36eeb626 100644 --- a/app/V1Module/presenters/base/BasePresenter.php +++ b/app/V1Module/presenters/base/BasePresenter.php @@ -213,7 +213,14 @@ private function processParams(ReflectionMethod $reflection) } // handle loose parameters - $paramData = MetaFormatHelper::extractRequestParamData($reflection); + + // cache the data from the loose attributes to improve performance + $actionPath = get_class($this) . $reflection->name; + if (!FormatCache::looseParametersCached($actionPath)) { + $newParamData = MetaFormatHelper::extractRequestParamData($reflection); + FormatCache::cacheLooseParameters($actionPath, $newParamData); + } + $paramData = FormatCache::getLooseParameters($actionPath); $this->processParamsLoose($paramData); } diff --git a/app/helpers/MetaFormats/FormatCache.php b/app/helpers/MetaFormats/FormatCache.php index 54e574475..a341cc014 100644 --- a/app/helpers/MetaFormats/FormatCache.php +++ b/app/helpers/MetaFormats/FormatCache.php @@ -16,6 +16,37 @@ class FormatCache private static ?array $formatNamesHashSet = null; private static ?array $formatToFieldFormatsMap = null; + // this array caches loose attribute data which are added over time by the presenters + private static array $actionToRequestParamDataMap = []; + + /** + * @param string $actionPath The presenter class name joined with the name of the action method. + * @return bool Returns whether the loose parameters of the action are cached. + */ + public static function looseParametersCached(string $actionPath): bool + { + return array_key_exists($actionPath, self::$actionToRequestParamDataMap); + } + + /** + * @param string $actionPath The presenter class name joined with the name of the action method. + * @return array Returns the cached RequestParamData array of the loose attributes. + */ + public static function getLooseParameters(string $actionPath): array + { + return self::$actionToRequestParamDataMap[$actionPath]; + } + + /** + * Caches a RequestParamData array from the loose attributes of an action. + * @param string $actionPath The presenter class name joined with the name of the action method. + * @param array $data The RequestParamData array to be cached. + */ + public static function cacheLooseParameters(string $actionPath, array $data): void + { + self::$actionToRequestParamDataMap[$actionPath] = $data; + } + /** * @return array Returns a dictionary of dictionaries: [ => [ => RequestParamData, ...], ...] * mapping formats to their fields and field metadata. From 4f74a2d08eb88a17e0fece035d33d05f795788cd Mon Sep 17 00:00:00 2001 From: Vojtech Kloda Date: Mon, 7 Jul 2025 12:04:12 +0200 Subject: [PATCH 18/20] improved format validation performance --- app/V1Module/presenters/base/BasePresenter.php | 2 +- app/helpers/MetaFormats/MetaFormat.php | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/app/V1Module/presenters/base/BasePresenter.php b/app/V1Module/presenters/base/BasePresenter.php index d36eeb626..abca291a5 100644 --- a/app/V1Module/presenters/base/BasePresenter.php +++ b/app/V1Module/presenters/base/BasePresenter.php @@ -286,7 +286,7 @@ private function processParamsFormat(string $format, ?array $valueDictionary): M } // this throws if the value is invalid - $formatInstance->checkedAssign($fieldName, $value); + $formatInstance->checkedAssignWithSchema($requestParamData, $fieldName, $value); } // validate structural constraints diff --git a/app/helpers/MetaFormats/MetaFormat.php b/app/helpers/MetaFormats/MetaFormat.php index 8138eff92..e6f724615 100644 --- a/app/helpers/MetaFormats/MetaFormat.php +++ b/app/helpers/MetaFormats/MetaFormat.php @@ -41,6 +41,21 @@ public function checkedAssign(string $fieldName, mixed $value) $this->$fieldName = $value; } + /** + * Tries to assign a value to a field. If the value does not conform to the provided schema, an exception is thrown. + * The exception details why the value does not conform to the format. + * More performant version of checkedAssign. + * @param RequestParamData $requestParamData The schema of the request parameter. + * @param string $fieldName The name of the field. + * @param mixed $value The value to be assigned. + * @throws InvalidApiArgumentException Thrown when the value is not assignable. + */ + public function checkedAssignWithSchema(RequestParamData $requestParamData, string $fieldName, mixed $value) + { + $requestParamData->conformsToDefinition($value); + $this->$fieldName = $value; + } + /** * Validates the given format. * @throws InvalidApiArgumentException Thrown when a value is not assignable. From a3697d6e2ba8cc2fd8594efec972014f5f85de56 Mon Sep 17 00:00:00 2001 From: Vojtech Kloda Date: Mon, 7 Jul 2025 12:30:16 +0200 Subject: [PATCH 19/20] removed duplicate mock classes --- app/helpers/Mocks/MockHelper.php | 64 ----------------------- app/helpers/Mocks/MockTemplate.php | 22 -------- app/helpers/Mocks/MockTemplateFactory.php | 15 ------ app/helpers/Mocks/MockUserStorage.php | 26 --------- 4 files changed, 127 deletions(-) delete mode 100644 app/helpers/Mocks/MockHelper.php delete mode 100644 app/helpers/Mocks/MockTemplate.php delete mode 100644 app/helpers/Mocks/MockTemplateFactory.php delete mode 100644 app/helpers/Mocks/MockUserStorage.php diff --git a/app/helpers/Mocks/MockHelper.php b/app/helpers/Mocks/MockHelper.php deleted file mode 100644 index a0fd66b37..000000000 --- a/app/helpers/Mocks/MockHelper.php +++ /dev/null @@ -1,64 +0,0 @@ -application = $application; - - $factory = new MockTemplateFactory(); - - $presenter->injectPrimary($httpRequest, $httpResponse, user: $user, templateFactory: $factory); - } - - /** - * Injects a Format class to the FormatCache. - * This method must not be used outside of testing, normal Format classes are discovered automatically. - * @param string $format The Format class name. - */ - public static function injectFormat(string $format) - { - // make sure the cache is initialized (it uses lazy loading) - FormatCache::getFormatToFieldDefinitionsMap(); - FormatCache::getFormatNamesHashSet(); - - // inject the format name - $hashSetReflector = new ReflectionProperty(FormatCache::class, "formatNamesHashSet"); - $hashSetReflector->setAccessible(true); - $formatNamesHashSet = $hashSetReflector->getValue(); - $formatNamesHashSet[$format] = true; - $hashSetReflector->setValue(null, $formatNamesHashSet); - - // inject the format definitions - $formatMapReflector = new ReflectionProperty(FormatCache::class, "formatToFieldFormatsMap"); - $formatMapReflector->setAccessible(true); - $formatToFieldFormatsMap = $formatMapReflector->getValue(); - $formatToFieldFormatsMap[$format] = MetaFormatHelper::createNameToFieldDefinitionsMap($format); - $formatMapReflector->setValue(null, $formatToFieldFormatsMap); - } -} diff --git a/app/helpers/Mocks/MockTemplate.php b/app/helpers/Mocks/MockTemplate.php deleted file mode 100644 index 1caf655fe..000000000 --- a/app/helpers/Mocks/MockTemplate.php +++ /dev/null @@ -1,22 +0,0 @@ - Date: Mon, 7 Jul 2025 18:16:44 +0200 Subject: [PATCH 20/20] added method comments and type hints --- app/V1Module/presenters/base/BasePresenter.php | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/app/V1Module/presenters/base/BasePresenter.php b/app/V1Module/presenters/base/BasePresenter.php index abca291a5..04dfe8e87 100644 --- a/app/V1Module/presenters/base/BasePresenter.php +++ b/app/V1Module/presenters/base/BasePresenter.php @@ -26,6 +26,7 @@ use App\Responses\StorageFileResponse; use App\Responses\ZipFilesResponse; use Nette\Application\Application; +use Nette\Http\FileUpload; use Nette\Http\IResponse; use Tracy\ILogger; use ReflectionClass; @@ -347,7 +348,12 @@ private function getPostField($param, $required = true) } } - private function getFileField($required = true) + /** + * @param bool $required Whether the file field is required. + * @throws BadRequestException Thrown when the number of files is not 1 (and the field is required). + * @return FileUpload|null Returns a FileUpload object or null if the file was optional and not sent. + */ + private function getFileField(bool $required = true): FileUpload | null { $req = $this->getRequest(); $files = $req->getFiles();