Skip to content

Commit d84cda4

Browse files
authored
Merge pull request #341 from saloonphp/feature/add-certificate-authenticator
Feature | Added certificate authenticator
2 parents f7d2df9 + 8147e91 commit d84cda4

File tree

8 files changed

+124
-3
lines changed

8 files changed

+124
-3
lines changed

.github/workflows/tests.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ jobs:
3333
uses: shivammathur/setup-php@v2
3434
with:
3535
php-version: ${{ matrix.php }}
36-
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, bcmath, intl, exif, iconv, fileinfo
36+
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, bcmath, intl, exif, iconv, fileinfo, xsl, sodium
3737
coverage: none
3838

3939
- name: Setup problem matchers

composer.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
"league/flysystem": "^3.0",
3434
"pestphp/pest": "^2.6",
3535
"phpstan/phpstan": "^1.9",
36+
"saloonphp/xml-wrangler": "^1.1",
3637
"spatie/ray": "^1.33",
3738
"symfony/dom-crawler": "^6.0"
3839
},
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Saloon\Http\Auth;
6+
7+
use GuzzleHttp\RequestOptions;
8+
use Saloon\Http\PendingRequest;
9+
use Saloon\Contracts\Authenticator;
10+
use Saloon\Http\Senders\GuzzleSender;
11+
use Saloon\Exceptions\SaloonException;
12+
13+
class CertificateAuthenticator implements Authenticator
14+
{
15+
/**
16+
* Constructor
17+
*/
18+
public function __construct(
19+
public string $path,
20+
public ?string $password = null,
21+
) {
22+
//
23+
}
24+
25+
/**
26+
* Apply the authentication to the request.
27+
*
28+
* @throws \Saloon\Exceptions\SaloonException
29+
*/
30+
public function set(PendingRequest $pendingRequest): void
31+
{
32+
if (! $pendingRequest->getConnector()->sender() instanceof GuzzleSender) {
33+
throw new SaloonException('The CertificateAuthenticator is only supported when using the GuzzleSender.');
34+
}
35+
36+
// See: https://docs.guzzlephp.org/en/stable/request-options.html#cert
37+
38+
$path = $this->path;
39+
$password = $this->password;
40+
41+
$certificate = is_string($password) ? [$path, $password] : $path;
42+
43+
$pendingRequest->config()->add(RequestOptions::CERT, $certificate);
44+
}
45+
}

src/Http/Response.php

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
use Saloon\Traits\Macroable;
1111
use InvalidArgumentException;
1212
use Saloon\Helpers\ArrayHelpers;
13+
use Saloon\XmlWrangler\XmlReader;
1314
use Illuminate\Support\Collection;
1415
use Saloon\Contracts\FakeResponse;
1516
use Saloon\Repositories\ArrayStore;
@@ -136,11 +137,19 @@ public function getPsrResponse(): ResponseInterface
136137
*/
137138
public function body(): string
138139
{
139-
return $this->stream()->getContents();
140+
$stream = $this->stream();
141+
142+
$contents = $stream->getContents();
143+
144+
if ($stream->isSeekable()) {
145+
$stream->rewind();
146+
}
147+
148+
return $contents;
140149
}
141150

142151
/**
143-
* Get the body as a stream. Don't forget to close the stream after using ->close().
152+
* Get the body as a stream.
144153
*/
145154
public function stream(): StreamInterface
146155
{
@@ -227,6 +236,8 @@ public function object(): object
227236

228237
/**
229238
* Convert the XML response into a SimpleXMLElement.
239+
*
240+
* @deprecated Use the xmlReader method instead.
230241
*/
231242
public function xml(mixed ...$arguments): SimpleXMLElement|bool
232243
{
@@ -237,6 +248,18 @@ public function xml(mixed ...$arguments): SimpleXMLElement|bool
237248
return simplexml_load_string($this->decodedXml, ...$arguments);
238249
}
239250

251+
/**
252+
* Load the XML response into a reader
253+
*
254+
* Requires XML Wrangler (composer require saloonphp/xml-wrangler)
255+
*
256+
* @see https://github.com/saloonphp/xml-wrangler
257+
*/
258+
public function xmlReader(): XmlReader
259+
{
260+
return XmlReader::fromSaloonResponse($this);
261+
}
262+
240263
/**
241264
* Get the JSON decoded body of the response as a collection.
242265
*

src/Traits/Auth/AuthenticatesRequests.php

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
use Saloon\Http\Auth\TokenAuthenticator;
1111
use Saloon\Http\Auth\DigestAuthenticator;
1212
use Saloon\Http\Auth\HeaderAuthenticator;
13+
use Saloon\Http\Auth\CertificateAuthenticator;
1314

1415
trait AuthenticatesRequests
1516
{
@@ -95,4 +96,14 @@ public function withHeaderAuth(string $accessToken, string $headerName = 'Author
9596
{
9697
return $this->authenticate(new HeaderAuthenticator($accessToken, $headerName));
9798
}
99+
100+
/**
101+
* Authenticate the request with a certificate.
102+
*
103+
* @return $this
104+
*/
105+
public function withCertificateAuth(string $path, ?string $password = null): static
106+
{
107+
return $this->authenticate(new CertificateAuthenticator($path, $password));
108+
}
98109
}

tests/Fixtures/Saloon/xml.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{"statusCode":200,"headers":{"Date":"Sat, 02 Dec 2023 16:01:23 GMT","Content-Type":"text\/xml; charset=UTF-8","Content-Length":"1317","Connection":"keep-alive","access-control-allow-origin":"*","Cache-Control":"no-cache, private","x-frame-options":"SAMEORIGIN","x-xss-protection":"1; mode=block","x-content-type-options":"nosniff","CF-Cache-Status":"DYNAMIC","Report-To":"{\"endpoints\":[{\"url\":\"https:\\\/\\\/a.nel.cloudflare.com\\\/report\\\/v3?s=8t7HkDmpXwrJNaQUDwqb6GXENRT2oZt3tqla7AT69PlDzz%2FoF6lUjsYS%2BYYE7%2FR9x3mRtRFgOGqlr0zT0byUcjWetwrtOaO4O%2BGho%2B1Frpk3erLyDD0kQEp6TA%2BhiUqeB%2FSYRXWwO9%2FHohaktPsv\"}],\"group\":\"cf-nel\",\"max_age\":604800}","NEL":"{\"success_fraction\":0,\"report_to\":\"cf-nel\",\"max_age\":604800}","Server":"cloudflare","CF-RAY":"82f4c9cb9b1f638b-LHR","alt-svc":"h3=\":443\"; ma=86400"},"data":"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<breakfast_menu name=\"Big G's Breakfasts\">\n <food soldOut=\"false\" bestSeller=\"true\">\n <name>Belgian Waffles<\/name>\n <price>$5.95<\/price>\n <description>Two of our famous Belgian Waffles with plenty of real maple syrup<\/description>\n <calories>650<\/calories>\n <\/food>\n <food soldOut=\"false\" bestSeller=\"false\">\n <name>Strawberry Belgian Waffles<\/name>\n <price>$7.95<\/price>\n <description>Light Belgian waffles covered with strawberries and whipped cream<\/description>\n <calories>900<\/calories>\n <\/food>\n <food soldOut=\"false\" bestSeller=\"true\">\n <name>Berry-Berry Belgian Waffles<\/name>\n <price>$8.95<\/price>\n <description>Light Belgian waffles covered with an assortment of fresh berries and whipped cream<\/description>\n <calories>900<\/calories>\n <\/food>\n <food soldOut=\"true\" bestSeller=\"false\">\n <name>French Toast<\/name>\n <price>$4.50<\/price>\n <description>Thick slices made from our homemade sourdough bread<\/description>\n <calories>600<\/calories>\n <\/food>\n <food soldOut=\"false\" bestSeller=\"false\">\n <name>Homestyle Breakfast<\/name>\n <price>$6.95<\/price>\n <description>Two eggs, bacon or sausage, toast, and our ever-popular hash browns<\/description>\n <calories>950<\/calories>\n <\/food>\n<\/breakfast_menu>\n"}

tests/Unit/AuthenticatesRequestsTest.php

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
declare(strict_types=1);
44

5+
use GuzzleHttp\RequestOptions;
56
use Saloon\Exceptions\SaloonException;
67
use Saloon\Tests\Fixtures\Requests\UserRequest;
78
use Saloon\Tests\Fixtures\Connectors\ArraySenderConnector;
@@ -61,3 +62,27 @@
6162

6263
expect($query)->toHaveKey('X-Authorization', 'Sammyjo20');
6364
});
65+
66+
test('you can add a certificate to a request', function () {
67+
$certPath = __DIR__ . '/certificate.cer';
68+
69+
$requestA = UserRequest::make()->withCertificateAuth($certPath);
70+
71+
$pendingRequestA = connector()->createPendingRequest($requestA);
72+
$configA = $pendingRequestA->config()->all();
73+
74+
expect($configA)->toBe([
75+
RequestOptions::CERT => $certPath,
76+
]);
77+
78+
// Test with password
79+
80+
$requestB = UserRequest::make()->withCertificateAuth($certPath, 'example');
81+
82+
$pendingRequestB = connector()->createPendingRequest($requestB);
83+
$configB = $pendingRequestB->config()->all();
84+
85+
expect($configB)->toBe([
86+
RequestOptions::CERT => [$certPath, 'example'],
87+
]);
88+
});

tests/Unit/ResponseTest.php

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
use Saloon\Exceptions\Request\RequestException;
1616
use Saloon\Tests\Fixtures\Requests\UserRequest;
1717
use Saloon\Tests\Fixtures\Connectors\TestConnector;
18+
use Saloon\Tests\Fixtures\Requests\CustomEndpointRequest;
1819

1920
test('you can get the original pending request', function () {
2021
$mockClient = new MockClient([
@@ -239,6 +240,20 @@
239240
expect($simpleXml)->toBeInstanceOf(SimpleXMLElement::class);
240241
});
241242

243+
test('the xmlReader method will return an XmlReader instance', function () {
244+
$mockClient = new MockClient([
245+
MockResponse::fixture('xml'),
246+
]);
247+
248+
$request = new CustomEndpointRequest;
249+
$request->setEndpoint('/breakfast-menu');
250+
251+
$response = connector()->send($request, $mockClient);
252+
$reader = $response->xmlReader();
253+
254+
expect($reader->value('food.2.name')->sole())->toBe('Berry-Berry Belgian Waffles');
255+
});
256+
242257
test('the headers method returns an array store', function () {
243258
$mockClient = new MockClient([
244259
MockResponse::make(['name' => 'Sam', 'work' => 'Codepotato'], 200, ['X-Greeting' => 'Howdy']),

0 commit comments

Comments
 (0)