Passed
Pull Request — master (#84)
by Joao
01:45
created

AbstractRequester::expectStatus()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 14
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 7
c 0
b 0
f 0
dl 0
loc 14
rs 10
cc 1
nc 1
nop 1
1
<?php
2
3
namespace ByJG\ApiTools;
4
5
use ByJG\ApiTools\Base\Schema;
6
use ByJG\ApiTools\Exception\DefinitionNotFoundException;
7
use ByJG\ApiTools\Exception\GenericApiException;
8
use ByJG\ApiTools\Exception\HttpMethodNotFoundException;
9
use ByJG\ApiTools\Exception\InvalidDefinitionException;
10
use ByJG\ApiTools\Exception\InvalidRequestException;
11
use ByJG\ApiTools\Exception\NotMatchedException;
12
use ByJG\ApiTools\Exception\PathNotFoundException;
13
use ByJG\ApiTools\Exception\RequiredArgumentNotFound;
14
use ByJG\ApiTools\Exception\StatusCodeNotMatchedException;
15
use ByJG\Util\Uri;
16
use ByJG\WebRequest\Exception\MessageException;
17
use ByJG\WebRequest\Exception\RequestException;
18
use ByJG\WebRequest\Psr7\MemoryStream;
19
use ByJG\WebRequest\Psr7\Request;
20
use Psr\Http\Message\RequestInterface;
21
use Psr\Http\Message\ResponseInterface;
22
23
/**
24
 * Abstract baseclass for request handlers.
25
 *
26
 * The baseclass provides processing and verification of request and response.
27
 * It only delegates the actual message exchange to the derived class. For the
28
 * messages, it uses the PHP PSR-7 implementation.
29
 *
30
 * This is an implementation of the Template Method Patttern
31
 * (https://en.wikipedia.org/wiki/Template_method_pattern).
32
 */
33
abstract class AbstractRequester
34
{
35
    /**
36
     * @var Schema|null
37
     */
38
    protected ?Schema $schema = null;
39
40
    protected int $statusExpected = 200;
41
    protected array $assertHeader = [];
42
    protected array $assertBody = [];
43
44
    /**
45
     * @var array<callable> PHPUnit assertions to execute after response is received
46
     */
47
    protected array $phpunitAssertions = [];
48
49
    /**
50
     * @var RequestInterface
51
     */
52
    protected RequestInterface $psr7Request;
53
54
    /**
55
     * AbstractRequester constructor.
56
     * @throws MessageException
57
     * @throws RequestException
58
     */
59
    public function __construct()
60
    {
61
        $this->withPsr7Request(Request::getInstance(new Uri("/"))->withMethod("get"));
62
    }
63
64
    /**
65
     * abstract function to be implemented by derived classes
66
     *
67
     * This function must be implemented by derived classes. It should process
68
     * the given request and return an according response.
69
     *
70
     * @param RequestInterface $request
71
     * @return ResponseInterface
72
     */
73
    abstract protected function handleRequest(RequestInterface $request): ResponseInterface;
74
75
    /**
76
     * @param Schema $schema
77
     * @return $this
78
     */
79
    public function withSchema(Schema $schema): self
80
    {
81
        $this->schema = $schema;
82
83
        return $this;
84
    }
85
86
    /**
87
     * @return bool
88
     */
89
    public function hasSchema(): bool
90
    {
91
        return !empty($this->schema);
92
    }
93
94
    /**
95
     * @param string $method
96
     * @return $this
97
     */
98
    public function withMethod(string $method): self
99
    {
100
        $this->psr7Request = $this->psr7Request->withMethod($method);
101
102
        return $this;
103
    }
104
105
    /**
106
     * @param string $path
107
     * @return $this
108
     */
109
    public function withPath(string $path): self
110
    {
111
        $uri = $this->psr7Request->getUri()->withPath($path);
112
        $this->psr7Request = $this->psr7Request->withUri($uri);
113
114
        return $this;
115
    }
116
117
    /**
118
     * @param string|array $requestHeader
119
     * @return $this
120
     */
121
    public function withRequestHeader(string|array $requestHeader): self
122
    {
123
        foreach ((array)$requestHeader as $name => $value) {
124
            $this->psr7Request = $this->psr7Request->withHeader($name, $value);
125
        }
126
127
        return $this;
128
    }
129
130
    /**
131
     * @param array|null $query
132
     * @return $this
133
     */
134
    public function withQuery(?array $query = null): self
135
    {
136
        $uri = $this->psr7Request->getUri();
137
138
        if (is_null($query)) {
139
            $uri = $uri->withQuery("");
140
            $this->psr7Request = $this->psr7Request->withUri($uri);
141
            return $this;
142
        }
143
144
        $currentQuery = [];
145
        parse_str($uri->getQuery(), $currentQuery);
146
147
        $uri = $uri->withQuery(http_build_query(array_merge($currentQuery, $query)));
148
        $this->psr7Request = $this->psr7Request->withUri($uri);
149
150
        return $this;
151
    }
152
153
    /**
154
     * @param mixed $requestBody
155
     * @return $this
156
     */
157
    public function withRequestBody(array|string $requestBody): self
158
    {
159
        $contentType = $this->psr7Request->getHeaderLine("Content-Type");
160
        if (is_array($requestBody) && (empty($contentType) || str_contains($contentType, "application/json"))) {
161
            $requestBody = json_encode($requestBody);
162
        }
163
        $this->psr7Request = $this->psr7Request->withBody(new MemoryStream($requestBody));
164
165
        return $this;
166
    }
167
168
    /**
169
     * @param RequestInterface $requestInterface
170
     * @return $this
171
     */
172
    public function withPsr7Request(RequestInterface $requestInterface): self
173
    {
174
        $this->psr7Request = $requestInterface->withHeader("Accept", "application/json");
175
176
        return $this;
177
    }
178
179
    /**
180
     * Expect a specific HTTP status code.
181
     *
182
     * @param int $expectedStatus Expected HTTP status code
183
     * @return $this
184
     */
185
    public function expectStatus(int $expectedStatus): self
186
    {
187
        $this->statusExpected = $expectedStatus;
188
189
        // Add PHPUnit assertion to be executed after response is received
190
        $this->phpunitAssertions[] = function ($testCase, $response) use ($expectedStatus) {
191
            $testCase->assertEquals(
192
                $expectedStatus,
193
                $response->getStatusCode(),
194
                "Expected HTTP status code $expectedStatus"
195
            );
196
        };
197
198
        return $this;
199
    }
200
201
    /**
202
     * Expect a specific header to contain a value.
203
     *
204
     * @param string $header Header name
205
     * @param string $contains Expected value to be contained in the header
206
     * @return $this
207
     */
208
    public function expectHeaderContains(string $header, string $contains): self
209
    {
210
        $this->assertHeader[$header] = $contains;
211
212
        return $this;
213
    }
214
215
    /**
216
     * Expect the response body to contain a string.
217
     *
218
     * @param string $contains Expected string to be contained in the body
219
     * @return $this
220
     */
221
    public function expectBodyContains(string $contains): self
222
    {
223
        $this->assertBody[] = $contains;
224
225
        return $this;
226
    }
227
228
    /**
229
     * Expect the JSON response to contain specific key-value pairs.
230
     *
231
     * This performs a subset match - the response can contain additional fields.
232
     *
233
     * @param array $expected Expected key-value pairs (supports nested arrays)
234
     * @return $this
235
     */
236
    public function expectJsonContains(array $expected): self
237
    {
238
        $this->phpunitAssertions[] = function ($testCase, $response) use ($expected) {
239
            $body = json_decode((string)$response->getBody(), true);
240
241
            if ($body === null) {
242
                $testCase->fail('Response body is not valid JSON');
243
            }
244
245
            foreach ($expected as $key => $value) {
246
                $testCase->assertArrayHasKey(
247
                    $key,
248
                    $body,
249
                    "Expected JSON response to contain key '$key'"
250
                );
251
252
                if (is_array($value)) {
253
                    $testCase->assertEquals(
254
                        $value,
255
                        $body[$key],
256
                        "Expected JSON key '$key' to match nested array"
257
                    );
258
                } else {
259
                    $testCase->assertEquals(
260
                        $value,
261
                        $body[$key],
262
                        "Expected JSON key '$key' to equal " . json_encode($value)
263
                    );
264
                }
265
            }
266
        };
267
268
        return $this;
269
    }
270
271
    /**
272
     * Expect a specific value at a JSONPath expression.
273
     *
274
     * Supports simple dot notation like 'user.name' or 'items.0.id'.
275
     *
276
     * @param string $path JSONPath expression (dot notation)
277
     * @param mixed $expectedValue Expected value at that path
278
     * @return $this
279
     */
280
    public function expectJsonPath(string $path, mixed $expectedValue): self
281
    {
282
        $this->phpunitAssertions[] = function ($testCase, $response) use ($path, $expectedValue) {
283
            $body = json_decode((string)$response->getBody(), true);
284
285
            if ($body === null) {
286
                $testCase->fail('Response body is not valid JSON');
287
            }
288
289
            // Simple JSONPath implementation using dot notation
290
            $keys = explode('.', $path);
291
            $current = $body;
292
293
            foreach ($keys as $key) {
294
                if (is_array($current) && array_key_exists($key, $current)) {
295
                    $current = $current[$key];
296
                } else {
297
                    $testCase->fail("JSONPath '$path' not found in response (failed at key '$key')");
298
                    return;
299
                }
300
            }
301
302
            $testCase->assertEquals(
303
                $expectedValue,
304
                $current,
305
                "Expected value at JSONPath '$path' to equal " . json_encode($expectedValue)
306
            );
307
        };
308
309
        return $this;
310
    }
311
312
    /**
313
     * Get the expected status code.
314
     *
315
     * @return int
316
     */
317
    public function getExpectedStatus(): int
318
    {
319
        return $this->statusExpected;
320
    }
321
322
    /**
323
     * Get the registered PHPUnit assertions.
324
     *
325
     * @return array<callable>
326
     */
327
    public function getPhpunitAssertions(): array
328
    {
329
        return $this->phpunitAssertions;
330
    }
331
332
    /**
333
     * @return ResponseInterface
334
     * @throws DefinitionNotFoundException
335
     * @throws GenericApiException
336
     * @throws HttpMethodNotFoundException
337
     * @throws InvalidDefinitionException
338
     * @throws InvalidRequestException
339
     * @throws NotMatchedException
340
     * @throws PathNotFoundException
341
     * @throws RequiredArgumentNotFound
342
     * @throws StatusCodeNotMatchedException
343
     */
344
    public function send(): ResponseInterface
345
    {
346
        // Process URI based on the OpenAPI schema
347
        $uriSchema = new Uri($this->schema->getServerUrl());
0 ignored issues
show
Bug introduced by
The method getServerUrl() does not exist on null. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

347
        $uriSchema = new Uri($this->schema->/** @scrutinizer ignore-call */ getServerUrl());

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
348
349
        if (empty($uriSchema->getScheme())) {
350
            $uriSchema = $uriSchema->withScheme($this->psr7Request->getUri()->getScheme());
351
        }
352
353
        if (empty($uriSchema->getHost())) {
354
            $uriSchema = $uriSchema->withHost($this->psr7Request->getUri()->getHost());
355
        }
356
357
        $uri = $this->psr7Request->getUri()
358
            ->withScheme($uriSchema->getScheme())
359
            ->withHost($uriSchema->getHost())
360
            ->withPort($uriSchema->getPort())
361
            ->withPath($uriSchema->getPath() . $this->psr7Request->getUri()->getPath());
362
363
        if (!preg_match("~^{$this->schema->getBasePath()}~",  $uri->getPath())) {
364
            $uri = $uri->withPath($this->schema->getBasePath() . $uri->getPath());
365
        }
366
367
        $this->psr7Request = $this->psr7Request->withUri($uri);
368
369
        // Prepare Body to Match Against Specification
370
        $requestBody = $this->psr7Request->getBody()->getContents();
371
        if (!empty($requestBody)) {
372
            $contentType = $this->psr7Request->getHeaderLine("content-type");
373
            if (empty($contentType) || str_contains($contentType, "application/json")) {
374
                $requestBody = json_decode($requestBody, true);
375
            } elseif (str_contains($contentType, "multipart/")) {
376
                $requestBody = $this->parseMultiPartForm($contentType, $requestBody);
377
            } else {
378
                throw new InvalidRequestException("Cannot handle Content Type '$contentType'");
379
            }
380
        }
381
382
        // Check if the body is the expected before request
383
        $bodyRequestDef = $this->schema->getRequestParameters($this->psr7Request->getUri()->getPath(), $this->psr7Request->getMethod());
384
        $bodyRequestDef->match($requestBody);
385
386
        // Handle Request
387
        $response = $this->handleRequest($this->psr7Request);
388
        $responseHeader = $response->getHeaders();
389
        $responseBodyStr = (string) $response->getBody();
390
        $responseBody = json_decode($responseBodyStr, true);
391
        $statusReturned = $response->getStatusCode();
392
393
        // Assert results
394
        if ($this->statusExpected != $statusReturned) {
395
            throw new StatusCodeNotMatchedException(
396
                "Status code not matched: Expected $this->statusExpected, got $statusReturned",
397
                $responseBody
398
            );
399
        }
400
401
        $bodyResponseDef = $this->schema->getResponseParameters(
402
            $this->psr7Request->getUri()->getPath(),
403
            $this->psr7Request->getMethod(),
404
            $this->statusExpected
405
        );
406
        $bodyResponseDef->match($responseBody);
407
408
        foreach ($this->assertHeader as $key => $value) {
409
            if (!isset($responseHeader[$key]) || !str_contains($responseHeader[$key][0], $value)) {
410
                throw new NotMatchedException(
411
                    "Does not exists header '$key' with value '$value'",
412
                    $responseHeader
413
                );
414
            }
415
        }
416
417
        if (!empty($responseBodyStr)) {
418
            foreach ($this->assertBody as $item) {
419
                if (!str_contains($responseBodyStr, $item)) {
420
                    throw new NotMatchedException("Body does not contain '$item'");
421
                }
422
            }
423
        }
424
425
        return $response;
426
    }
427
428
    protected function parseMultiPartForm(?string $contentType, string $body): array|null
429
    {
430
        $matchRequest = [];
431
432
        if (empty($contentType) || !str_contains($contentType, "multipart/")) {
433
            return null;
434
        }
435
436
        $matches = [];
437
438
        preg_match('/boundary=(.*)$/', $contentType, $matches);
439
        $boundary = $matches[1];
440
441
        // split content by boundary and get rid of last -- element
442
        $blocks = preg_split("/-+$boundary/", $body);
443
        array_pop($blocks);
444
445
        // loop data blocks
446
        foreach ($blocks as $block) {
447
            if (empty($block))
448
                continue;
449
450
            if (str_contains($block, 'application/octet-stream')) {
451
                preg_match("/name=\"([^\"]*)\".*stream[\n|\r]+([^\n\r].*)?$/s", $block, $matches);
452
            } else {
453
                preg_match('/\bname=\"([^\"]*)\"\s*;.*?[\n|\r]+([^\n\r].*)?[\r|\n]$/s', $block, $matches);
454
            }
455
            $matchRequest[$matches[1]] = $matches[2];
456
        }
457
458
        return $matchRequest;
459
    }
460
}
461