From bf5fe730a49beae6703f1f5b99bbbe0590ab40ad Mon Sep 17 00:00:00 2001 From: "tien.xuan.vo" Date: Tue, 29 Oct 2024 03:19:17 +0700 Subject: [PATCH] feat: Allow logging to multiple sinks --- src/PhpPact/FFI/Client.php | 65 ++++++ src/PhpPact/FFI/ClientInterface.php | 20 ++ src/PhpPact/Log/Enum/LogLevel.php | 14 ++ src/PhpPact/Log/Exception/LogException.php | 9 + .../Log/Exception/LoggerApplyException.php | 15 ++ .../Exception/LoggerAttachSinkException.php | 20 ++ src/PhpPact/Log/Exception/LoggerException.php | 7 + .../Exception/LoggerUnserializeException.php | 7 + src/PhpPact/Log/Logger.php | 92 +++++++++ src/PhpPact/Log/LoggerInterface.php | 14 ++ src/PhpPact/Log/Model/AbstractSink.php | 17 ++ src/PhpPact/Log/Model/Buffer.php | 11 + src/PhpPact/Log/Model/File.php | 18 ++ src/PhpPact/Log/Model/SinkInterface.php | 12 ++ src/PhpPact/Log/Model/Stderr.php | 11 + src/PhpPact/Log/Model/Stdout.php | 11 + .../Log/PHPUnit/PactLoggingExtension.php | 17 ++ .../Log/PHPUnit/PactLoggingSubscriber.php | 35 ++++ tests/PhpPact/FFI/ClientTest.php | 54 +++++ tests/PhpPact/Log/LoggerTest.php | 189 ++++++++++++++++++ tests/PhpPact/Log/Model/BufferTest.php | 25 +++ tests/PhpPact/Log/Model/FileTest.php | 26 +++ tests/PhpPact/Log/Model/StderrTest.php | 25 +++ tests/PhpPact/Log/Model/StdoutTest.php | 25 +++ .../Log/PHPUnit/PactLoggingSubscriberTest.php | 116 +++++++++++ 25 files changed, 855 insertions(+) create mode 100644 src/PhpPact/Log/Enum/LogLevel.php create mode 100644 src/PhpPact/Log/Exception/LogException.php create mode 100644 src/PhpPact/Log/Exception/LoggerApplyException.php create mode 100644 src/PhpPact/Log/Exception/LoggerAttachSinkException.php create mode 100644 src/PhpPact/Log/Exception/LoggerException.php create mode 100644 src/PhpPact/Log/Exception/LoggerUnserializeException.php create mode 100644 src/PhpPact/Log/Logger.php create mode 100644 src/PhpPact/Log/LoggerInterface.php create mode 100644 src/PhpPact/Log/Model/AbstractSink.php create mode 100644 src/PhpPact/Log/Model/Buffer.php create mode 100644 src/PhpPact/Log/Model/File.php create mode 100644 src/PhpPact/Log/Model/SinkInterface.php create mode 100644 src/PhpPact/Log/Model/Stderr.php create mode 100644 src/PhpPact/Log/Model/Stdout.php create mode 100644 src/PhpPact/Log/PHPUnit/PactLoggingExtension.php create mode 100644 src/PhpPact/Log/PHPUnit/PactLoggingSubscriber.php create mode 100644 tests/PhpPact/Log/LoggerTest.php create mode 100644 tests/PhpPact/Log/Model/BufferTest.php create mode 100644 tests/PhpPact/Log/Model/FileTest.php create mode 100644 tests/PhpPact/Log/Model/StderrTest.php create mode 100644 tests/PhpPact/Log/Model/StdoutTest.php create mode 100644 tests/PhpPact/Log/PHPUnit/PactLoggingSubscriberTest.php diff --git a/src/PhpPact/FFI/Client.php b/src/PhpPact/FFI/Client.php index 6b7c6ec4..6a2de3c3 100644 --- a/src/PhpPact/FFI/Client.php +++ b/src/PhpPact/FFI/Client.php @@ -510,6 +510,41 @@ public function interactionContents(int $interaction, int $part, string $content return $result; } + public function loggerInit(): void + { + $this->call('pactffi_logger_init'); + } + + public function loggerAttachSink(string $sinkSpecifier, int $levelFilter): int + { + $method = 'pactffi_logger_attach_sink'; + $result = $this->call($method, $sinkSpecifier, $levelFilter); + if (!is_int($result)) { + throw new InvalidResultException(sprintf('Invalid result of "%s". Expected "integer", but got "%s"', $method, get_debug_type($result))); + } + return $result; + } + + public function loggerApply(): int + { + $method = 'pactffi_logger_apply'; + $result = $this->call($method); + if (!is_int($result)) { + throw new InvalidResultException(sprintf('Invalid result of "%s". Expected "integer", but got "%s"', $method, get_debug_type($result))); + } + return $result; + } + + public function fetchLogBuffer(?string $logId = null): string + { + $method = 'pactffi_fetch_log_buffer'; + $result = $this->call($method, $logId); + if (!is_string($result)) { + throw new InvalidResultException(sprintf('Invalid result of "%s". Expected "string", but got "%s"', $method, get_debug_type($result))); + } + return $result; + } + public function getInteractionPartRequest(): int { return $this->getEnum('InteractionPart_Request'); @@ -550,6 +585,36 @@ public function getPactSpecificationUnknown(): int return $this->getEnum('PactSpecification_Unknown'); } + public function getLevelFilterTrace(): int + { + return $this->getEnum('LevelFilter_Trace'); + } + + public function getLevelFilterDebug(): int + { + return $this->getEnum('LevelFilter_Debug'); + } + + public function getLevelFilterInfo(): int + { + return $this->getEnum('LevelFilter_Info'); + } + + public function getLevelFilterWarn(): int + { + return $this->getEnum('LevelFilter_Warn'); + } + + public function getLevelFilterError(): int + { + return $this->getEnum('LevelFilter_Error'); + } + + public function getLevelFilterOff(): int + { + return $this->getEnum('LevelFilter_Off'); + } + private function getEnum(string $name): int { $value = $this->get($name); diff --git a/src/PhpPact/FFI/ClientInterface.php b/src/PhpPact/FFI/ClientInterface.php index 909ce607..9babb2ac 100644 --- a/src/PhpPact/FFI/ClientInterface.php +++ b/src/PhpPact/FFI/ClientInterface.php @@ -123,6 +123,14 @@ public function responseStatusV2(int $interaction, ?string $status): bool; public function interactionContents(int $interaction, int $part, string $contentType, string $contents): int; + public function loggerInit(): void; + + public function loggerAttachSink(string $sinkSpecifier, int $levelFilter): int; + + public function loggerApply(): int; + + public function fetchLogBuffer(?string $logId = null): string; + public function getInteractionPartRequest(): int; public function getInteractionPartResponse(): int; @@ -138,4 +146,16 @@ public function getPactSpecificationV3(): int; public function getPactSpecificationV4(): int; public function getPactSpecificationUnknown(): int; + + public function getLevelFilterTrace(): int; + + public function getLevelFilterDebug(): int; + + public function getLevelFilterInfo(): int; + + public function getLevelFilterWarn(): int; + + public function getLevelFilterError(): int; + + public function getLevelFilterOff(): int; } diff --git a/src/PhpPact/Log/Enum/LogLevel.php b/src/PhpPact/Log/Enum/LogLevel.php new file mode 100644 index 00000000..acd4ec39 --- /dev/null +++ b/src/PhpPact/Log/Enum/LogLevel.php @@ -0,0 +1,14 @@ + "Can't set logger (applying the logger failed, perhaps because one is applied already).", + default => 'Unknown error', + }; + parent::__construct($message, $code); + } +} diff --git a/src/PhpPact/Log/Exception/LoggerAttachSinkException.php b/src/PhpPact/Log/Exception/LoggerAttachSinkException.php new file mode 100644 index 00000000..c7349271 --- /dev/null +++ b/src/PhpPact/Log/Exception/LoggerAttachSinkException.php @@ -0,0 +1,20 @@ + "Can't set logger (applying the logger failed, perhaps because one is applied already).", + -2 => 'No logger has been initialized (call `pactffi_logger_init` before any other log function).', + -3 => 'The sink specifier was not UTF-8 encoded.', + -4 => 'The sink type specified is not a known type (known types: "stdout", "stderr", "buffer", or "file /some/path").', + -5 => 'No file path was specified in a file-type sink specification.', + -6 => 'Opening a sink to the specified file path failed (check permissions).', + default => 'Unknown error', + }; + parent::__construct($message, $code); + } +} diff --git a/src/PhpPact/Log/Exception/LoggerException.php b/src/PhpPact/Log/Exception/LoggerException.php new file mode 100644 index 00000000..0f2106f9 --- /dev/null +++ b/src/PhpPact/Log/Exception/LoggerException.php @@ -0,0 +1,7 @@ +client->loggerInit(); + } + + protected function __clone(): void + { + } + + public function __wakeup(): never + { + throw new LoggerUnserializeException('Cannot unserialize a singleton.'); + } + + public static function instance(?ClientInterface $client = null): Logger + { + if (!isset(self::$instance)) { + self::$instance = new self($client ?? new Client()); + } + return self::$instance; + } + + public static function tearDown(): void + { + self::$instance = null; + } + + public function attach(SinkInterface $sink): void + { + if ($this->applied) { + return; + } + $this->sinks[] = $sink; + } + + public function apply(): void + { + if ($this->applied) { + return; + } + foreach ($this->sinks as $sink) { + $error = $this->client->loggerAttachSink($sink->getSpecifier(), $this->getLevelFilter($sink->getLevel())); + if ($error) { + throw new LoggerAttachSinkException($error); + } + } + $error = $this->client->loggerApply(); + if ($error) { + throw new LoggerApplyException($error); + } + $this->applied = true; + } + + public function fetchBuffer(): string + { + return $this->client->fetchLogBuffer(); + } + + private function getLevelFilter(LogLevel $level): int + { + return match ($level) { + LogLevel::TRACE => $this->client->getLevelFilterTrace(), + LogLevel::DEBUG => $this->client->getLevelFilterDebug(), + LogLevel::INFO => $this->client->getLevelFilterInfo(), + LogLevel::WARN => $this->client->getLevelFilterWarn(), + LogLevel::ERROR => $this->client->getLevelFilterError(), + LogLevel::OFF => $this->client->getLevelFilterOff(), + LogLevel::NONE => $this->client->getLevelFilterOff(), + }; + } +} diff --git a/src/PhpPact/Log/LoggerInterface.php b/src/PhpPact/Log/LoggerInterface.php new file mode 100644 index 00000000..1b809c0b --- /dev/null +++ b/src/PhpPact/Log/LoggerInterface.php @@ -0,0 +1,14 @@ +level; + } +} diff --git a/src/PhpPact/Log/Model/Buffer.php b/src/PhpPact/Log/Model/Buffer.php new file mode 100644 index 00000000..14d87724 --- /dev/null +++ b/src/PhpPact/Log/Model/Buffer.php @@ -0,0 +1,11 @@ +path}"; + } +} diff --git a/src/PhpPact/Log/Model/SinkInterface.php b/src/PhpPact/Log/Model/SinkInterface.php new file mode 100644 index 00000000..3e7e279e --- /dev/null +++ b/src/PhpPact/Log/Model/SinkInterface.php @@ -0,0 +1,12 @@ +registerSubscriber(new PactLoggingSubscriber(Logger::instance())); + } +} diff --git a/src/PhpPact/Log/PHPUnit/PactLoggingSubscriber.php b/src/PhpPact/Log/PHPUnit/PactLoggingSubscriber.php new file mode 100644 index 00000000..332c7e95 --- /dev/null +++ b/src/PhpPact/Log/PHPUnit/PactLoggingSubscriber.php @@ -0,0 +1,35 @@ +logger->attach(new File($logFile, LogLevel::from(\strtoupper($logLevel)))); + } + if ($logFile === false && is_string($logLevel)) { + $this->logger->attach(new Stdout(LogLevel::from(\strtoupper($logLevel)))); + } + if (is_string($logFile) && $logLevel === false) { + $this->logger->attach(new File($logFile, LogLevel::INFO)); + } + if (is_string($logFile) || is_string($logLevel)) { + $this->logger->apply(); + } + } +} diff --git a/tests/PhpPact/FFI/ClientTest.php b/tests/PhpPact/FFI/ClientTest.php index 2b664602..b91c3cf3 100644 --- a/tests/PhpPact/FFI/ClientTest.php +++ b/tests/PhpPact/FFI/ClientTest.php @@ -385,6 +385,30 @@ public function testInteractionContents(): void $this->assertSame(5, $result); } + public function testLoggerInit(): void + { + $this->client->loggerInit(); + $this->expectNotToPerformAssertions(); + } + + public function testLoggerAttachSink(): void + { + $result = $this->client->loggerAttachSink('stdout', 0); + $this->assertSame(0, $result); + } + + public function testLoggerApply(): void + { + $result = $this->client->loggerApply(); + $this->assertSame(-1, $result); + } + + public function testFetchLogBuffer(): void + { + $result = $this->client->fetchLogBuffer(); + $this->assertSame('', $result); + } + public function testGetInteractionPartRequest(): void { $this->assertSame(0, $this->client->getInteractionPartRequest()); @@ -424,4 +448,34 @@ public function testGetPactSpecificationUnknown(): void { $this->assertSame(0, $this->client->getPactSpecificationUnknown()); } + + public function testGetLevelFilterTrace(): void + { + $this->assertSame(5, $this->client->getLevelFilterTrace()); + } + + public function testGetLevelFilterDebug(): void + { + $this->assertSame(4, $this->client->getLevelFilterDebug()); + } + + public function testGetLevelFilterInfo(): void + { + $this->assertSame(3, $this->client->getLevelFilterInfo()); + } + + public function testGetLevelFilterWarn(): void + { + $this->assertSame(2, $this->client->getLevelFilterWarn()); + } + + public function testGetLevelFilterError(): void + { + $this->assertSame(1, $this->client->getLevelFilterError()); + } + + public function testGetLevelFilterOff(): void + { + $this->assertSame(0, $this->client->getLevelFilterOff()); + } } diff --git a/tests/PhpPact/Log/LoggerTest.php b/tests/PhpPact/Log/LoggerTest.php new file mode 100644 index 00000000..7c55bd5c --- /dev/null +++ b/tests/PhpPact/Log/LoggerTest.php @@ -0,0 +1,189 @@ +client = $this->createMock(ClientInterface::class); + $this->client + ->expects($this->any()) + ->method('getLevelFilterTrace') + ->willReturn(5); + $this->client + ->expects($this->any()) + ->method('getLevelFilterDebug') + ->willReturn(4); + $this->client + ->expects($this->any()) + ->method('getLevelFilterInfo') + ->willReturn(3); + $this->client + ->expects($this->any()) + ->method('getLevelFilterWarn') + ->willReturn(2); + $this->client + ->expects($this->any()) + ->method('getLevelFilterError') + ->willReturn(1); + $this->client + ->expects($this->any()) + ->method('getLevelFilterOff') + ->willReturn(0); + $this->logger = Logger::instance($this->client); + } + + public function tearDown(): void + { + Logger::tearDown(); + } + + public function testSameInstance(): void + { + $this->assertSame(Logger::instance($this->client), $this->logger); + } + + public function testClone(): void + { + $this->expectException(Error::class); + $this->expectExceptionMessage('Call to protected PhpPact\Log\Logger::__clone()'); + clone $this->logger; + } + + public function testSerialize(): void + { + $this->expectException(LoggerUnserializeException::class); + $this->expectExceptionMessage('Cannot unserialize a singleton.'); + $result = serialize($this->logger); + unserialize($result); + } + + public function testApply(): void + { + $this->logger->attach(new File('/path/to/file', LogLevel::DEBUG)); + $this->logger->attach(new Buffer(LogLevel::ERROR)); + $this->logger->attach(new Stdout(LogLevel::WARN)); + $this->logger->attach(new Stderr(LogLevel::INFO)); + $calls = [ + [ + 'args' => ['file /path/to/file', 4], + 'return' => 0 + ], + [ + 'args' => ['buffer', 1], + 'return' => 0 + ], + [ + 'args' => ['stdout', 2], + 'return' => 0 + ], + [ + 'args' => ['stderr', 3], + 'return' => 0 + ] + ]; + $this->client + ->expects($this->exactly(4)) + ->method('loggerAttachSink') + ->willReturnCallback(function (...$args) use (&$calls) { + $call = array_shift($calls); + $this->assertSame($call['args'], $args); + + return $call['return']; + }); + $this->client + ->expects($this->once()) + ->method('loggerApply') + ->willReturn(0); + $this->logger->apply(); + $this->logger->attach(new Stderr(LogLevel::TRACE)); + $this->logger->apply(); + $this->logger->apply(); + } + + #[TestWith([0])] + #[TestWith([-1])] + #[TestWith([-2])] + #[TestWith([-3])] + #[TestWith([-4])] + #[TestWith([-5])] + #[TestWith([-6])] + #[TestWith([-7])] + public function testAttachSinkReturnError(int $error): void + { + $this->logger->attach(new Stderr(LogLevel::INFO)); + $this->client + ->expects($this->once()) + ->method('loggerAttachSink') + ->with('stderr', 3) + ->willReturn($error); + if ($error) { + $this->expectException(LoggerAttachSinkException::class); + $this->expectExceptionMessage(match ($error) { + -1 => "Can't set logger (applying the logger failed, perhaps because one is applied already).", + -2 => 'No logger has been initialized (call `pactffi_logger_init` before any other log function).', + -3 => 'The sink specifier was not UTF-8 encoded.', + -4 => 'The sink type specified is not a known type (known types: "stdout", "stderr", "buffer", or "file /some/path").', + -5 => 'No file path was specified in a file-type sink specification.', + -6 => 'Opening a sink to the specified file path failed (check permissions).', + default => 'Unknown error', + }); + } + $this->logger->apply(); + } + + #[TestWith([0])] + #[TestWith([-1])] + #[TestWith([-2])] + public function testApplyReturnError(int $error): void + { + $this->logger->attach(new Stderr(LogLevel::OFF)); + $this->client + ->expects($this->once()) + ->method('loggerAttachSink') + ->with('stderr', 0) + ->willReturn(0); + $this->client + ->expects($this->once()) + ->method('loggerApply') + ->willReturn($error); + if ($error) { + $this->expectException(LoggerApplyException::class); + $this->expectExceptionMessage(match ($error) { + -1 => "Can't set logger (applying the logger failed, perhaps because one is applied already).", + default => 'Unknown error', + }); + } + $this->logger->apply(); + } + + public function testFetchBuffer(): void + { + $log = 'log from pact'; + $this->client + ->expects($this->once()) + ->method('fetchLogBuffer') + ->willReturn($log); + $this->assertSame($log, $this->logger->fetchBuffer()); + } +} diff --git a/tests/PhpPact/Log/Model/BufferTest.php b/tests/PhpPact/Log/Model/BufferTest.php new file mode 100644 index 00000000..c09eac18 --- /dev/null +++ b/tests/PhpPact/Log/Model/BufferTest.php @@ -0,0 +1,25 @@ +assertSame('buffer', $sink->getSpecifier()); + $this->assertSame($logLevel, $sink->getLevel()); + } +} diff --git a/tests/PhpPact/Log/Model/FileTest.php b/tests/PhpPact/Log/Model/FileTest.php new file mode 100644 index 00000000..69568f2e --- /dev/null +++ b/tests/PhpPact/Log/Model/FileTest.php @@ -0,0 +1,26 @@ +assertSame("file {$path}", $sink->getSpecifier()); + $this->assertSame($logLevel, $sink->getLevel()); + } +} diff --git a/tests/PhpPact/Log/Model/StderrTest.php b/tests/PhpPact/Log/Model/StderrTest.php new file mode 100644 index 00000000..631f0178 --- /dev/null +++ b/tests/PhpPact/Log/Model/StderrTest.php @@ -0,0 +1,25 @@ +assertSame('stderr', $sink->getSpecifier()); + $this->assertSame($logLevel, $sink->getLevel()); + } +} diff --git a/tests/PhpPact/Log/Model/StdoutTest.php b/tests/PhpPact/Log/Model/StdoutTest.php new file mode 100644 index 00000000..4f76394c --- /dev/null +++ b/tests/PhpPact/Log/Model/StdoutTest.php @@ -0,0 +1,25 @@ +assertSame('stdout', $sink->getSpecifier()); + $this->assertSame($logLevel, $sink->getLevel()); + } +} diff --git a/tests/PhpPact/Log/PHPUnit/PactLoggingSubscriberTest.php b/tests/PhpPact/Log/PHPUnit/PactLoggingSubscriberTest.php new file mode 100644 index 00000000..3ff334a9 --- /dev/null +++ b/tests/PhpPact/Log/PHPUnit/PactLoggingSubscriberTest.php @@ -0,0 +1,116 @@ +logger = $this->createMock(LoggerInterface::class); + $this->subscriber = new PactLoggingSubscriber($this->logger); + $this->event = new Started( + new Info( + new Snapshot(HRTime::fromSecondsAndNanoseconds(0, 0), MemoryUsage::fromBytes(0), MemoryUsage::fromBytes(0), new GarbageCollectorStatus(0, 0, 0, 0, null, null, null, null, null, null, null, null)), + Duration::fromSecondsAndNanoseconds(0, 0), + MemoryUsage::fromBytes(0), + Duration::fromSecondsAndNanoseconds(0, 0), + MemoryUsage::fromBytes(0) + ), + new Runtime() + ); + } + + public function testDoNotLog(): void + { + putenv('PACT_LOG'); + putenv('PACT_LOGLEVEL'); + $this->logger + ->expects($this->never()) + ->method('attach'); + $this->logger + ->expects($this->never()) + ->method('apply'); + $this->subscriber->notify($this->event); + } + + public function testLogToFile(): void + { + putenv('PACT_LOG=./log/pact.txt'); + putenv('PACT_LOGLEVEL=trace'); + $this->logger + ->expects($this->once()) + ->method('attach') + ->with($this->callback(function (SinkInterface $sink) { + $this->assertInstanceOf(File::class, $sink); + $this->assertSame('file ./log/pact.txt', $sink->getSpecifier()); + $this->assertSame(LogLevel::TRACE, $sink->getLevel()); + + return true; + })); + $this->logger + ->expects($this->once()) + ->method('apply'); + $this->subscriber->notify($this->event); + } + + public function testLogToFileWithDefaultLevel(): void + { + putenv('PACT_LOG=./log/pact.txt'); + putenv('PACT_LOGLEVEL'); + $this->logger + ->expects($this->once()) + ->method('attach') + ->with($this->callback(function (SinkInterface $sink) { + $this->assertInstanceOf(File::class, $sink); + $this->assertSame('file ./log/pact.txt', $sink->getSpecifier()); + $this->assertSame(LogLevel::INFO, $sink->getLevel()); + + return true; + })); + $this->logger + ->expects($this->once()) + ->method('apply'); + $this->subscriber->notify($this->event); + } + + public function testLogToStandardOutput(): void + { + putenv('PACT_LOG'); + putenv('PACT_LOGLEVEL=debug'); + $this->logger + ->expects($this->once()) + ->method('attach') + ->with($this->callback(function (SinkInterface $sink) { + $this->assertInstanceOf(Stdout::class, $sink); + $this->assertSame('stdout', $sink->getSpecifier()); + $this->assertSame(LogLevel::DEBUG, $sink->getLevel()); + + return true; + })); + $this->logger + ->expects($this->once()) + ->method('apply'); + $this->subscriber->notify($this->event); + } +}