SlackLogger::setOptions()   A
last analyzed

Complexity

Conditions 1
Paths 1

Size

Total Lines 5
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 3
CRAP Score 1

Importance

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