This commit is contained in:
icefox 2026-02-19 10:33:01 -03:00
parent f1d46dacb6
commit 75ce822b84
No known key found for this signature in database
6 changed files with 399 additions and 1 deletions

View file

@ -1,5 +1,4 @@
parameters:
paths:
- src
- tests
level: 5

View file

@ -45,6 +45,7 @@ trait DataObject
*/
public static function fromArray(array $input): ?static
{
$logger = new Log();
$parameters = RuleFactory::getParametersMeta(static::class);
foreach ($parameters as $parameter) {
$parameterName = $parameter->reflection->getName();
@ -66,12 +67,15 @@ trait DataObject
continue;
}
}
$logger->inputRaw($input);
$rules = static::getRules();
$logger->rules($rules);
$validator = static::withValidator($input, $rules);
if ($validator->fails()) {
$logger->validationErrors($validator->errors()->toArray());
return static::fails($validator);
}
@ -94,6 +98,7 @@ trait DataObject
$parameter->reflection,
);
}
$logger->input($mappedInput);
return App::make(static::class, $mappedInput);
}

67
src/Log.php Normal file
View file

@ -0,0 +1,67 @@
<?php
namespace Icefox\DTO;
use Illuminate\Support\Facades\App;
use Illuminate\Validation\Rule;
use Psr\Log\LogLevel;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
readonly class Log
{
public LoggerInterface $logger;
public function __construct()
{
$raw = config('dto.log.logger');
if (is_callable($raw)) {
$this->logger = App::call($raw);
return;
}
if (is_object($raw)) {
$this->logger = $raw;
return;
}
if (is_string($raw) && class_exists($raw)) {
$this->logger = App::make($raw);
return;
}
$this->logger = new NullLogger();
}
/**
* @param array<string,array<int, string|Rule>> $rules
*/
public function rules(array $rules): void
{
$level = config('dto.log.rules') ?? LogLevel::DEBUG;
$this->logger->log($level, print_r($rules, true));
}
/**
* @param array<string,mixed> $input
*/
public function input(array $input): void
{
$level = config('dto.log.input') ?? LogLevel::DEBUG;
$this->logger->log($level, print_r($input, true));
}
/**
* @param array<string, null|int|float|string|array> $input
*/
public function inputRaw(array $input): void
{
$level = config('dto.log.raw_input') ?? LogLevel::DEBUG;
$this->logger->log($level, print_r($input, true));
}
/**
* @param array<string, array<int, string>> $errors
*/
public function validationErrors(array $errors): void
{
$level = config('dto.log.validation_errors') ?? LogLevel::INFO;
$this->logger->log($level, print_r($errors, true));
}
}

View file

@ -2,10 +2,20 @@
use Icefox\DTO\Factories\CollectionFactory;
use Illuminate\Support\Collection;
use Psr\Log\LogLevel;
use Psr\Log\NullLogger;
return [
'cast' => [],
'rules' => [
Collection::class => CollectionFactory::rules(...),
],
'log' => [
'logger' => NullLogger::class,
'internal' => LogLevel::WARNING,
'rules' => LogLevel::DEBUG,
'input' => LogLevel::DEBUG,
'raw_input' => LogLevel::DEBUG,
'validation_errors' => LogLevel::INFO,
],
];

View file

@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Tests\Logging;
use Psr\Log\AbstractLogger;
class CustomLogger extends AbstractLogger
{
public array $logs = [];
public function log($level, string|\Stringable $message, array $context = []): void
{
$this->logs[] = [
'level' => $level,
'message' => $message,
'context' => $context,
];
}
public function hasLog(string $level, string $contains): bool
{
foreach ($this->logs as $log) {
if ($log['level'] === $level && str_contains($log['message'], $contains)) {
return true;
}
}
return false;
}
public function clear(): void
{
$this->logs = [];
}
}

281
tests/Logging/LogTest.php Normal file
View file

@ -0,0 +1,281 @@
<?php
declare(strict_types=1);
namespace Tests\Logging;
use Icefox\DTO\Log;
use Psr\Log\LogLevel;
use Psr\Log\NullLogger;
use Tests\Classes\PrimitiveData;
use Tests\TestCase;
describe('logger resolution', function () {
afterEach(function () {
config()->set('dto.log.logger', NullLogger::class);
});
it('uses NullLogger as fallback when logger config is null', function () {
config()->set('dto.log.logger', null);
$log = new Log();
expect($log->logger)->toBeInstanceOf(NullLogger::class);
});
it('uses NullLogger as fallback when logger config is invalid', function () {
config()->set('dto.log.logger', 'NonExistentLoggerClass');
$log = new Log();
expect($log->logger)->toBeInstanceOf(NullLogger::class);
});
it('instantiates logger from class name via Laravel container', function () {
config()->set('dto.log.logger', CustomLogger::class);
$log = new Log();
expect($log->logger)->toBeInstanceOf(CustomLogger::class);
});
it('uses logger object directly when provided', function () {
$customLogger = new CustomLogger();
config()->set('dto.log.logger', $customLogger);
$log = new Log();
expect($log->logger)->toBe($customLogger);
});
it('invokes callable to get logger instance', function () {
config()->set('dto.log.logger', function () {
return new CustomLogger();
});
$log = new Log();
expect($log->logger)->toBeInstanceOf(CustomLogger::class);
});
});
describe('log level configuration', function () {
beforeEach(function () {
$this->customLogger = new CustomLogger();
config()->set('dto.log.logger', $this->customLogger);
});
afterEach(function () {
config()->set('dto.log.logger', NullLogger::class);
config()->set('dto.log.rules', LogLevel::DEBUG);
config()->set('dto.log.input', LogLevel::DEBUG);
config()->set('dto.log.raw_input', LogLevel::DEBUG);
config()->set('dto.log.validation_errors', LogLevel::INFO);
});
it('logs rules at configured level', function () {
config()->set('dto.log.rules', LogLevel::INFO);
$log = new Log();
$log->rules(['field' => ['required']]);
expect($this->customLogger->hasLog(LogLevel::INFO, 'field'))->toBeTrue();
});
it('logs input at configured level', function () {
config()->set('dto.log.input', LogLevel::INFO);
$log = new Log();
$log->input(['field' => 'value']);
expect($this->customLogger->hasLog(LogLevel::INFO, 'value'))->toBeTrue();
});
it('logs raw input at configured level', function () {
config()->set('dto.log.raw_input', LogLevel::ERROR);
$log = new Log();
$log->inputRaw(['field' => 'raw_value']);
expect($this->customLogger->hasLog(LogLevel::ERROR, 'raw_value'))->toBeTrue();
});
it('logs validation errors at configured level', function () {
config()->set('dto.log.validation_errors', LogLevel::ERROR);
$log = new Log();
$log->validationErrors(['field' => ['The field is required.']]);
expect($this->customLogger->hasLog(LogLevel::ERROR, 'required'))->toBeTrue();
});
it('allows different log levels for each log type', function () {
config()->set('dto.log.rules', LogLevel::DEBUG);
config()->set('dto.log.input', LogLevel::INFO);
config()->set('dto.log.raw_input', LogLevel::INFO);
$log = new Log();
$log->rules(['rules_field' => ['required']]);
$log->input(['input_field' => 'value']);
$log->inputRaw(['raw_field' => 'raw_value']);
expect($this->customLogger->hasLog(LogLevel::DEBUG, 'rules_field'))->toBeTrue();
expect($this->customLogger->hasLog(LogLevel::INFO, 'input_field'))->toBeTrue();
expect($this->customLogger->hasLog(LogLevel::INFO, 'raw_field'))->toBeTrue();
});
it('defaults to DEBUG level when not configured', function () {
config()->set('dto.log.rules', null);
config()->set('dto.log.input', null);
config()->set('dto.log.raw_input', null);
$customLogger = new CustomLogger();
config()->set('dto.log.logger', $customLogger);
$log = new Log();
$log->rules(['field' => ['required']]);
$log->input(['field' => 'value']);
$log->inputRaw(['field' => 'raw_value']);
expect(count($customLogger->logs))->toBe(3);
expect($customLogger->logs[0]['level'])->toBe(LogLevel::DEBUG);
expect($customLogger->logs[1]['level'])->toBe(LogLevel::DEBUG);
expect($customLogger->logs[2]['level'])->toBe(LogLevel::DEBUG);
});
});
describe('integration with DataObject', function () {
beforeEach(function () {
$this->customLogger = new CustomLogger();
config()->set('dto.log.logger', $this->customLogger);
config()->set('dto.log.rules', LogLevel::DEBUG);
config()->set('dto.log.input', LogLevel::DEBUG);
config()->set('dto.log.raw_input', LogLevel::DEBUG);
});
afterEach(function () {
config()->set('dto.log.logger', NullLogger::class);
});
it('logs raw input during fromArray execution', function () {
PrimitiveData::fromArray([
'string' => 'test',
'int' => 42,
'float' => 3.14,
'bool' => true,
]);
expect($this->customLogger->hasLog(LogLevel::DEBUG, 'raw_input'))->toBeFalse();
expect($this->customLogger->hasLog(LogLevel::DEBUG, 'string'))->toBeTrue();
expect($this->customLogger->hasLog(LogLevel::DEBUG, '42'))->toBeTrue();
});
it('logs rules during fromArray execution', function () {
PrimitiveData::fromArray([
'string' => 'test',
'int' => 42,
'float' => 3.14,
'bool' => true,
]);
expect($this->customLogger->hasLog(LogLevel::DEBUG, 'required'))->toBeTrue();
});
it('logs processed input during fromArray execution', function () {
PrimitiveData::fromArray([
'string' => 'test',
'int' => 42,
'float' => 3.14,
'bool' => true,
]);
expect($this->customLogger->hasLog(LogLevel::DEBUG, 'test'))->toBeTrue();
});
it('captures all three log types during successful fromArray', function () {
PrimitiveData::fromArray([
'string' => 'integration_test',
'int' => 123,
'float' => 9.99,
'bool' => false,
]);
$rawInputLogged = false;
$rulesLogged = false;
$inputLogged = false;
foreach ($this->customLogger->logs as $log) {
if (str_contains($log['message'], 'string')) {
$rawInputLogged = true;
}
if (str_contains($log['message'], 'required')) {
$rulesLogged = true;
}
if (str_contains($log['message'], 'integration_test')) {
$inputLogged = true;
}
}
expect($rawInputLogged)->toBeTrue('Raw input should be logged');
expect($rulesLogged)->toBeTrue('Rules should be logged');
expect($inputLogged)->toBeTrue('Processed input should be logged');
});
it('logs even when validation fails', function () {
try {
PrimitiveData::fromArray([
'int' => 42,
'float' => 3.14,
'bool' => true,
]);
} catch (\Illuminate\Validation\ValidationException $e) {
// Expected
}
expect($this->customLogger->hasLog(LogLevel::DEBUG, 'required'))->toBeTrue();
expect($this->customLogger->hasLog(LogLevel::DEBUG, '42'))->toBeTrue();
});
it('logs validation errors when validation fails', function () {
config()->set('dto.log.validation_errors', LogLevel::ERROR);
try {
PrimitiveData::fromArray([
'int' => 42,
'float' => 3.14,
'bool' => true,
]);
} catch (\Illuminate\Validation\ValidationException $e) {
// Expected
}
expect($this->customLogger->hasLog(LogLevel::ERROR, 'string'))->toBeTrue();
expect($this->customLogger->hasLog(LogLevel::ERROR, 'required'))->toBeTrue();
});
});
describe('logging with NullLogger', function () {
it('does not throw when logging with NullLogger', function () {
config()->set('dto.log.logger', NullLogger::class);
$log = new Log();
expect(function () use ($log) {
$log->rules(['field' => ['required']]);
$log->input(['field' => 'value']);
$log->inputRaw(['field' => 'raw_value']);
})->not->toThrow(\Throwable::class);
});
it('does not affect DataObject behavior when using NullLogger', function () {
config()->set('dto.log.logger', NullLogger::class);
$object = PrimitiveData::fromArray([
'string' => 'test',
'int' => 42,
'float' => 3.14,
'bool' => true,
]);
expect($object)->toBeInstanceOf(PrimitiveData::class);
expect($object->string)->toBe('test');
});
});