Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
fb573f7
added validator tests
eceltov May 14, 2025
0d31158
MetaFormat::validate now throws instead of returning bool, fixed bad …
eceltov May 14, 2025
9213a9e
added format tests
eceltov May 14, 2025
0daa9d4
made many methods accept a request object instead of fetching it them…
eceltov May 16, 2025
eaac5b8
added nested format tests
eceltov May 16, 2025
81cc0c4
added BasePresenter tests
eceltov May 16, 2025
b639c9e
improved format tests
eceltov May 16, 2025
d109873
Merge branch 'client-generator-adaptation' into meta-testing
eceltov May 20, 2025
964387a
Merge branch 'meta-testing' of github.com:ReCodEx/api into meta-testing
eceltov May 20, 2025
1ba4ffc
Merge branch 'master' into meta-testing
eceltov Jun 5, 2025
88c953f
WIP adding mocks
eceltov Jun 5, 2025
685be46
fixed tests with mocks
eceltov Jun 5, 2025
2328fe9
fixed missing constraints in parameters
eceltov Jun 9, 2025
b30eca2
Merge branch 'master' into meta-testing
eceltov Jun 12, 2025
804d6c7
removed unused imports
eceltov Jun 12, 2025
ef0840e
added more tests and comments
eceltov Jun 12, 2025
8c56741
Merge branch 'meta-testing' into validation-bugfixes
eceltov Jun 13, 2025
d7d7bbc
added file support
eceltov Jun 15, 2025
2f9405e
loose and Format attributes are now merged when generating swagger
eceltov Jun 15, 2025
9f1263e
made uploaded file chunk optional so that tests do not fail
eceltov Jun 15, 2025
991dff7
fixed style error
eceltov Jun 15, 2025
d07da17
added caching mechanism for loose attributes
eceltov Jul 6, 2025
4f74a2d
improved format validation performance
eceltov Jul 7, 2025
d067cd0
merged with master
eceltov Jul 7, 2025
a3697d6
removed duplicate mock classes
eceltov Jul 7, 2025
5ca772a
added method comments and type hints
eceltov Jul 7, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions app/V1Module/presenters/UploadedFilesPresenter.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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", required: false)]
public function actionAppendPartial(string $id, int $offset)
{
$partialFile = $this->uploadedPartialFiles->findOrThrow($id);
Expand Down
38 changes: 36 additions & 2 deletions app/V1Module/presenters/base/BasePresenter.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -213,7 +214,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);
}

Expand Down Expand Up @@ -279,7 +287,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
Expand All @@ -305,6 +313,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}");
}
Expand Down Expand Up @@ -338,6 +348,30 @@ private function getPostField($param, $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();

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);
Expand Down
28 changes: 28 additions & 0 deletions app/helpers/MetaFormats/Attributes/FFile.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

namespace App\Helpers\MetaFormats\Attributes;

use App\Helpers\MetaFormats\FileRequestType;
use App\Helpers\MetaFormats\Type;
use App\Helpers\MetaFormats\Validators\VFile;
use Attribute;

/**
* Attribute used to annotate format definition properties representing a file parameter.
*/
#[Attribute(Attribute::TARGET_PROPERTY)]
class FFile extends FormatParameterAttribute
{
/**
* @param FileRequestType $fileRequestType How will the file be transmitted in the request.
* @param string $description The description of the request parameter.
* @param bool $required Whether the request parameter is required.
*/
public function __construct(
FileRequestType $fileRequestType,
string $description = "",
bool $required = true,
) {
parent::__construct(Type::File, new VFile($fileRequestType), $description, $required, false);
}
}
28 changes: 28 additions & 0 deletions app/helpers/MetaFormats/Attributes/File.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

namespace App\Helpers\MetaFormats\Attributes;

use App\Helpers\MetaFormats\FileRequestType;
use App\Helpers\MetaFormats\Type;
use App\Helpers\MetaFormats\Validators\VFile;
use Attribute;

/**
* Attribute used to specify that an endpoint expects a file.
*/
#[Attribute(Attribute::TARGET_METHOD)]
class File extends Param
{
/**
* @param FileRequestType $fileRequestType How will the file be transmitted in the request.
* @param string $description The description of the request parameter.
* @param bool $required Whether the request parameter is required.
*/
public function __construct(
FileRequestType $fileRequestType,
string $description = "",
bool $required = true,
) {
parent::__construct(Type::File, "file", new VFile($fileRequestType), $description, $required, false);
}
}
14 changes: 14 additions & 0 deletions app/helpers/MetaFormats/FileRequestType.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php

namespace App\Helpers\MetaFormats;

// @codingStandardsIgnoreStart
/**
* An enumeration of types how files can be transmitted.
*/
enum FileRequestType
{
case OctetStream;
case FormData;
}
// @codingStandardsIgnoreEnd
31 changes: 31 additions & 0 deletions app/helpers/MetaFormats/FormatCache.php
Original file line number Diff line number Diff line change
Expand Up @@ -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: [<formatName> => [<fieldName> => RequestParamData, ...], ...]
* mapping formats to their fields and field metadata.
Expand Down
15 changes: 15 additions & 0 deletions app/helpers/MetaFormats/MetaFormat.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
14 changes: 12 additions & 2 deletions app/helpers/MetaFormats/MetaFormatHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
namespace App\Helpers\MetaFormats;

use App\Exceptions\InternalServerException;
use App\Helpers\MetaFormats\Attributes\FFile;
use App\Helpers\MetaFormats\Attributes\File;
use App\Helpers\MetaFormats\Attributes\Format;
use App\Helpers\MetaFormats\Attributes\FormatParameterAttribute;
use App\Helpers\MetaFormats\Attributes\FPath;
Expand Down Expand Up @@ -50,8 +52,9 @@ public static function getEndpointAttributes(ReflectionMethod $reflectionMethod)
$path = $reflectionMethod->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);
}

/**
Expand Down Expand Up @@ -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) {
Expand Down
8 changes: 8 additions & 0 deletions app/helpers/MetaFormats/RequestParamData.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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,
Expand All @@ -155,6 +162,7 @@ public function toAnnotationParameterData()
$arrayDepth,
$nestedObjectParameterData,
$constraints,
$fileRequestType,
);
}
}
1 change: 1 addition & 0 deletions app/helpers/MetaFormats/Type.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,6 @@ enum Type
case Post;
case Query;
case Path;
case File;
}
// @codingStandardsIgnoreEnd
25 changes: 25 additions & 0 deletions app/helpers/MetaFormats/Validators/VFile.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

namespace App\Helpers\MetaFormats\Validators;

use App\Helpers\MetaFormats\FileRequestType;

/**
* Validates files. Currently, all files are valid.
*/
class VFile extends BaseValidator
{
public const SWAGGER_TYPE = "string";
public readonly FileRequestType $fileRequestType;

public function __construct(FileRequestType $fileRequestType)
{
parent::__construct(strict: false);
$this->fileRequestType = $fileRequestType;
}

public function validate(mixed $value): bool
{
return true;
}
}
Loading