Skip to content
Open
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
12 changes: 10 additions & 2 deletions src/Annotations/Prefetch.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,16 @@
#[Attribute(Attribute::TARGET_PARAMETER)]
class Prefetch implements ParameterAnnotationInterface
{
/** @param string|(callable&array{class-string, string}) $callable */
public function __construct(public readonly string|array $callable)
/**
* @param string|(callable&array{class-string, string})|object $callable The prefetch method: a bare method name on
* the declaring class, an array callable, an
* invokable object, or — from PHP 8.5 —
* first-class callable syntax. A non-static
* method named by the array form is resolved
* through the container; first-class callable
* syntax cannot express that form.
*/
public function __construct(public readonly string|array|object $callable)
{
}

Expand Down
100 changes: 94 additions & 6 deletions src/Annotations/Security.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,43 @@

use Attribute;
use BadMethodCallException;
use TheCodingMachine\GraphQLite\GraphQLRuntimeException;

use function array_key_exists;
use function is_string;

#[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
class Security implements MiddlewareAnnotationInterface
{
private string $expression;
private string|null $expression = null;

/** @var array{class-string, string}|object|null */
private array|object|null $rule = null;

private mixed $failWith;
private bool $failWithIsSet = false;
private int $statusCode;
private string $message;

/**
* @param array<string, mixed>|string $data data array managed by the Doctrine Annotations library or the expression
* The message exactly as it was written in the attribute, or null when none was written.
*
* The default is applied by {@see getMessage()} rather than here, so that "no message given"
* stays distinguishable from "a message was given, and it happens to read like the default".
* A middleware with a better message available, such as one the rule states itself, can only
* prefer it over the default if it can tell those two apart. See {@see hasMessage()}.
*/
private string|null $message;

/**
* @param array<string, mixed>|string $data data array managed by the Doctrine Annotations library or the expression
* @param string|null $expression A security expression, evaluated by Symfony ExpressionLanguage. Suits short,
* local predicates. Prefer $rule for logic worth typing, testing or reusing.
* @param array{class-string, string}|object|null $rule A callable receiving a
* {@see \TheCodingMachine\GraphQLite\Security\SecurityRuleContext}
* and returning bool. Accepts an array callable, an invokable
* object, and — from PHP 8.5 — first-class callable syntax
* or an inline static closure. A non-static method named by
* the array form is resolved through the container.
*
* @throws BadMethodCallException
*/
Expand All @@ -30,17 +52,33 @@ public function __construct(
mixed $failWith = '__fail__with__magic__key__',
string|null $message = null,
int|null $statusCode = null,
array|object|null $rule = null,
) {
if (is_string($data)) {
$data = ['expression' => $data];
}

$expression = $data['value'] ?? $data['expression'] ?? $expression;
$rule = $data['rule'] ?? $rule;

// An empty expression has always counted as "no expression"; keep that.
if (! $expression) {
throw new BadMethodCallException('The #[Security] attribute must be passed an expression. For instance: "#[Security("is_granted(\'CAN_EDIT_STUFF\')")]"');
$expression = null;
}

// Silent precedence would turn a half-finished migration into a security change: whichever
// of the two lost would simply stop being enforced, with nothing in the schema or the
// response saying so. #[Security] is repeatable, so two checks means two attributes.
if ($expression !== null && $rule !== null) {
throw new BadMethodCallException('A #[Security] attribute cannot be passed both an expression and a rule. #[Security] is repeatable: declare one attribute per check.');
}

if ($expression === null && $rule === null) {
throw new BadMethodCallException('The #[Security] attribute must be passed an expression or a rule. For instance: "#[Security(rule: [MyRules::class, \'canEditStuff\'])]"');
}

$this->expression = $expression;
$this->rule = $rule;

if (array_key_exists('failWith', $data)) {
$this->failWith = $data['failWith'];
Expand All @@ -49,18 +87,50 @@ public function __construct(
$this->failWith = $failWith;
$this->failWithIsSet = true;
}
$this->message = $message ?? $data['message'] ?? 'Access denied.';
$this->message = $message ?? $data['message'] ?? null;
$this->statusCode = $statusCode ?? $data['statusCode'] ?? 403;
// Deliberately reads the raw arguments rather than $this->message: an attribute passing
// failWith together with a message has always been an error, and storing the message
// unresolved must not change which combinations are rejected.
if ($this->failWithIsSet === true && (($message || isset($data['message'])) || ($statusCode || isset($data['statusCode'])))) {
throw new BadMethodCallException('A #[Security] attribute that has "failWith" attribute set cannot have a message or a statusCode attribute.');
}
}

/**
* Whether this annotation carries an expression rather than a rule.
*
* Custom middlewares reading #[Security] annotations should branch on this, or on
* {@see getRule()}, before calling {@see getExpression()}.
*/
public function hasExpression(): bool
{
return $this->expression !== null;
}

/** @throws GraphQLRuntimeException When this annotation carries a rule instead of an expression. */
public function getExpression(): string
{
if ($this->expression === null) {
throw new GraphQLRuntimeException('This #[Security] attribute carries a rule, not an expression. Call getRule() instead, or check hasExpression() first.');
}

return $this->expression;
}

/**
* The callable to invoke, or null when this annotation carries an expression.
*
* The value is returned exactly as it was written in the attribute; turning it into a Closure
* is the middleware's job, so that it happens once, at schema-build time.
*
* @return array{class-string, string}|object|null
*/
public function getRule(): array|object|null
{
return $this->rule;
}

public function isFailWithSet(): bool
{
return $this->failWithIsSet;
Expand All @@ -76,8 +146,26 @@ public function getStatusCode(): int
return $this->statusCode;
}

/**
* Whether a message was written in the attribute.
*
* A middleware holding a better message than the default, such as one a rule states itself,
* branches on this: an explicit message is the field author's decision and always wins, while
* an absent one leaves the middleware free to supply its own before falling back to
* {@see getMessage()}.
*/
public function hasMessage(): bool
{
return $this->message !== null;
}

/**
* The message to deny with, defaulting to "Access denied." when the attribute gave none.
*
* Always a string, so a middleware that has nothing better to offer can call this alone.
*/
public function getMessage(): string
{
return $this->message;
return $this->message ?? 'Access denied.';
}
}
131 changes: 131 additions & 0 deletions src/CallableResolver.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
<?php

declare(strict_types=1);

namespace TheCodingMachine\GraphQLite;

use Closure;
use Psr\Container\ContainerInterface;
use ReflectionException;
use ReflectionFunction;
use ReflectionFunctionAbstract;
use ReflectionMethod;

use function is_array;
use function is_string;

/**
* Normalizes the callable forms an attribute can carry into a {@see Closure}.
*
* Attribute arguments are constant expressions, so a behavior-carrying attribute can only name a
* callable in a handful of ways: an array callable, a bare method name, an invokable object, or,
* from PHP 8.5, first-class callable syntax and inline static closures. This class turns every one
* of them into a Closure once, at schema-build time, so the per-request path never branches on the
* shape it was handed and an unresolvable callable fails at startup with a class and method name in
* the message rather than inside a resolver.
*
* A non-static method named by the array form is resolved through the container, which is the
* behavior {@see Annotations\Prefetch} has always had. First-class callable syntax cannot express
* that form, so the array form is not a compatibility shim for older PHP: it is the only syntax
* that can name a container-resolved method, and it stays.
*/
class CallableResolver
{
public function __construct(private readonly ContainerInterface $container)
{
}

/**
* @param string|array{class-string, string}|object $callable An array callable, a bare method
* name resolved against $classContext,
* an invokable object, or a Closure.
* @param class-string|null $classContext Class a bare method name is resolved against.
*
* @return array{Closure, ReflectionFunctionAbstract} The normalized closure, paired with the
* reflection of what it ultimately calls.
*
* @throws InvalidCallableRuntimeException
*/
public function resolve(string|array|object $callable, string|null $classContext = null): array
{
if ($callable instanceof Closure) {
return [$callable, self::reflectClosure($callable)];
}

if (! is_array($callable) && ! is_string($callable)) {
return self::resolveInvokable($callable);
}

// A bare method name is equivalent to [$classContext, $method].
if (is_string($callable)) {
if ($classContext === null) {
throw InvalidCallableRuntimeException::noClassContext($callable);
}

$callable = [$classContext, $callable];
}

[$className, $methodName] = $callable;

try {
$refMethod = new ReflectionMethod($className, $methodName);
} catch (ReflectionException $e) {
throw InvalidCallableRuntimeException::methodNotFound($className, $methodName, $e);
}

if (! $refMethod->isPublic()) {
throw InvalidCallableRuntimeException::methodNotPublic($className, $methodName);
}

if ($refMethod->isStatic()) {
return [$refMethod->getClosure(), $refMethod];
}

// A non-static method is resolved through the container at invocation time, so the service
// keeps its normal lifecycle instead of being built while the schema is compiled.
$container = $this->container;
$closure = static fn (mixed ...$args): mixed => $container->get($className)->$methodName(...$args);

return [$closure, $refMethod];
}

/**
* @return array{Closure, ReflectionFunctionAbstract}
*
* @throws InvalidCallableRuntimeException
*/
private static function resolveInvokable(object $callable): array
{
try {
$refMethod = new ReflectionMethod($callable, '__invoke');
} catch (ReflectionException $e) {
throw InvalidCallableRuntimeException::notInvokable($callable::class, $e);
}

if (! $refMethod->isPublic()) {
throw InvalidCallableRuntimeException::methodNotPublic($callable::class, '__invoke');
}

return [$refMethod->getClosure($callable), $refMethod];
}

/**
* Recovers the method a Closure came from, when there is one.
*
* First-class callable syntax (`Foo::bar(...)`) produces a Closure that still reports its
* originating method's name, scope and docblock, so callers that need to reflect the target,
* such as {@see ParameterizedCallableResolver}, keep working exactly as they do for the array
* form. An inline closure has no such origin and reflects as a plain function.
*/
private static function reflectClosure(Closure $closure): ReflectionFunctionAbstract
{
$refFunction = new ReflectionFunction($closure);
$scope = $refFunction->getClosureScopeClass();

if ($scope !== null && $scope->hasMethod($refFunction->getName())) {
return $scope->getMethod($refFunction->getName());
}

return $refFunction;
}
}
35 changes: 35 additions & 0 deletions src/InvalidCallableRuntimeException.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,39 @@ public static function methodNotFound(string $className, string $methodName, Thr
{
return new self('Method ' . $className . '::' . $methodName . " wasn't found or isn't accessible.", 0, $previous);
}

public static function methodNotPublic(string $className, string $methodName): self
{
return new self(
'Method ' . $className . '::' . $methodName . ' must be public to be used as a callable. On PHP 8.5 and later, '
. 'first-class callable syntax written inside the declaring class keeps the class scope and can therefore name a '
. 'private method: ' . $className . '::' . $methodName . '(...).',
);
}

public static function notInvokable(string $className, Throwable|null $previous = null): self
{
return new self(
'Object of class ' . $className . ' cannot be used as a callable because it has no __invoke() method.',
0,
$previous,
);
}

public static function noClassContext(string $methodName): self
{
return new self(
'The bare method name "' . $methodName . '" cannot be resolved because no class context was provided. '
. 'Name the class explicitly instead, for instance [MyRules::class, "' . $methodName . '"].',
);
}

public static function notAMethod(): self
{
return new self(
'The callable must name a real method, because its parameters are mapped to GraphQL arguments and their '
. 'descriptions are read from the method docblock. An inline closure has no such method. Use an array '
. 'callable, an invokable object, or first-class callable syntax on PHP 8.5 and later.',
);
}
}
13 changes: 13 additions & 0 deletions src/Middlewares/BadExpressionInSecurityException.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,17 @@ public static function wrapException(Throwable $e, QueryFieldDescriptor|InputFie

return new self($message, $e->getCode(), $e);
}

/**
* Raised while the schema is built, so a malformed expression is a startup error naming the
* field rather than a surprise inside a resolver on some later request.
*/
public static function fromSyntaxError(Throwable $e, QueryFieldDescriptor|InputFieldDescriptor $fieldDescriptor, string $expression): self
{
$originalResolver = $fieldDescriptor->getOriginalResolver();
$message = 'The expression in the #[Security] attribute of "' . $originalResolver->toString() . '" is not valid: '
. $e->getMessage() . ' Expression: "' . $expression . '".';

return new self($message, $e->getCode(), $e);
}
}
Loading
Loading