Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion .github/workflows/static-analysis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ jobs:
run: composer install --no-interaction --no-progress --prefer-dist

- name: Run PHP Mess Detector
run: ./vendor/bin/phpmd src text ./phpmd.xml
run: ./vendor/bin/phpmd analyze src --format=text --ruleset=./phpmd.xml --no-progress

static-analysis-composer-require-checker:
name: ComposerRequireChecker
Expand Down
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@
"cs-fix": "phpcbf src tests",
"phpstan": "phpstan analyse -c phpstan.neon",
"psalm": "psalm --show-info=false",
"phpmd": "phpmd text ./phpmd.xml",
"phpmd": "phpmd analyze src --format=text --ruleset=./phpmd.xml --no-progress",
"baseline": "phpstan analyse -c phpstan.neon --generate-baseline --allow-empty-baseline && psalm --set-baseline=psalm-baseline.xml",
"crc": "composer-require-checker check ./composer.json",
"metrics": "phpmetrics --report-html=build/metrics --exclude=Exception src",
Expand Down
34 changes: 34 additions & 0 deletions src/Exception/InvalidInputTypeException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

declare(strict_types=1);

namespace Ray\InputQuery\Exception;

use InvalidArgumentException;
use Throwable;

use function get_debug_type;

final class InvalidInputTypeException extends InvalidArgumentException
{
public function __construct(
public readonly string $paramName,
public readonly string $expectedType,
public readonly string $actualType,
public readonly int|string|null $itemKey = null,
int $code = 0,
Throwable|null $previous = null,
) {
parent::__construct('', $code, $previous);
}

public static function forParameter(string $paramName, mixed $actualValue): self
{
return new self($paramName, 'array', get_debug_type($actualValue));
}

public static function forItem(string $paramName, int|string $itemKey, mixed $actualValue): self
{
return new self($paramName, 'array', get_debug_type($actualValue), $itemKey);
}
}
158 changes: 128 additions & 30 deletions src/InputQuery.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
use Ray\InputQuery\Attribute\Input;
use Ray\InputQuery\Attribute\InputFile;
use Ray\InputQuery\Exception\InvalidFileUploadAttributeException;
use Ray\InputQuery\Exception\InvalidInputTypeException;
use ReflectionAttribute;
use ReflectionClass;
use ReflectionMethod;
Expand All @@ -27,7 +28,6 @@
use function assert;
use function class_exists;
use function count;
use function gettype;
use function is_array;
use function is_bool;
use function is_float;
Expand Down Expand Up @@ -194,7 +194,7 @@ private function resolveInputParameter(ReflectionParameter $param, array $query,

// Handle union types (e.g., FileUpload|ErrorFileUpload)
if ($type instanceof ReflectionUnionType) {
return $this->resolveUnionType($param, $query, $type);
return $this->resolveUnionType($param, $query, $inputAttributes, $type);
}

if (! $type instanceof ReflectionNamedType) {
Expand All @@ -214,33 +214,68 @@ private function resolveInputParameter(ReflectionParameter $param, array $query,
*/
private function resolveBuiltinType(ReflectionParameter $param, array $query, array $inputAttributes, ReflectionNamedType $type): mixed
{
$paramName = $param->getName();

if ($type->getName() === 'array') {
$inputAttribute = $inputAttributes[0]->newInstance();
if ($inputAttribute->item !== null) {
assert(class_exists($inputAttribute->item));
$itemClass = $inputAttribute->item;

// Check if array items are FileUpload types
if (FileUploadTypeChecker::isFileUploadType($itemClass)) {
throw new InvalidFileUploadAttributeException(
sprintf('FileUpload array parameter "%s" must use #[InputFile] attribute, not #[Input]', $paramName),
);
}

/** @var class-string<T> $itemClass */
return $this->createArrayOfInputs($paramName, $query, $itemClass);
}
return $this->getArrayParameterValue($param, $query, $inputAttributes);
}

// Scalar type with #[Input]
$paramName = $param->getName();
/** @psalm-suppress MixedAssignment $value */
$value = $query[$paramName] ?? $this->getDefaultValue($param);

return $this->convertScalar($value, $type);
}

/**
* @param Query $query
* @param InputAttributes $inputAttributes
*/
private function getArrayParameterValue(ReflectionParameter $param, array $query, array $inputAttributes): mixed
{
$paramName = $param->getName();
$itemClass = $this->getArrayInputItemClass($paramName, $inputAttributes);
if ($itemClass !== null) {
return $this->createArrayOfInputs($paramName, $query, $itemClass);
}

if (! array_key_exists($paramName, $query)) {
return $this->getDefaultValue($param);
}

/** @var mixed $arrayData */
$arrayData = $query[$paramName];
if (! is_array($arrayData)) {
throw InvalidInputTypeException::forParameter($paramName, $arrayData);
}

return $arrayData;
}

/**
* @param InputAttributes $inputAttributes
*
* @return class-string<T>|null
*/
private function getArrayInputItemClass(string $paramName, array $inputAttributes): string|null
{
$inputAttribute = $inputAttributes[0]->newInstance();
if ($inputAttribute->item === null) {
return null;
}

assert(class_exists($inputAttribute->item));
/** @var class-string<T> $itemClass */
$itemClass = $inputAttribute->item;

if (FileUploadTypeChecker::isFileUploadType($itemClass)) {
throw new InvalidFileUploadAttributeException(
sprintf('FileUpload array parameter "%s" must use #[InputFile] attribute, not #[Input]', $paramName),
);
}

return $itemClass;
}

/**
* @param Query $query
* @param InputAttributes $inputAttributes
Expand Down Expand Up @@ -473,20 +508,14 @@ private function createArrayOfInputs(string $paramName, array $query, string $it
$arrayData = $query[$paramName];

if (! is_array($arrayData)) {
return [];
throw InvalidInputTypeException::forParameter($paramName, $arrayData);
}

$result = [];
/** @var mixed $itemData */
foreach ($arrayData as $key => $itemData) {
if (! is_array($itemData)) {
throw new InvalidArgumentException(
sprintf(
'Expected array for item at key "%s", got %s.',
$key,
gettype($itemData),
),
);
throw InvalidInputTypeException::forItem($paramName, $key, $itemData);
}

// Query parameters from HTTP requests have string keys
Expand All @@ -498,9 +527,16 @@ private function createArrayOfInputs(string $paramName, array $query, string $it
return $result;
}

/** @param Query $query */
private function resolveUnionType(ReflectionParameter $param, array $query, ReflectionUnionType $type): mixed
{
/**
* @param Query $query
* @param InputAttributes $inputAttributes
*/
private function resolveUnionType(
ReflectionParameter $param,
array $query,
array $inputAttributes,
ReflectionUnionType $type,
): mixed {
// Check if this is a file upload union type
if (FileUploadTypeChecker::isValidFileUploadUnion($type)) {
// This is a valid FileUpload union, handle as file upload
Expand All @@ -510,9 +546,71 @@ private function resolveUnionType(ReflectionParameter $param, array $query, Refl
return $this->fileUploadFactory->create($param, $query, $inputFileAttribute);
}

if ($this->isNullableArrayUnion($type)) {
return $this->getNullableArrayParameterValue($param, $query, $inputAttributes);
}

// Not a FileUpload union type, handle as regular parameter
$paramName = $param->getName();

return $query[$paramName] ?? $this->getDefaultValue($param);
}

private function isNullableArrayUnion(ReflectionUnionType $type): bool
{
$hasArray = false;
$hasNull = false;

foreach ($type->getTypes() as $namedType) {
if (! $namedType instanceof ReflectionNamedType) {
return false;
}

if ($namedType->getName() === 'array') {
$hasArray = true;
continue;
}

if ($namedType->getName() === 'null') {
$hasNull = true;
continue;
}

return false;
}

return $hasArray && $hasNull;
}

/**
* @param Query $query
* @param InputAttributes $inputAttributes
*/
private function getNullableArrayParameterValue(
ReflectionParameter $param,
array $query,
array $inputAttributes,
): mixed {
$paramName = $param->getName();
if (! array_key_exists($paramName, $query)) {
return $this->getDefaultValue($param);
}

/** @var mixed $arrayData */
$arrayData = $query[$paramName];
if ($arrayData === null) {
return null;
}

$itemClass = $this->getArrayInputItemClass($paramName, $inputAttributes);
if ($itemClass !== null) {
return $this->createArrayOfInputs($paramName, $query, $itemClass);
}

if (! is_array($arrayData)) {
throw InvalidInputTypeException::forParameter($paramName, $arrayData);
}

return $arrayData;
}
}
Loading
Loading