Skip to content
Closed
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
73 changes: 73 additions & 0 deletions src/Core/Exceptions/Forbidden.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
<?php

namespace Neuron\Core\Exceptions;

use Exception;
use Throwable;

/**
* Exception thrown when an authenticated user lacks permission to access a resource.
*
* This exception should be thrown when a user is authenticated but does not have
* the necessary permissions to access a resource. It corresponds to HTTP 403 Forbidden
* status code.
*
* @package Neuron\Core\Exceptions
*
* @example
* ```php
* // Throw when user lacks specific permission
* if (!$user->hasPermission('admin.edit')) {
* throw new Forbidden('You do not have permission to edit', 'User', 'admin.edit');
* }
*
* // Throw when accessing forbidden resource
* if (!$this->canAccessResource($user, $document)) {
* throw new Forbidden('Access denied to document', 'Document #' . $document->id);
* }
* ```
*/
class Forbidden extends Exception
{
private ?string $resource;
private ?string $permission;

/**
* @param string $message The error message
* @param string|null $resource The resource that was forbidden (e.g., 'User Profile', 'Document #123')
* @param string|null $permission The permission that was lacking (e.g., 'admin.edit', 'document.read')
* @param int $code The exception code (defaults to 403)
* @param Throwable|null $previous Previous exception for chaining
*/
public function __construct(
string $message = 'Access forbidden',
?string $resource = null,
?string $permission = null,
int $code = 403,
?Throwable $previous = null
) {
$this->resource = $resource;
$this->permission = $permission;
parent::__construct( $message, $code, $previous );
}

/**
* Get the resource that was forbidden
*
* @return string|null
*/
public function getResource(): ?string
{
return $this->resource;
}

/**
* Get the permission that was lacking
*
* @return string|null
*/
public function getPermission(): ?string
{
return $this->permission;
}
}
58 changes: 58 additions & 0 deletions src/Core/Exceptions/Unauthorized.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<?php

namespace Neuron\Core\Exceptions;

use Exception;
use Throwable;

/**
* Exception thrown when authentication is required but not provided or invalid.
*
* This exception should be thrown when a user needs to authenticate to access
* a resource. It corresponds to HTTP 401 Unauthorized status code.
*
* @package Neuron\Core\Exceptions
*
* @example
* ```php
* // Throw when no authentication is provided
* if (!$request->hasAuthToken()) {
* throw new Unauthorized('Authentication required');
* }
*
* // Throw with realm for HTTP Basic Auth
* if (!$this->isValidCredentials($credentials)) {
* throw new Unauthorized('Invalid credentials', 'Admin Area');
* }
* ```
*/
class Unauthorized extends Exception
{
private ?string $realm;

/**
* @param string $message The error message
* @param string|null $realm Optional authentication realm (e.g., for WWW-Authenticate header)
* @param int $code The exception code (defaults to 401)
* @param Throwable|null $previous Previous exception for chaining
*/
public function __construct(
string $message = 'Authentication required',
?string $realm = null,
int $code = 401,
?Throwable $previous = null
) {
$this->realm = $realm;
parent::__construct( $message, $code, $previous );
}

/**
* Get the authentication realm if specified
*
* @return string|null
*/
public function getRealm(): ?string
{
return $this->realm;
}
}
239 changes: 239 additions & 0 deletions src/Core/ProblemDetails/ProblemDetails.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
<?php

namespace Neuron\Core\ProblemDetails;

use JsonSerializable;

/**
* RFC 9457 Problem Details for HTTP APIs implementation.
*
* This class represents a problem detail as defined in RFC 9457, providing
* a standardized format for API error responses. It enables consistent,
* machine-readable error information across all API endpoints.
*
* RFC 9457 defines a standard format for error responses in HTTP APIs,
* replacing the older RFC 7807. The format provides both required and
* optional fields to convey error information in a structured way.
*
* Required fields:
* - type: A URI reference that identifies the problem type
* - title: A short, human-readable summary of the problem type
* - status: The HTTP status code for this occurrence of the problem
*
* Optional fields:
* - detail: A human-readable explanation specific to this occurrence
* - instance: A URI reference that identifies the specific occurrence
*
* Extension fields:
* - Any additional fields can be included to provide more context
*
* @package Neuron\Core\ProblemDetails
*
* @example
* ```php
* // Create a validation error problem detail
* $problem = new ProblemDetails(
* type: '/errors/validation',
* title: 'Validation Failed',
* status: 400,
* detail: 'The request contains invalid fields',
* extensions: [
* 'errors' => [
* 'email' => 'Invalid email format',
* 'password' => 'Must be at least 8 characters'
* ]
* ]
* );
*
* // Convert to JSON for API response
* $json = json_encode($problem);
* ```
*/
class ProblemDetails implements JsonSerializable
{
private string $type;
private string $title;
private int $status;
private ?string $detail;
private ?string $instance;
private array $extensions;

/**
* Create a new Problem Details instance.
*
* @param string $type URI reference identifying the problem type
* @param string $title Short, human-readable summary of the problem
* @param int $status HTTP status code for this occurrence
* @param string|null $detail Human-readable explanation specific to this occurrence
* @param string|null $instance URI reference identifying the specific occurrence
* @param array $extensions Additional fields to include in the response
*/
public function __construct(
string $type,
string $title,
int $status,
?string $detail = null,
?string $instance = null,
array $extensions = []
) {
$this->type = $type;
$this->title = $title;
$this->status = $status;
$this->detail = $detail;
$this->instance = $instance;
$this->extensions = $extensions;

$this->validate();
}

/**
* Validate the problem details fields.
*
* @throws \InvalidArgumentException If required fields are invalid
*/
private function validate(): void
{
if (empty($this->type)) {
throw new \InvalidArgumentException('Problem type cannot be empty');
}

if (empty($this->title)) {
throw new \InvalidArgumentException('Problem title cannot be empty');
}

if ($this->status < 400 || $this->status > 599) {
throw new \InvalidArgumentException('Problem status must be a 4xx or 5xx HTTP status code');
}

// Validate extension field names (must be valid for JSON and XML)
foreach (array_keys($this->extensions) as $key) {
if (!preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*$/', $key)) {
throw new \InvalidArgumentException("Invalid extension field name: $key");
}

// Reserved field names cannot be used as extensions
if (in_array($key, ['type', 'title', 'status', 'detail', 'instance'])) {
throw new \InvalidArgumentException("Reserved field name cannot be used as extension: $key");
}
}
}

/**
* Get the problem type URI.
*/
public function getType(): string
{
return $this->type;
}

/**
* Get the problem title.
*/
public function getTitle(): string
{
return $this->title;
}

/**
* Get the HTTP status code.
*/
public function getStatus(): int
{
return $this->status;
}

/**
* Get the problem detail.
*/
public function getDetail(): ?string
{
return $this->detail;
}

/**
* Get the problem instance URI.
*/
public function getInstance(): ?string
{
return $this->instance;
}

/**
* Get extension fields.
*/
public function getExtensions(): array
{
return $this->extensions;
}

/**
* Get a specific extension field value.
*
* @param string $key The extension field name
* @return mixed|null The field value or null if not set
*/
public function getExtension(string $key): mixed
{
return $this->extensions[$key] ?? null;
}

/**
* Convert to array representation.
*
* @return array The problem details as an associative array
*/
public function toArray(): array
{
$data = [
'type' => $this->type,
'title' => $this->title,
'status' => $this->status,
];

if ($this->detail !== null) {
$data['detail'] = $this->detail;
}

if ($this->instance !== null) {
$data['instance'] = $this->instance;
}

// Add extension fields
foreach ($this->extensions as $key => $value) {
$data[$key] = $value;
}

return $data;
}

/**
* Specify data which should be serialized to JSON.
*
* @return array Data to be JSON encoded
*/
public function jsonSerialize(): array
{
return $this->toArray();
}

/**
* Create a ProblemDetails instance from an array.
*
* @param array $data The problem details data
* @return self
*/
public static function fromArray(array $data): self
{
$type = $data['type'] ?? '';
$title = $data['title'] ?? '';
$status = $data['status'] ?? 500;
$detail = $data['detail'] ?? null;
$instance = $data['instance'] ?? null;

// Extract extensions (any fields not in the standard set)
$standardFields = ['type', 'title', 'status', 'detail', 'instance'];
$extensions = array_diff_key($data, array_flip($standardFields));

return new self($type, $title, $status, $detail, $instance, $extensions);
}
}
Loading