Passed
Push — main ( d1c8bb...2cd0ba )
by Daniel
12:40
created

SlackLogger   A

Complexity

Total Complexity 16

Size/Duplication

Total Lines 193
Duplicated Lines 0 %

Test Coverage

Coverage 94.81%

Importance

Changes 0
Metric Value
wmc 16
eloc 76
dl 0
loc 193
rs 10
c 0
b 0
f 0
ccs 73
cts 77
cp 0.9481

8 Methods

Rating   Name   Duplication   Size   Complexity  
A getWebhookUrl() 0 3 1
A logLevelIsSufficient() 0 5 1
A sendJsonToSlack() 0 30 5
A getOptions() 0 3 1
A setOptions() 0 5 1
A log() 0 50 4
A __construct() 0 7 1
A setWebhookUrl() 0 9 2
1
<?php
2
3
declare(strict_types=1);
4
5
namespace DanBettles\Defence\Logger;
6
7
use Exception;
8
use InvalidArgumentException;
9
use Psr\Log\AbstractLogger;
10
use Psr\Log\LogLevel;
11
use Symfony\Component\HttpFoundation\Request;
12
use Symfony\Component\HttpFoundation\Response;
13
14
use function array_replace;
15
use function curl_close;
16
use function curl_exec;
17
use function curl_getinfo;
18
use function curl_init;
19
use function curl_setopt;
20
use function filter_var;
21
use function is_scalar;
22
use function json_encode;
23
use function print_r;
24
use function sprintf;
25
use function ucfirst;
26
27
use const CURLOPT_CUSTOMREQUEST;
28
use const CURLOPT_HTTPHEADER;
29
use const CURLOPT_POSTFIELDS;
30
use const CURLOPT_RETURNTRANSFER;
31
use const CURLOPT_URL;
32
use const FILTER_VALIDATE_URL;
33
use const false;
34
use const true;
35
36
/**
37
 * A logger that sends log entries to Slack.
38
 *
39
 * A message will be sent to Slack only if its log-level meets or exceeds the minimum log-level of the logger.  The
40
 * default minimum log-level of the logger is `"debug"` to ensure that, out of the box, the logger will send all
41
 * messages to Slack.
42
 *
43
 * @phpstan-type Context array<string,mixed>
44
 * @phpstan-type LoggerOptions array<string,string>
45
 */
46
class SlackLogger extends AbstractLogger
47
{
48
    /**
49
     * @var array<string,int>
50
     */
51
    private const LOG_LEVEL_PRIORITY = [
52
        LogLevel::EMERGENCY => 8,
53
        LogLevel::ALERT => 7,
54
        LogLevel::CRITICAL => 6,
55
        LogLevel::ERROR => 5,
56
        LogLevel::WARNING => 4,
57
        LogLevel::NOTICE => 3,
58
        LogLevel::INFO => 2,
59
        LogLevel::DEBUG => 1,
60
    ];
61
62
    /**
63
     * @var array<string,string>
64
     */
65
    private const LOG_LEVEL_EMOJI = [
66
        LogLevel::EMERGENCY => ':bangbang:',
67
        LogLevel::ALERT => ':bangbang:',
68
        LogLevel::CRITICAL => ':bangbang:',
69
        LogLevel::ERROR => ':bangbang:',
70
        LogLevel::WARNING => ':warning:',
71
        LogLevel::NOTICE => ':information_source:',
72
        LogLevel::INFO => ':information_source:',
73
        LogLevel::DEBUG => ':information_source:',
74
    ];
75
76
    /**
77
     * @var string
78
     */
79
    private $webhookUrl;
80
81
    /**
82
     * @phpstan-var LoggerOptions
83
     */
84
    private $options;
85
86
    /**
87
     * @phpstan-param LoggerOptions $options
88
     */
89 36
    public function __construct(string $webhookUrl, array $options = [])
90
    {
91 36
        $this
92 36
            ->setWebhookUrl($webhookUrl)
93 36
            ->setOptions(array_replace([
94 36
                'min_log_level' => LogLevel::DEBUG,
95 36
            ], $options))
96 36
        ;
97
    }
98
99
    /**
100
     * @throws InvalidArgumentException If the webhook URL is not a valid URL.
101
     */
102 36
    private function setWebhookUrl(string $url): self
103
    {
104 36
        if (!filter_var($url, FILTER_VALIDATE_URL)) {
105 2
            throw new InvalidArgumentException('The webhook URL is not a valid URL.');
106
        }
107
108 34
        $this->webhookUrl = $url;
109
110 34
        return $this;
111
    }
112
113 2
    public function getWebhookUrl(): string
114
    {
115 2
        return $this->webhookUrl;
116
    }
117
118
    /**
119
     * @phpstan-param LoggerOptions $options
120
     */
121 34
    private function setOptions(array $options): self
122
    {
123 34
        $this->options = $options;
124
125 34
        return $this;
126
    }
127
128
    /**
129
     * @phpstan-return LoggerOptions
130
     */
131 26
    public function getOptions(): array
132
    {
133 26
        return $this->options;
134
    }
135
136
    /**
137
     * @throws Exception If it failed to initialize a new cURL session.
138
     * @throws Exception If `curl_exec()` did not return the result.
139
     * @throws Exception If it failed to send the JSON to Slack.
140
     */
141 1
    protected function sendJsonToSlack(string $json): string
142
    {
143
        /** @var resource|false */
144 1
        $curl = curl_init();
145
146 1
        if (false === $curl) {
147
            throw new Exception('Failed to initialize a new cURL session.');
148
        }
149
150 1
        curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
151
152 1
        curl_setopt($curl, CURLOPT_CUSTOMREQUEST, Request::METHOD_POST);
153 1
        curl_setopt($curl, CURLOPT_URL, $this->getWebhookUrl());
154 1
        curl_setopt($curl, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
155 1
        curl_setopt($curl, CURLOPT_POSTFIELDS, $json);
156
157 1
        $result = curl_exec($curl);
158
159 1
        if (true === $result) {
160
            throw new Exception("`curl_exec()` did not return the result.");
161
        }
162
163 1
        $httpResponseCode = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
164 1
        curl_close($curl);
165
166 1
        if (false === $result || Response::HTTP_OK !== $httpResponseCode) {
167 1
            throw new Exception('Failed to send the JSON to Slack.');
168
        }
169
170
        return $result;
171
    }
172
173
    /**
174
     * Returns `true` if the specified log-level is sufficient to trigger the logger action, or `false` otherwise.
175
     */
176 24
    private function logLevelIsSufficient(string $logLevel): bool
177
    {
178 24
        $minLogLevel = $this->getOptions()['min_log_level'];
179
180 24
        return self::LOG_LEVEL_PRIORITY[$logLevel] >= self::LOG_LEVEL_PRIORITY[$minLogLevel];
181
    }
182
183
    /**
184
     * @see Psr\Log\LoggerInterface::log()
185
     * @param string $level
186
     * @param string $message
187
     * @phpstan-param Context $context
188
     */
189 24
    public function log($level, $message, array $context = [])
190
    {
191 24
        if (!$this->logLevelIsSufficient($level)) {
192 7
            return;
193
        }
194
195 17
        $logLevelEmoji = self::LOG_LEVEL_EMOJI[$level];
196
197 17
        $contextElements = [];
198
199 17
        foreach ($context as $name => $value) {
200
            //@codingStandardsIgnoreStart
201 1
            $valueFormatted = is_scalar($value)
202 1
                ? $value
203
                : '```' . /** @scrutinizer ignore-type */ print_r($value, true) . '```'
204 1
            ;
205
            //@codingStandardsIgnoreEnd
206
207 1
            $contextElements[] = [
208 1
                'type' => 'mrkdwn',
209 1
                'text' => "{$name}: {$valueFormatted}",
210 1
            ];
211
        }
212
213
        /** @var string */
214 17
        $json = json_encode([
215 17
            'text' => "{$logLevelEmoji} Defence handled suspicious request",
216 17
            'blocks' => [
217 17
                [
218 17
                    'type' => 'section',
219 17
                    'text' => [
220 17
                        'type' => 'mrkdwn',
221 17
                        'text' => 'Handled suspicious request.',
222 17
                    ],
223 17
                ],
224 17
                [
225 17
                    'type' => 'section',
226 17
                    'text' => [
227 17
                        'type' => 'mrkdwn',
228 17
                        'text' => sprintf("*%s %s: %s*", $logLevelEmoji, ucfirst($level), $message),
229 17
                    ],
230 17
                ],
231 17
                [
232 17
                    'type' => 'context',
233 17
                    'elements' => $contextElements,
234 17
                ],
235 17
            ],
236 17
        ]);
237
238 17
        $this->sendJsonToSlack($json);
239
    }
240
}
241