diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..adb881e --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +# phpstorm project files +.idea + +# netbeans project files +nbproject/* + +# zend studio for eclipse project files +.buildpath +.project +.settings + +# windows thumbnail cache +Thumbs.db + +# Mac DS_Store Files +.DS_Store + +/vendor/ +composer.lock +/.phpunit.result.cache \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3a7664a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +FROM php:8.0-cli-alpine + +RUN apk update && \ + apk add --no-cache \ + libzip-dev \ + git \ + openssl-dev && \ + docker-php-ext-install -j$(nproc) \ + zip + +RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/bin/ --filename=composer + +ENV PATH /var/app/bin:/var/app/vendor/bin:$PATH + +WORKDIR /var/app \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..7961a2a --- /dev/null +++ b/Makefile @@ -0,0 +1,24 @@ +UID=$(shell id -u) +GID=$(shell id -g) +CONTAINER=php + +start: erase cache-folders build composer-install bash + +erase: + docker-compose down -v + +build: + docker-compose build && \ + docker-compose pull + +cache-folders: + mkdir -p ~/.composer && chown ${UID}:${GID} ~/.composer + +composer-install: + docker-compose run --rm -u ${UID}:${GID} ${CONTAINER} composer install + +bash: + docker-compose run --rm -u ${UID}:${GID} ${CONTAINER} sh + +phpunit: ## execute project unit tests + docker-compose run --rm -u ${UID}:${GID} ${CONTAINER} phpunit --no-coverage \ No newline at end of file diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..15b42d3 --- /dev/null +++ b/composer.json @@ -0,0 +1,63 @@ +{ + "name": "colvin/message-logger-php", + "description": "Processors for define common structure", + "authors": [ + { + "name": "Aaron Bernabeu Rodríguez", + "email": "aaron.bernabeu@thecolvinco.com" + }, + { + "name": "Alejandro Mascort Colomer", + "email": "alejandro.mascort@thecolvinco.com" + }, + { + "name": "Alejandro García Sánchez", + "email": "alejandro.garcia@thecolvinco.com" + }, + { + "name": "Diego García", + "email": "diego@thecolvinco.com" + }, + { + "name": "Miquel Mariño Espinosa", + "email": "miquel.marino@thecolvinco.com" + }, + { + "name": "Juan Cama Villafan", + "email": "juan.cama@thecolvinco.com" + }, + { + "name": "Victor del Valle", + "email": "victor.delvalle@thecolvinco.com" + } + ], + "license": "MIT", + "type": "library", + "autoload": { + "psr-4": { + "Colvin\\MessageLogger\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Colvin\\MessageLogger\\Tests\\": "tests/" + } + }, + "require": { + "php": "^8.0", + "ext-json": "*", + "symfony/dependency-injection": "^6.0", + "colvin/common-domain-php": "^0.1.1" + }, + "require-dev": { + "phpro/grumphp": "^1.5", + "phpunit/phpunit": "^9.5" + }, + "scripts": { + "post-install-cmd": [ + "rm -rf .git/hooks", + "mkdir -p .git/hooks", + "cp -r ./config/hooks/* .git/hooks" + ] + } +} diff --git a/config/hooks/pre-commit b/config/hooks/pre-commit new file mode 100644 index 0000000..48221d9 --- /dev/null +++ b/config/hooks/pre-commit @@ -0,0 +1,6 @@ +#!/bin/sh + +SCRIPT=$(docker-compose run --no-deps --rm php sh -c "grumphp git:pre-commit" 2>&1) +STATUS=$? +echo "$SCRIPT" +exit $STATUS \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..7dc28c6 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,8 @@ +version: '3.8' + +services: + php: + build: . + volumes: + - .:/var/app + - ~/.composer:/.composer \ No newline at end of file diff --git a/grumphp.yml b/grumphp.yml new file mode 100644 index 0000000..226e4d0 --- /dev/null +++ b/grumphp.yml @@ -0,0 +1,7 @@ +grumphp: + tasks: + composer: + strict: true + jsonlint: ~ + phplint: ~ + phpunit: ~ \ No newline at end of file diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..bc7728f --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,17 @@ + + + + + + + tests + + + \ No newline at end of file diff --git a/src/DependencyInjection/MessageLoggerPass.php b/src/DependencyInjection/MessageLoggerPass.php new file mode 100644 index 0000000..75c9851 --- /dev/null +++ b/src/DependencyInjection/MessageLoggerPass.php @@ -0,0 +1,46 @@ +addDefinitions( + [ + self::PROCESSOR_OCCURRED_ON => new Definition( + OccurredOnProcessor::class + ), + self::PROCESSOR_HOSTNAME => new Definition( + HostnameProcessor::class + ), + self::PROCESSOR_MESSAGE_DATA => new Definition( + MessageDataProcessor::class + ), + self::PROCESSOR_NORMALIZE_CONTEXT => new Definition( + NormalizeContextProcessor::class + ), + self::PROCESSOR_EXCEPTION => new Definition( + ExceptionProcessor::class + ), + ] + ); + } +} diff --git a/src/MessageLoggerProcessor.php b/src/MessageLoggerProcessor.php new file mode 100644 index 0000000..25904eb --- /dev/null +++ b/src/MessageLoggerProcessor.php @@ -0,0 +1,48 @@ +processors = $processors; + } + + public function __invoke(array $record): array + { + if (false === $this->isMessageRecord($record)) { + return $record; + } + + return \array_reduce($this->processors, static fn ($carry, callable $arg) => $arg($carry), $record); + } + + private function isMessageRecord(array $record): bool + { + if (false === \array_key_exists('context', $record)) { + return false; + } + + $context = $record['context']; + + if (false === \array_key_exists('message', $context) + || false === $context['message'] instanceof Message) { + return false; + } + + if (false === \array_key_exists('name', $context)) { + return false; + } + + return true; + } +} diff --git a/src/Processors/Domain/ExceptionProcessor.php b/src/Processors/Domain/ExceptionProcessor.php new file mode 100644 index 0000000..48497e4 --- /dev/null +++ b/src/Processors/Domain/ExceptionProcessor.php @@ -0,0 +1,34 @@ +messageData($record); + + return $this->aggregateData($record); + } + + private function messageData(array $record): array + { + $message = $this->getMessage($record); + + $record['extra']['messageId'] = $message->messageId()->value(); + $record['extra']['name'] = $message::messageName(); + $record['extra']['type'] = $message::messageType(); + $record['extra']['payload'] = \json_encode($message->messagePayload(), \JSON_THROW_ON_ERROR); + + return $this->explodeAsyncApi($record, $message::messageName()); + } + + private function explodeAsyncApi(array $record, string $asyncApiName): array + { + $explodedName = \explode('.', $asyncApiName); + + $record['extra']['asyncapi']['organization'] = $explodedName[0] ?? ''; + $record['extra']['asyncapi']['service'] = $explodedName[1] ?? ''; + $record['extra']['asyncapi']['version'] = $explodedName[2] ?? ''; + $record['extra']['asyncapi']['type'] = $explodedName[3] ?? ''; + $record['extra']['asyncapi']['resource'] = $explodedName[4] ?? ''; + $record['extra']['asyncapi']['name'] = $explodedName[5] ?? ''; + + return $record; + } + + private function aggregateData(array $record): array + { + $message = $this->getMessage($record); + + if (false === $message instanceof AggregateMessage) { + return $record; + } + + $record['extra']['aggregateId'] = $message->aggregateId()->value(); + $record['extra']['occurredOn'] = $message->occurredOn()->format(\DateTimeInterface::ATOM); + + return $record; + } + + private function getMessage(array $record): Message + { + return $record['context']['message']; + } +} diff --git a/src/Processors/Domain/NormalizeContextProcessor.php b/src/Processors/Domain/NormalizeContextProcessor.php new file mode 100644 index 0000000..24a28c6 --- /dev/null +++ b/src/Processors/Domain/NormalizeContextProcessor.php @@ -0,0 +1,19 @@ +occurredOn()->getTimestamp(), + $message->occurredOn()->format('v') + ); + $record['occurredOn'] = (int) $occurredOn; + + return $record; + } + + $record['occurredOn'] = (int) \round(\microtime(true) * 1000); + + return $record; + } +} diff --git a/src/Processors/Infrastructure/HostnameProcessor.php b/src/Processors/Infrastructure/HostnameProcessor.php new file mode 100644 index 0000000..67339e7 --- /dev/null +++ b/src/Processors/Infrastructure/HostnameProcessor.php @@ -0,0 +1,31 @@ +host = \gethostname(); + } + + public function __invoke(array $record): array + { + $message = $record['context']['message']; + + if (false === $message instanceof Message) { + return $record; + } + + $record['extra']['hostname'] = $this->host; + + return $record; + } +} diff --git a/src/Processors/MessageProcessor.php b/src/Processors/MessageProcessor.php new file mode 100644 index 0000000..5a2b503 --- /dev/null +++ b/src/Processors/MessageProcessor.php @@ -0,0 +1,10 @@ + self::throwable($element), + \is_array($element) => \array_map( + static function ($item) { + return self::from($item); + }, + $element + ), + $element instanceof \JsonSerializable, \is_object($element) => self::basic($element), + default => ['value' => $element], + }; + } + + private static function throwable(\Throwable $throwable): array + { + return [ + 'class' => \get_class($throwable), + 'message' => $throwable->getMessage(), + 'code' => $throwable->getCode(), + 'file' => $throwable->getFile(), + 'line' => $throwable->getLine(), + 'trace' => $throwable->getTraceAsString(), + ]; + } + + private static function basic($anything): array + { + return \json_decode( + \json_encode( + $anything, + \JSON_THROW_ON_ERROR + ), + true, + 512, + \JSON_THROW_ON_ERROR + ); + } +} diff --git a/tests/DependencyInjection/MessageLoggerPassTest.php b/tests/DependencyInjection/MessageLoggerPassTest.php new file mode 100644 index 0000000..67f3259 --- /dev/null +++ b/tests/DependencyInjection/MessageLoggerPassTest.php @@ -0,0 +1,59 @@ +messageLoggerPass = new MessageLoggerPass(); + } + + public function testProcess(): void + { + $container = new ContainerBuilder(); + + $this->messageLoggerPass->process($container); + + self::assertTrue($container->hasDefinition(MessageLoggerPass::PROCESSOR_OCCURRED_ON)); + self::assertEquals( + OccurredOnProcessor::class, + $container->getDefinition(MessageLoggerPass::PROCESSOR_OCCURRED_ON)->getClass() + ); + + self::assertTrue($container->hasDefinition(MessageLoggerPass::PROCESSOR_HOSTNAME)); + self::assertEquals( + HostnameProcessor::class, + $container->getDefinition(MessageLoggerPass::PROCESSOR_HOSTNAME)->getClass() + ); + + self::assertTrue($container->hasDefinition(MessageLoggerPass::PROCESSOR_MESSAGE_DATA)); + self::assertEquals( + MessageDataProcessor::class, + $container->getDefinition(MessageLoggerPass::PROCESSOR_MESSAGE_DATA)->getClass() + ); + + self::assertTrue($container->hasDefinition(MessageLoggerPass::PROCESSOR_NORMALIZE_CONTEXT)); + self::assertEquals( + NormalizeContextProcessor::class, + $container->getDefinition(MessageLoggerPass::PROCESSOR_NORMALIZE_CONTEXT)->getClass() + ); + + self::assertTrue($container->hasDefinition(MessageLoggerPass::PROCESSOR_EXCEPTION)); + self::assertEquals( + ExceptionProcessor::class, + $container->getDefinition(MessageLoggerPass::PROCESSOR_EXCEPTION)->getClass() + ); + } +} diff --git a/tests/Processors/AbstractProcessorTest.php b/tests/Processors/AbstractProcessorTest.php new file mode 100644 index 0000000..a150e44 --- /dev/null +++ b/tests/Processors/AbstractProcessorTest.php @@ -0,0 +1,17 @@ + [ + 'message' => $message, + ], + ]; + } +} \ No newline at end of file diff --git a/tests/Processors/Domain/CommandStub.php b/tests/Processors/Domain/CommandStub.php new file mode 100644 index 0000000..dc49898 --- /dev/null +++ b/tests/Processors/Domain/CommandStub.php @@ -0,0 +1,32 @@ +processor = new ExceptionProcessor(); + } + + public function testProcessException(): void + { + $value = 'c5b257aa-3050-4d38-aecc-1eae31a1032a'; + $exception = new LogicExceptionStub(Uuid::from($value)); + + $record = [ + 'context' => [ + 'exception' => $exception, + ], + ]; + + $recordReturned = $this->processor->__invoke($record); + $exceptionRecord = $recordReturned['context']['exception']; + self::assertArrayHasKey('class', $exceptionRecord); + self::assertArrayHasKey('message', $exceptionRecord); + self::assertArrayHasKey('code', $exceptionRecord); + self::assertArrayHasKey('file', $exceptionRecord); + self::assertArrayHasKey('line', $exceptionRecord); + self::assertArrayHasKey('trace', $exceptionRecord); + self::assertArrayHasKey('data', $exceptionRecord); + self::assertSame(\json_encode(['id' => $value], JSON_THROW_ON_ERROR), $exceptionRecord['data']); + } +} diff --git a/tests/Processors/Domain/LogicExceptionStub.php b/tests/Processors/Domain/LogicExceptionStub.php new file mode 100644 index 0000000..0ed8a53 --- /dev/null +++ b/tests/Processors/Domain/LogicExceptionStub.php @@ -0,0 +1,22 @@ + $this->example->value(), + ]; + } +} \ No newline at end of file diff --git a/tests/Processors/Domain/MessageDataProcessorTest.php b/tests/Processors/Domain/MessageDataProcessorTest.php new file mode 100644 index 0000000..780fd63 --- /dev/null +++ b/tests/Processors/Domain/MessageDataProcessorTest.php @@ -0,0 +1,67 @@ +processor = new MessageDataProcessor(); + } + + public function testProcessDomainEvent(): void + { + $message = DomainEventStub::create( + ['foo' => 'baz'], + ); + + $recordReturned = $this->processor->__invoke($this->getRecord($message)); + $this->assertMessageRecord($message, $recordReturned); + self::assertSame($message->aggregateId()->value(), $recordReturned['extra']['aggregateId'] ?? null); + self::assertSame( + $message->occurredOn()->format(\DateTimeInterface::ATOM), + $recordReturned['extra']['occurredOn'] ?? null + ); + } + + public function testProcessCommand() + { + $message = CommandStub::create( + ['foo' => 'baz'], + ); + $recordReturned = $this->processor->__invoke($this->getRecord($message)); + $this->assertMessageRecord($message, $recordReturned); + self::assertArrayNotHasKey('aggregateId', $recordReturned['extra']); + self::assertArrayNotHasKey('occurredOn', $recordReturned['extra']); + } + + private function assertMessageRecord(Message $message, array $recordReturned) + { + self::assertArrayHasKey('extra', $recordReturned); + $extra = $recordReturned['extra']; + self::assertSame($message::messageName(), $extra['name'] ?? null); + self::assertSame($message::messageType(), $extra['type'] ?? null); + self::assertSame( + \json_encode( + $message->messagePayload(), + JSON_THROW_ON_ERROR + ), + $extra['payload'] ?? null + ); + + $asyncApi = \explode('.', $message::messageName()); + self::assertSame($asyncApi[0], $extra['asyncapi']['organization']); + self::assertSame($asyncApi[1], $extra['asyncapi']['service']); + self::assertSame($asyncApi[2], $extra['asyncapi']['version']); + self::assertSame($asyncApi[3], $extra['asyncapi']['type']); + self::assertSame($asyncApi[4], $extra['asyncapi']['resource']); + self::assertSame($asyncApi[5], $extra['asyncapi']['name']); + } +} diff --git a/tests/Processors/Domain/NormalizeContextProcessorTest.php b/tests/Processors/Domain/NormalizeContextProcessorTest.php new file mode 100644 index 0000000..d315b15 --- /dev/null +++ b/tests/Processors/Domain/NormalizeContextProcessorTest.php @@ -0,0 +1,37 @@ +processor = new NormalizeContextProcessor(); + } + + public function testProcess(): void + { + $message = CommandStub::create(); + $recordReturned = $this->processor->__invoke($this->getRecord($message)); + + self::assertEquals( + $recordReturned['context']['message'], + \json_encode( + [ + 'messageId' => $message->messageId()->value(), + 'name' => $message::messageName(), + 'version' => $message::messageVersion(), + 'type' => $message::messageType(), + 'payload' => $message->messagePayload(), + ], + JSON_THROW_ON_ERROR + ) + ); + } +} diff --git a/tests/Processors/Domain/OccurredOnProcessorTest.php b/tests/Processors/Domain/OccurredOnProcessorTest.php new file mode 100644 index 0000000..370266c --- /dev/null +++ b/tests/Processors/Domain/OccurredOnProcessorTest.php @@ -0,0 +1,65 @@ +processor = new OccurredOnProcessor(); + } + + public function testNoMessageInstance(): void + { + $record = $this->getRecord('bar'); + + $returnedRecord = $this->processor->__invoke($record); + + self::assertSame($record, $returnedRecord); + } + + public function testDomainEventInstance(): void + { + $occurredOn = DateTimeValueObject::from('yesterday'); + + $domainEvent = DomainEventStub::fromPayload( + Uuid::from('8f55979e-edaa-4264-9db0-d8b3eda461ed'), + Uuid::from('50c906eb-ab05-4057-b0fb-5484a0a988ab'), + $occurredOn, + [] + ); + + $returnedRecord = $this->processor->__invoke($this->getRecord($domainEvent)); + + self::assertArrayHasKey('occurredOn', $returnedRecord); + self::assertEquals( + $returnedRecord['occurredOn'], + \sprintf( + '%d%d', + $occurredOn->getTimestamp(), + $occurredOn->format('v') + ) + ); + } + + public function testCommandInstance(): void + { + $command = CommandStub::fromPayload( + Uuid::from('0cf7eedd-de7c-4556-a46e-7f35d9107725'), + [] + ); + + $returnedRecord = $this->processor->__invoke($this->getRecord($command)); + + self::assertArrayHasKey('occurredOn', $returnedRecord); + self::assertIsInt($returnedRecord['occurredOn']); + } +} diff --git a/tests/Processors/Infrastructure/HostnameProcessorTest.php b/tests/Processors/Infrastructure/HostnameProcessorTest.php new file mode 100644 index 0000000..b880268 --- /dev/null +++ b/tests/Processors/Infrastructure/HostnameProcessorTest.php @@ -0,0 +1,23 @@ +processor = new HostnameProcessor(); + } + + public function testHostnameProcessor(): void + { + $recordReturned = $this->processor->__invoke($this->getRecord(CommandStub::create())); + self::assertArrayHasKey('hostname', $recordReturned['extra']); + } +} diff --git a/tests/Processors/Serializer/AssociativeSerializerTest.php b/tests/Processors/Serializer/AssociativeSerializerTest.php new file mode 100644 index 0000000..31069d9 --- /dev/null +++ b/tests/Processors/Serializer/AssociativeSerializerTest.php @@ -0,0 +1,64 @@ + $value], $result); + } + + public function testSerializeThrowable(): void + { + $value = new LogicExceptionStub(Uuid::from('67f05b91-b08e-4fda-8ea5-d9f18de04aac')); + $result = AssociativeSerializer::from($value); + + self::assertArrayHasKey('class', $result); + self::assertArrayHasKey('message', $result); + self::assertArrayHasKey('code', $result); + self::assertArrayHasKey('file', $result); + self::assertArrayHasKey('line', $result); + self::assertArrayHasKey('trace', $result); + + self::assertSame(LogicExceptionStub::class, $result['class']); + self::assertSame($value->getMessage(), $result['message']); + self::assertSame($value->getCode(), $result['code']); + self::assertSame($value->getFile(), $result['file']); + self::assertSame($value->getLine(), $result['line']); + self::assertSame($value->getTraceAsString(), $result['trace']); + } + + public function testSerializeJsonSerializable(): void + { + $value = CommandStub::create(['foo' => 'bar']); + $result = AssociativeSerializer::from($value); + self::assertSame( + \json_decode( + \json_encode( + $value, + JSON_THROW_ON_ERROR + ), + true, + 512, + JSON_THROW_ON_ERROR + ), + $result + ); + } + + public function testSerializerSimpleArray(): void + { + $value = ['foo' => ['bar' => 'baz']]; + $result = AssociativeSerializer::from($value); + self::assertSame(['foo' => ['bar' => ['value' => 'baz']]], $result); + } +}