Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.08% covered (success)
98.08%
102 / 104
90.00% covered (success)
90.00%
9 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
Breach
98.08% covered (success)
98.08%
102 / 104
90.00% covered (success)
90.00%
9 / 10
33
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getAllBreachSites
85.71% covered (warning)
85.71%
12 / 14
0.00% covered (danger)
0.00%
0 / 1
3.03
 filterDomain
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 hasDomainFilter
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 getRequest
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 getBreach
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
5
 getAllDataClasses
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
2
 getBreachedAccount
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
6
 getBreachedAccountTruncated
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
6
 getLatestBreach
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
5
1<?php
2
3declare(strict_types=1);
4
5namespace Icawebdesign\Hibp\Breach;
6
7use Exception;
8use GuzzleHttp\ClientInterface;
9use GuzzleHttp\Exception\ClientException;
10use GuzzleHttp\Exception\GuzzleException;
11use GuzzleHttp\Exception\RequestException;
12use Icawebdesign\Hibp\Exception\BreachNotFoundException;
13use Icawebdesign\Hibp\Exception\InvalidResponseBodyException;
14use Icawebdesign\Hibp\HibpHttp;
15use Icawebdesign\Hibp\Traits\HibpConfig;
16use Illuminate\Support\Collection;
17use JsonException;
18use Psr\Http\Message\ResponseInterface;
19use stdClass;
20
21use function trim;
22use function urlencode;
23
24use const JSON_THROW_ON_ERROR;
25
26class Breach implements BreachInterface
27{
28    use HibpConfig;
29
30    public int $statusCode;
31
32    protected ClientInterface $client;
33
34    protected string $apiRoot;
35
36    public function __construct(HibpHttp $hibpHttp)
37    {
38        $this->apiRoot = "{$this->hibpApiRoot}/v{$this->apiVersion}";
39        $this->client = $hibpHttp->client();
40    }
41
42    /**
43     * Get all breach sites in system
44     *
45     * @param ?string $domainFilter
46     * @param array<string, mixed> $options
47     *
48     * @return Collection<int, BreachSiteEntity>
49     * @throws GuzzleException|InvalidResponseBodyException
50     */
51    public function getAllBreachSites(?string $domainFilter = null, array $options = []): Collection
52    {
53        $uri = $this->filterDomain(uri: "{$this->apiRoot}/breaches", domainFilter: $domainFilter);
54
55        try {
56            $response = $this->getRequest(
57                uri: $uri,
58                options: $options,
59            );
60        } catch (RequestException $exception) {
61            $this->statusCode = $exception->getCode();
62            throw $exception;
63        }
64
65        $this->statusCode = $response->getStatusCode();
66
67        try {
68            /** @var array<int, stdClass> $data */
69            $data = json_decode((string)$response->getBody(), associative: false, flags: JSON_THROW_ON_ERROR);
70        } catch (JsonException $exception) {
71            throw new InvalidResponseBodyException($exception->getMessage());
72        }
73
74        return Collection::make($data)
75            ->map(static fn(stdClass $breach): BreachSiteEntity => new BreachSiteEntity(data: $breach));
76    }
77
78    private function filterDomain(string $uri, ?string $domainFilter = null): string
79    {
80        if (!$this->hasDomainFilter(domainFilter: $domainFilter)) {
81            return $uri;
82        }
83
84        /** @var string $domainFilter */
85        return sprintf('%s?domain=%s', $uri, urlencode($domainFilter));
86    }
87
88    private function hasDomainFilter(?string $domainFilter = null): bool
89    {
90        return ($domainFilter !== null) && (trim($domainFilter) !== '');
91    }
92
93    /**
94     * @param string $uri
95     * @param array<string, mixed> $options
96     *
97     * @return ResponseInterface
98     * @throws GuzzleException
99     */
100    private function getRequest(string $uri, array $options = []): ResponseInterface
101    {
102        return $this->client->request(
103            'GET',
104            $uri,
105            $options,
106        );
107    }
108
109    /**
110     * Get breach data for single account
111     *
112     * @param string $account
113     * @param array<string, mixed> $options
114     *
115     * @return BreachSiteEntity
116     * @throws Exception|GuzzleException|JsonException
117     */
118    public function getBreach(string $account, array $options = []): BreachSiteEntity
119    {
120        try {
121            $response = $this->getRequest(
122                uri: sprintf('%s/breach/%s', $this->apiRoot, urlencode($account)),
123                options: $options,
124            );
125        } catch (ClientException $exception) {
126            $this->statusCode = $exception->getCode();
127
128            throw match ($exception->getCode()) {
129                404 => new BreachNotFoundException($exception->getMessage()),
130                400 => new RequestException($exception->getMessage(), $exception->getRequest()),
131                default => $exception,
132            };
133        }
134
135        $data = json_decode((string)$response->getBody(), associative: false, flags: JSON_THROW_ON_ERROR);
136
137        return new BreachSiteEntity(data: $data);
138    }
139
140    /**
141     * Get list of all data classes in the system
142     *
143     * @param array<string, mixed> $options
144     *
145     * @return Collection<int, string>
146     * @throws GuzzleException|JsonException
147     */
148    public function getAllDataClasses(array $options = []): Collection
149    {
150        try {
151            $response = $this->getRequest(
152                uri: "{$this->apiRoot}/dataclasses",
153                options: $options,
154            );
155        } catch (ClientException $exception) {
156            $this->statusCode = $exception->getCode();
157            throw $exception;
158        }
159
160        $this->statusCode = $response->getStatusCode();
161
162        /** @var array<int, string> $data */
163        $data = json_decode((string)$response->getBody(), associative: true, flags: JSON_THROW_ON_ERROR);
164
165        return Collection::make($data);
166    }
167
168    /**
169     * Get list of breached sites an email address was found in
170     *
171     * @param string $emailAddress
172     * @param bool $includeUnverified
173     * @param ?string $domainFilter
174     * @param array<string, mixed> $options
175     *
176     * @return Collection<int, BreachSiteEntity>
177     * @throws GuzzleException|JsonException|Exception
178     */
179    public function getBreachedAccount(
180        string $emailAddress,
181        bool $includeUnverified = false,
182        ?string $domainFilter = null,
183        array $options = [],
184    ): Collection {
185        $uri = sprintf(
186            '%s/breachedaccount/%s?truncateResponse=false&includeUnverified=%s',
187            $this->apiRoot,
188            urlencode($emailAddress),
189            $includeUnverified ? 'true' : 'false',
190        );
191
192        $uri = $this->filterDomain(uri: $uri, domainFilter: $domainFilter);
193
194        try {
195            $response = $this->getRequest(
196                uri: $uri,
197                options: $options,
198            );
199        } catch (ClientException $exception) {
200            $this->statusCode = $exception->getCode();
201
202            throw match ($exception->getCode()) {
203                404 => new BreachNotFoundException($exception->getMessage()),
204                400 => new RequestException($exception->getMessage(), $exception->getRequest()),
205                default => $exception,
206            };
207        }
208
209        $this->statusCode = $response->getStatusCode();
210
211        /** @var array<int, stdClass> $data */
212        $data = json_decode((string)$response->getBody(), associative: false, flags: JSON_THROW_ON_ERROR);
213
214        return Collection::make($data)
215            ->map(static fn(stdClass $breach): BreachSiteEntity => new BreachSiteEntity(data: $breach));
216    }
217
218    /**
219     * Get breach data for an account but only return breach name
220     *
221     * @param string $emailAddress
222     * @param bool $includeUnverified
223     * @param ?string $domainFilter
224     * @param array<string, mixed> $options
225     *
226     * @return Collection<int, BreachSiteTruncatedEntity>
227     * @throws GuzzleException|JsonException|Exception
228     */
229    public function getBreachedAccountTruncated(
230        string $emailAddress,
231        bool $includeUnverified = false,
232        ?string $domainFilter = null,
233        array $options = [],
234    ): Collection {
235        $uri = sprintf(
236            '%s/breachedaccount/%s?truncateResponse=true&includeUnverified=%s',
237            $this->apiRoot,
238            urlencode($emailAddress),
239            $includeUnverified ? 'true' : 'false',
240        );
241
242        $uri = $this->filterDomain($uri, $domainFilter);
243
244        try {
245            $response = $this->getRequest(
246                uri: $uri,
247                options: $options,
248            );
249        } catch (ClientException $exception) {
250            $this->statusCode = $exception->getCode();
251
252            throw match ($exception->getCode()) {
253                404 => new BreachNotFoundException($exception->getMessage()),
254                400 => new RequestException($exception->getMessage(), $exception->getRequest()),
255                default => $exception,
256            };
257        }
258
259        $this->statusCode = $response->getStatusCode();
260
261        /** @var array<int, stdClass> $data */
262        $data = json_decode((string)$response->getBody(), associative: false, flags: JSON_THROW_ON_ERROR);
263
264        return Collection::make($data)
265            ->map(
266                static fn(stdClass $breach): BreachSiteTruncatedEntity
267                    => new BreachSiteTruncatedEntity(data: $breach),
268            );
269    }
270
271    /**
272     * @param array<string, mixed> $options
273     *
274     * @return BreachSiteEntity
275     * @throws GuzzleException
276     * @throws Exception|GuzzleException|JsonException
277     */
278    public function getLatestBreach(array $options = []): BreachSiteEntity
279    {
280        try {
281            $response = $this->getRequest(
282                uri: "{$this->apiRoot}/latestbreach",
283                options: $options,
284            );
285        } catch (ClientException $exception) {
286            $this->statusCode = $exception->getCode();
287
288            throw match ($exception->getCode()) {
289                404 => new BreachNotFoundException($exception->getMessage()),
290                400 => new RequestException($exception->getMessage(), $exception->getRequest()),
291                default => $exception,
292            };
293        }
294
295        $data = json_decode((string)$response->getBody(), associative: false, flags: JSON_THROW_ON_ERROR);
296
297        return new BreachSiteEntity($data);
298    }
299}