├── .gitignore
├── .editorconfig
├── src
├── Exceptions
│ ├── RuntimeException.php
│ └── ServerException.php
├── MultiResponseInterface.php
├── InitializingResponseInterface.php
├── ResponseInterface.php
├── Responses
│ ├── NotFoundResponse.php
│ └── DefaultResponse.php
├── Response.php
├── DelayedResponse.php
├── ResponseStack.php
├── ResponseByMethod.php
├── RequestInfo.php
├── InternalServer.php
└── MockWebServer.php
├── phpcs.xml.dist
├── phpstan.neon
├── .github
├── dependabot.yml
└── workflows
│ ├── lint.yml
│ ├── ci.yml
│ └── cover.yml
├── example
├── basic.php
├── simple.php
├── methods.php
├── notfound.php
├── multi.php
├── phpunit.php
└── delayed.php
├── phpunit.xml.dist
├── test
├── Regression
│ ├── MockWebServer_RegressionTest.php
│ └── ResponseByMethod_RegressionTest.php
├── Integration
│ ├── Mock
│ │ └── ExampleInitializingResponse.php
│ ├── MockWebServer_GetRequestByOffset_IntegrationTest.php
│ ├── MockWebServer_ChangedDefault_IntegrationTest.php
│ ├── InternalServer_IntegrationTest.php
│ └── MockWebServer_IntegrationTest.php
├── ResponseStackTest.php
├── DelayedResponseTest.php
└── InternalServerTest.php
├── server
└── server.php
├── composer.json
├── LICENSE.md
├── .phan
└── config.php
├── mddoc.xml
├── .php-cs-fixer.dist.php
├── docs
└── docs.md
└── README.md
/.gitignore:
--------------------------------------------------------------------------------
1 | /vendor
2 | /composer.lock
3 | /coverage.xml
4 | *.cache
5 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | [*.php]
2 | charset = utf-8
3 |
4 | indent_style = tab
5 | indent_size = 4
6 |
7 | end_of_line = lf
8 | insert_final_newline = true
9 |
--------------------------------------------------------------------------------
/src/Exceptions/RuntimeException.php:
--------------------------------------------------------------------------------
1 |
2 |
3 | src
4 | test
5 | example
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/phpstan.neon:
--------------------------------------------------------------------------------
1 | parameters:
2 | level: 9
3 | paths:
4 | - src
5 | - server
6 | phpVersion: 70100
7 | ignoreErrors:
8 | -
9 | identifier: missingType.iterableValue
10 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | updates:
3 | - package-ecosystem: composer
4 | directory: "/"
5 | schedule:
6 | interval: daily
7 | time: "11:00"
8 | open-pull-requests-limit: 10
9 | - package-ecosystem: "github-actions"
10 | directory: "/"
11 | schedule:
12 | interval: monthly
13 | time: "11:00"
14 |
--------------------------------------------------------------------------------
/example/basic.php:
--------------------------------------------------------------------------------
1 | start();
9 |
10 | $url = $server->getServerRoot() . '/endpoint?get=foobar';
11 |
12 | echo "Requesting: $url\n\n";
13 | echo file_get_contents($url);
14 |
--------------------------------------------------------------------------------
/phpunit.xml.dist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | test
5 |
6 |
7 |
8 |
9 | ./src
10 | ./server
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/src/MultiResponseInterface.php:
--------------------------------------------------------------------------------
1 | start();
16 | $server->stop();
17 | $server->stop();
18 | }
19 |
20 | }
21 |
--------------------------------------------------------------------------------
/test/Integration/Mock/ExampleInitializingResponse.php:
--------------------------------------------------------------------------------
1 | headers['X-Did-Call-Init'] = 'YES';
17 | }
18 |
19 | }
20 |
--------------------------------------------------------------------------------
/example/simple.php:
--------------------------------------------------------------------------------
1 | start();
10 |
11 | // We define the server's response to requests of the /definedPath endpoint
12 | $url = $server->setResponseOfPath(
13 | '/definedPath',
14 | new Response(
15 | 'This is our http body response',
16 | [ 'Cache-Control' => 'no-cache' ],
17 | 200
18 | )
19 | );
20 |
21 | echo "Requesting: $url\n\n";
22 |
23 | $content = file_get_contents($url);
24 |
25 | // $http_response_header is a little known variable magically defined
26 | // in the current scope by file_get_contents with the response headers
27 | echo implode("\n", $http_response_header) . "\n\n";
28 | echo $content . "\n";
29 |
--------------------------------------------------------------------------------
/src/ResponseInterface.php:
--------------------------------------------------------------------------------
1 | value or ["Full: Header","OtherFull: Header"]
25 | *
26 | * @internal
27 | */
28 | public function getHeaders( RequestInfo $request ) : array;
29 |
30 | /**
31 | * Get the HTTP Status Code
32 | *
33 | * @internal
34 | */
35 | public function getStatus( RequestInfo $request ) : int;
36 |
37 | }
38 |
--------------------------------------------------------------------------------
/.github/workflows/lint.yml:
--------------------------------------------------------------------------------
1 | on:
2 | - pull_request
3 | - push
4 |
5 | name: Lint
6 |
7 | jobs:
8 | run:
9 | name: Linters
10 |
11 | strategy:
12 | matrix:
13 | operating-system: [ubuntu-latest]
14 | php-versions: ['8.3']
15 |
16 | runs-on: ${{ matrix.operating-system }}
17 |
18 | steps:
19 | - name: Checkout
20 | uses: actions/checkout@v5
21 |
22 | - name: Install PHP
23 | uses: shivammathur/setup-php@v2
24 | with:
25 | php-version: ${{ matrix.php-versions }}
26 | extensions: sockets, json, curl
27 | tools: phan
28 |
29 | - name: Install dependencies with composer
30 | run: composer install
31 |
32 | - name: PHPCS
33 | run: vendor/bin/phpcs
34 |
35 | - name: phan
36 | run: phan --no-progress-bar
37 |
--------------------------------------------------------------------------------
/src/Responses/NotFoundResponse.php:
--------------------------------------------------------------------------------
1 | getParsedUri()['path'];
20 |
21 | return MockWebServer::VND . ": Resource '{$path}' not found!\n";
22 | }
23 |
24 | public function getHeaders( RequestInfo $request ) : array {
25 | return [];
26 | }
27 |
28 | public function getStatus( RequestInfo $request ) : int {
29 | return 404;
30 | }
31 |
32 | }
33 |
--------------------------------------------------------------------------------
/server/server.php:
--------------------------------------------------------------------------------
1 | 'application/json' ];
25 | }
26 |
27 | public function getStatus( RequestInfo $request ) : int {
28 | return 200;
29 | }
30 |
31 | }
32 |
--------------------------------------------------------------------------------
/example/methods.php:
--------------------------------------------------------------------------------
1 | start();
11 |
12 | // Create a response for both a POST and GET request to the same URL
13 |
14 | $response = new ResponseByMethod([
15 | ResponseByMethod::METHOD_GET => new Response("This is our http GET response"),
16 | ResponseByMethod::METHOD_POST => new Response("This is our http POST response", [], 201),
17 | ]);
18 |
19 | $url = $server->setResponseOfPath('/foo/bar', $response);
20 |
21 | foreach( [ ResponseByMethod::METHOD_GET, ResponseByMethod::METHOD_POST ] as $method ) {
22 | echo "$method request to $url:\n";
23 |
24 | $context = stream_context_create([ 'http' => [ 'method' => $method ] ]);
25 | $content = file_get_contents($url, false, $context);
26 |
27 | echo $content . "\n\n";
28 | }
29 |
--------------------------------------------------------------------------------
/test/Integration/MockWebServer_GetRequestByOffset_IntegrationTest.php:
--------------------------------------------------------------------------------
1 | start();
13 |
14 | for( $i = 0; $i <= 80; $i++ ) {
15 | $link = $server->getServerRoot() . '/link' . $i;
16 | $content = @file_get_contents($link);
17 | $this->assertNotFalse($content, "test link $i");
18 | }
19 |
20 | for( $i = 0; $i <= 80; $i++ ) {
21 | $this->assertSame('/link' . $i, $server->getRequestByOffset($i)->getRequestUri(),
22 | "test positive offset alignment");
23 | }
24 |
25 | for( $i = 0; $i <= 80; $i++ ) {
26 | $this->assertSame('/link' . $i, $server->getRequestByOffset(-81 + $i)->getRequestUri(),
27 | "test negative offset alignment");
28 | }
29 | }
30 |
31 | }
32 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | on:
2 | - pull_request
3 | - push
4 |
5 | name: CI
6 |
7 | jobs:
8 | test:
9 | name: Tests
10 |
11 | strategy:
12 | matrix:
13 | operating-system: [ubuntu-latest, windows-latest]
14 | php-versions: ['7.1', '7.2', '7.3', '7.4', '8.0', '8.1', '8.2', '8.3', '8.4', '8.5']
15 |
16 | runs-on: ${{ matrix.operating-system }}
17 |
18 | steps:
19 | - name: Checkout
20 | uses: actions/checkout@v5
21 |
22 | - name: Install PHP
23 | uses: shivammathur/setup-php@v2
24 | with:
25 | php-version: ${{ matrix.php-versions }}
26 | extensions: sockets, json, curl
27 |
28 | - name: Install dependencies with composer
29 | run: composer install
30 |
31 | - name: Test with phpunit
32 | run: vendor/bin/phpunit
33 |
34 | - name: Install PHPStan if PHP >= 7.4
35 | if: startsWith(matrix.php-versions, '7.4') || startsWith(matrix.php-versions, '8.')
36 | run: |
37 | composer require --dev phpstan/phpstan:2.1.17
38 | vendor/bin/phpstan
39 |
--------------------------------------------------------------------------------
/example/notfound.php:
--------------------------------------------------------------------------------
1 | start();
10 |
11 | // The default response is donatj\MockWebServer\Responses\DefaultResponse
12 | // which returns an HTTP 200 and a descriptive JSON payload.
13 | //
14 | // Change the default response to donatj\MockWebServer\Responses\NotFoundResponse
15 | // to get a standard 404.
16 | //
17 | // Any other response may be specified as default as well.
18 | $server->setDefaultResponse(new NotFoundResponse);
19 |
20 | $content = file_get_contents($server->getServerRoot() . '/PageDoesNotExist', false, stream_context_create([
21 | 'http' => [ 'ignore_errors' => true ], // allow reading 404s
22 | ]));
23 |
24 | // $http_response_header is a little known variable magically defined
25 | // in the current scope by file_get_contents with the response headers
26 | echo implode("\n", $http_response_header) . "\n\n";
27 | echo $content . "\n";
28 |
--------------------------------------------------------------------------------
/example/multi.php:
--------------------------------------------------------------------------------
1 | start();
11 |
12 | // We define the servers response to requests of the /definedPath endpoint
13 | $url = $server->setResponseOfPath(
14 | '/definedPath',
15 | new ResponseStack(
16 | new Response("Response One"),
17 | new Response("Response Two")
18 | )
19 | );
20 |
21 | echo "Requesting: $url\n\n";
22 |
23 | $contentOne = file_get_contents($url);
24 | $contentTwo = file_get_contents($url);
25 | // This third request is expected to 404 which will error if errors are not ignored
26 | $contentThree = file_get_contents($url, false, stream_context_create([ 'http' => [ 'ignore_errors' => true ] ]));
27 |
28 | // $http_response_header is a little known variable magically defined
29 | // in the current scope by file_get_contents with the response headers
30 | echo $contentOne . "\n";
31 | echo $contentTwo . "\n";
32 | echo $contentThree . "\n";
33 |
--------------------------------------------------------------------------------
/.github/workflows/cover.yml:
--------------------------------------------------------------------------------
1 | on:
2 | push:
3 | branches:
4 | - master
5 |
6 | name: Coveralls
7 |
8 | jobs:
9 | run:
10 | name: Tests
11 |
12 | strategy:
13 | matrix:
14 | operating-system: [ ubuntu-latest ]
15 | php-versions: [ '8.3' ]
16 |
17 | runs-on: ${{ matrix.operating-system }}
18 |
19 | steps:
20 | - name: Checkout
21 | uses: actions/checkout@v5
22 |
23 | - name: Install PHP
24 | uses: shivammathur/setup-php@v2
25 | with:
26 | php-version: ${{ matrix.php-versions }}
27 | extensions: sockets, json, curl
28 |
29 | - name: Install dependencies with composer
30 | run: composer install
31 |
32 | - name: Run tests
33 | run: XDEBUG_MODE=coverage ./vendor/bin/phpunit --coverage-clover build/logs/clover.xml
34 |
35 | - name: Upload coverage results to Coveralls
36 | env:
37 | COVERALLS_REPO_TOKEN: ${{ secrets.GITHUB_TOKEN }}
38 | run: |
39 | composer global require php-coveralls/php-coveralls
40 | php-coveralls --coverage_clover=build/logs/clover.xml -v
41 |
--------------------------------------------------------------------------------
/example/phpunit.php:
--------------------------------------------------------------------------------
1 | start();
14 | }
15 |
16 | public function testGetParams() : void {
17 | $result = file_get_contents(self::$server->getServerRoot() . '/autoEndpoint?foo=bar');
18 | $decoded = json_decode($result, true);
19 | $this->assertSame('bar', $decoded['_GET']['foo']);
20 | }
21 |
22 | public function testGetSetPath() : void {
23 | // $url = http://127.0.0.1:8123/definedEndPoint
24 | $url = self::$server->setResponseOfPath('/definedEndPoint', new Response('foo bar content'));
25 | $result = file_get_contents($url);
26 | $this->assertSame('foo bar content', $result);
27 | }
28 |
29 | public static function tearDownAfterClass() : void {
30 | // stopping the web server during tear down allows us to reuse the port for later tests
31 | self::$server->stop();
32 | }
33 |
34 | }
35 |
--------------------------------------------------------------------------------
/composer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "donatj/mock-webserver",
3 | "description": "Simple mock web server for unit testing",
4 | "type": "library",
5 | "license": "MIT",
6 | "keywords": [
7 | "webserver", "mock", "testing", "unit testing", "dev", "http", "phpunit"
8 | ],
9 | "authors": [
10 | {
11 | "name": "Jesse G. Donat",
12 | "email": "donatj@gmail.com",
13 | "homepage": "https://donatstudios.com",
14 | "role": "Lead"
15 | }
16 | ],
17 | "require": {
18 | "php": ">=7.1",
19 | "ext-sockets": "*",
20 | "ext-json": "*",
21 | "ralouphie/getallheaders": "~2.0 || ~3.0"
22 | },
23 | "autoload": {
24 | "psr-4": {
25 | "donatj\\MockWebServer\\": "src/"
26 | }
27 | },
28 | "autoload-dev": {
29 | "psr-4": {
30 | "Test\\": "test/"
31 | }
32 | },
33 | "require-dev": {
34 | "donatj/drop": "^1.0",
35 | "phpunit/phpunit": "~7|~9",
36 | "friendsofphp/php-cs-fixer": "^3.1",
37 | "squizlabs/php_codesniffer": "^3.6",
38 | "corpus/coding-standard": "^0.6.0 || ^0.9.0",
39 | "ext-curl": "*"
40 | },
41 | "config": {
42 | "allow-plugins": {
43 | "dealerdirect/phpcodesniffer-composer-installer": true
44 | }
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | The MIT License
2 | ===============
3 |
4 | Copyright (c) 2017 Jesse G. Donat
5 |
6 | Permission is hereby granted, free of charge, to any person obtaining a copy
7 | of this software and associated documentation files (the "Software"), to deal
8 | in the Software without restriction, including without limitation the rights
9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10 | copies of the Software, and to permit persons to whom the Software is
11 | furnished to do so, subject to the following conditions:
12 |
13 | The above copyright notice and this permission notice shall be included in
14 | all copies or substantial portions of the Software.
15 |
16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
22 | THE SOFTWARE.
--------------------------------------------------------------------------------
/src/Response.php:
--------------------------------------------------------------------------------
1 | body = $body;
21 | $this->headers = $headers;
22 | $this->status = $status;
23 | }
24 |
25 | public function getRef() : string {
26 | $content = json_encode([
27 | md5($this->body),
28 | $this->status,
29 | $this->headers,
30 | ]);
31 |
32 | if( $content === false ) {
33 | throw new RuntimeException('Failed to encode response content to JSON: ' . json_last_error_msg());
34 | }
35 |
36 | return md5($content);
37 | }
38 |
39 | public function getBody( RequestInfo $request ) : string {
40 | return $this->body;
41 | }
42 |
43 | public function getHeaders( RequestInfo $request ) : array {
44 | return $this->headers;
45 | }
46 |
47 | public function getStatus( RequestInfo $request ) : int {
48 | return $this->status;
49 | }
50 |
51 | }
52 |
--------------------------------------------------------------------------------
/example/delayed.php:
--------------------------------------------------------------------------------
1 | start();
11 |
12 | $response = new Response(
13 | 'This is our http body response',
14 | [ 'Cache-Control' => 'no-cache' ],
15 | 200
16 | );
17 |
18 | // Wrap the response in a DelayedResponse object, which will delay the response
19 | $delayedResponse = new DelayedResponse(
20 | $response,
21 | 100000 // sets a delay of 100000 microseconds (.1 seconds) before returning the response
22 | );
23 |
24 | $realtimeUrl = $server->setResponseOfPath('/realtime', $response);
25 | $delayedUrl = $server->setResponseOfPath('/delayed', $delayedResponse);
26 |
27 | echo "Requesting: $realtimeUrl\n\n";
28 |
29 | // This request will run as quickly as possible
30 | $start = microtime(true);
31 | file_get_contents($realtimeUrl);
32 | echo "Realtime Request took: " . (microtime(true) - $start) . " seconds\n\n";
33 |
34 | echo "Requesting: $delayedUrl\n\n";
35 |
36 | // The request will take the delayed time + the time it takes to make and transfer the request
37 | $start = microtime(true);
38 | file_get_contents($delayedUrl);
39 | echo "Delayed Request took: " . (microtime(true) - $start) . " seconds\n\n";
40 |
--------------------------------------------------------------------------------
/test/ResponseStackTest.php:
--------------------------------------------------------------------------------
1 | getMockBuilder(RequestInfo::class)->disableOriginalConstructor()->getMock();
14 |
15 | $x = new ResponseStack;
16 |
17 | $this->assertSame('Past the end of the ResponseStack', $x->getBody($mock));
18 | $this->assertSame(404, $x->getStatus($mock));
19 | $this->assertSame([], $x->getHeaders($mock));
20 | $this->assertFalse($x->next());
21 | }
22 |
23 | /**
24 | * @dataProvider customResponseProvider
25 | */
26 | public function testCustomPastEndResponse( $body, $headers, $status ) : void {
27 | $mock = $this->getMockBuilder(RequestInfo::class)->disableOriginalConstructor()->getMock();
28 |
29 | $x = new ResponseStack;
30 | $x->setPastEndResponse(new Response($body, $headers, $status));
31 |
32 | $this->assertSame($body, $x->getBody($mock));
33 | $this->assertSame($status, $x->getStatus($mock));
34 | $this->assertSame($headers, $x->getHeaders($mock));
35 | $this->assertFalse($x->next());
36 | }
37 |
38 | public function customResponseProvider() : array {
39 | return [
40 | [ 'PastEnd', [ 'HeaderA' => 'BVAL' ], 420 ],
41 | [ ' Leading and trailing whitespace ', [], 0 ],
42 | ];
43 | }
44 |
45 | }
46 |
--------------------------------------------------------------------------------
/test/Regression/ResponseByMethod_RegressionTest.php:
--------------------------------------------------------------------------------
1 | setResponseOfPath(
16 | '/interest-categories/cat-xyz/interests',
17 | new ResponseByMethod([
18 | ResponseByMethod::METHOD_GET => new ResponseStack(
19 | new Response('get-a'),
20 | new Response('get-b'),
21 | new Response('get-c')
22 | ),
23 | ResponseByMethod::METHOD_DELETE => new ResponseStack(
24 | new Response('delete-a'),
25 | new Response('delete-b'),
26 | new Response('delete-c')
27 | ),
28 | ])
29 | );
30 |
31 | $server->start();
32 |
33 | $method = function ( string $method ) {
34 | return stream_context_create([ 'http' => [ 'method' => $method ] ]);
35 | };
36 |
37 | $this->assertSame('get-a', file_get_contents($path));
38 | $this->assertSame('delete-a', file_get_contents($path, false, $method('DELETE')));
39 | $this->assertSame('get-b', file_get_contents($path));
40 | $this->assertSame('delete-b', file_get_contents($path, false, $method('DELETE')));
41 | $this->assertSame('delete-c', file_get_contents($path, false, $method('DELETE')));
42 | $this->assertSame(false, @file_get_contents($path, false, $method('DELETE')));
43 | $this->assertSame('get-c', file_get_contents($path));
44 | $this->assertSame(false, @file_get_contents($path));
45 |
46 | $server->stop();
47 | }
48 |
49 | }
50 |
--------------------------------------------------------------------------------
/src/DelayedResponse.php:
--------------------------------------------------------------------------------
1 | response = $response;
28 | $this->delay = $delay;
29 |
30 | $this->usleep = '\\usleep';
31 | if( $usleep ) {
32 | $this->usleep = $usleep;
33 | }
34 | }
35 |
36 | public function getRef() : string {
37 | return md5('delayed.' . $this->response->getRef());
38 | }
39 |
40 | public function initialize( RequestInfo $request ) : void {
41 | ($this->usleep)($this->delay);
42 | }
43 |
44 | public function getBody( RequestInfo $request ) : string {
45 | return $this->response->getBody($request);
46 | }
47 |
48 | public function getHeaders( RequestInfo $request ) : array {
49 | return $this->response->getHeaders($request);
50 | }
51 |
52 | public function getStatus( RequestInfo $request ) : int {
53 | return $this->response->getStatus($request);
54 | }
55 |
56 | public function next() : bool {
57 | if( $this->response instanceof MultiResponseInterface ) {
58 | return $this->response->next();
59 | }
60 |
61 | return false;
62 | }
63 |
64 | }
65 |
--------------------------------------------------------------------------------
/test/Integration/MockWebServer_ChangedDefault_IntegrationTest.php:
--------------------------------------------------------------------------------
1 | start();
15 |
16 | $server->setResponseOfPath('funk', new Response('fresh'));
17 | $path = $server->getUrlOfResponse(new Response('fries'));
18 |
19 | $content = file_get_contents($server->getServerRoot() . '/PageDoesNotExist');
20 | $result = json_decode($content, true);
21 | $this->assertNotFalse(stripos($http_response_header[0], '200 OK'));
22 | $this->assertSame('/PageDoesNotExist', $result['PARSED_REQUEST_URI']['path']);
23 |
24 | // try with a 404
25 | $server->setDefaultResponse(new NotFoundResponse);
26 |
27 | $content = file_get_contents($server->getServerRoot() . '/PageDoesNotExist', false, stream_context_create([
28 | 'http' => [ 'ignore_errors' => true ], // allow reading 404s
29 | ]));
30 |
31 | $this->assertNotFalse(stripos($http_response_header[0], '404 Not Found'));
32 | $this->assertSame("VND.DonatStudios.MockWebServer: Resource '/PageDoesNotExist' not found!\n", $content);
33 |
34 | // try with a custom response
35 | $server->setDefaultResponse(new Response('cool beans'));
36 | $content = file_get_contents($server->getServerRoot() . '/BadUrlBadTime');
37 | $this->assertSame('cool beans', $content);
38 |
39 | // ensure non-404-ing pages continue to work as expected
40 | $content = file_get_contents($server->getServerRoot() . '/funk');
41 | $this->assertSame('fresh', $content);
42 |
43 | $content = file_get_contents($path);
44 | $this->assertSame('fries', $content);
45 | }
46 |
47 | }
48 |
--------------------------------------------------------------------------------
/test/DelayedResponseTest.php:
--------------------------------------------------------------------------------
1 | getMockBuilder(RequestInfo::class)
21 | ->disableOriginalConstructor()
22 | ->getMock();
23 |
24 | $resp->initialize($requestInfo);
25 |
26 | $this->assertSame(1234, $foundDelay);
27 | }
28 |
29 | public function testNext() : void {
30 | $resp = new DelayedResponse(new DefaultResponse, 1234);
31 | $this->assertFalse($resp->next());
32 |
33 | $resp = new DelayedResponse(new DelayedResponse(new DefaultResponse, 1234), 1234);
34 | $this->assertFalse($resp->next());
35 |
36 | $resp = new DelayedResponse(new ResponseStack(
37 | new Response('foo'),
38 | new Response('bar'),
39 | new Response('baz')
40 | ), 1234);
41 |
42 | $req = $this->getMockBuilder(RequestInfo::class)
43 | ->disableOriginalConstructor()
44 | ->getMock();
45 |
46 | $this->assertSame('foo', $resp->getBody($req));
47 | $this->assertTrue($resp->next());
48 | $this->assertSame('bar', $resp->getBody($req));
49 | $this->assertTrue($resp->next());
50 | $this->assertSame('baz', $resp->getBody($req));
51 | $this->assertFalse($resp->next());
52 | }
53 |
54 | public function testGetRef() : void {
55 | $resp1 = new DelayedResponse(new DefaultResponse, 1234);
56 | $this->assertNotFalse(
57 | preg_match('/^[a-f0-9]{32}$/', $resp1->getRef()),
58 | 'Ref must be a 32 character hex string'
59 | );
60 |
61 | $resp2 = new DelayedResponse(new Response('foo'), 1234);
62 | $this->assertNotFalse(
63 | preg_match('/^[a-f0-9]{32}$/', $resp2->getRef()),
64 | 'Ref is a 32 character hex string'
65 | );
66 |
67 | $this->assertNotSame($resp1->getRef(), $resp2->getRef(), 'Ref is unique per response');
68 | }
69 |
70 | }
71 |
--------------------------------------------------------------------------------
/test/InternalServerTest.php:
--------------------------------------------------------------------------------
1 | testTmpDir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'testTemp';
19 | mkdir($this->testTmpDir);
20 |
21 | $counterFileName = $this->testTmpDir . DIRECTORY_SEPARATOR . MockWebServer::REQUEST_COUNT_FILE;
22 | file_put_contents($counterFileName, '0');
23 | }
24 |
25 | /**
26 | * @after
27 | */
28 | public function afterEachTest() : void {
29 | $this->removeTempDirectory();
30 | }
31 |
32 | private function removeTempDirectory() : void {
33 | $it = new \RecursiveDirectoryIterator($this->testTmpDir, \FilesystemIterator::SKIP_DOTS);
34 | $files = new \RecursiveIteratorIterator($it,
35 | \RecursiveIteratorIterator::CHILD_FIRST);
36 |
37 | foreach( $files as $file ) {
38 | if( $file->isDir() ) {
39 | rmdir($file->getRealPath());
40 | } else {
41 | unlink($file->getRealPath());
42 | }
43 | }
44 |
45 | rmdir($this->testTmpDir);
46 | }
47 |
48 | /**
49 | * @dataProvider countProvider
50 | */
51 | public function testShouldIncrementRequestCounter( ?int $inputCount, int $expectedCount ) : void {
52 | $counterFileName = $this->testTmpDir . DIRECTORY_SEPARATOR . MockWebServer::REQUEST_COUNT_FILE;
53 | file_put_contents($counterFileName, '0');
54 |
55 | InternalServer::incrementRequestCounter($this->testTmpDir, $inputCount);
56 | $this->assertStringEqualsFile($counterFileName, (string)$expectedCount);
57 | }
58 |
59 | public function countProvider() : array {
60 | return [
61 | 'null count' => [
62 | 'inputCount' => null,
63 | 'expectedCount' => 1,
64 | ],
65 | 'int count' => [
66 | 'inputCount' => 25,
67 | 'expectedCount' => 25,
68 | ],
69 | ];
70 | }
71 |
72 | public function testShouldLogRequestsOnInstanceCreate() : void {
73 | $fakeReq = new RequestInfo([
74 | 'REQUEST_URI' => '/',
75 | 'REQUEST_METHOD' => 'GET',
76 | ],
77 | [], [], [], [], [], '');
78 | new InternalServer($this->testTmpDir, $fakeReq);
79 |
80 | $lastRequestFile = $this->testTmpDir . DIRECTORY_SEPARATOR . MockWebServer::LAST_REQUEST_FILE;
81 | $requestFile = $this->testTmpDir . DIRECTORY_SEPARATOR . 'request.1';
82 |
83 | $lastRequestContent = file_get_contents($lastRequestFile);
84 | $requestContent = file_get_contents($requestFile);
85 |
86 | $this->assertSame($lastRequestContent, $requestContent);
87 | $this->assertSame(serialize($fakeReq), $requestContent);
88 | }
89 |
90 | }
91 |
--------------------------------------------------------------------------------
/src/ResponseStack.php:
--------------------------------------------------------------------------------
1 | responses[] = $response;
33 |
34 | $refBase .= $response->getRef();
35 | }
36 |
37 | $this->ref = md5($refBase);
38 |
39 | $this->currentResponse = reset($this->responses) ?: null;
40 | $this->pastEndResponse = new Response('Past the end of the ResponseStack', [], 404);
41 | }
42 |
43 | public function initialize( RequestInfo $request ) : void {
44 | if( $this->currentResponse instanceof InitializingResponseInterface ) {
45 | $this->currentResponse->initialize($request);
46 | }
47 | }
48 |
49 | public function next() : bool {
50 | array_shift($this->responses);
51 | $this->currentResponse = reset($this->responses) ?: null;
52 |
53 | return (bool)$this->currentResponse;
54 | }
55 |
56 | public function getRef() : string {
57 | return $this->ref;
58 | }
59 |
60 | public function getBody( RequestInfo $request ) : string {
61 | return $this->currentResponse ?
62 | $this->currentResponse->getBody($request) :
63 | $this->pastEndResponse->getBody($request);
64 | }
65 |
66 | public function getHeaders( RequestInfo $request ) : array {
67 | return $this->currentResponse ?
68 | $this->currentResponse->getHeaders($request) :
69 | $this->pastEndResponse->getHeaders($request);
70 | }
71 |
72 | public function getStatus( RequestInfo $request ) : int {
73 | return $this->currentResponse ?
74 | $this->currentResponse->getStatus($request) :
75 | $this->pastEndResponse->getStatus($request);
76 | }
77 |
78 | /**
79 | * Gets the response returned when the stack is exhausted.
80 | */
81 | public function getPastEndResponse() : ResponseInterface {
82 | return $this->pastEndResponse;
83 | }
84 |
85 | /**
86 | * Set the response to return when the stack is exhausted.
87 | */
88 | public function setPastEndResponse( ResponseInterface $pastEndResponse ) : void {
89 | $this->pastEndResponse = $pastEndResponse;
90 | }
91 |
92 | }
93 |
--------------------------------------------------------------------------------
/.phan/config.php:
--------------------------------------------------------------------------------
1 | '7.1',
15 |
16 | // A list of directories that should be parsed for class and
17 | // method information. After excluding the directories
18 | // defined in exclude_analysis_directory_list, the remaining
19 | // files will be statically analyzed for errors.
20 | //
21 | // Thus, both first-party and third-party code being used by
22 | // your application should be included in this list.
23 | 'directory_list' => [
24 | 'src',
25 | 'vendor/ralouphie',
26 | ],
27 |
28 | "exclude_file_list" => [
29 | ],
30 |
31 | // "exclude_file_regex" => "@\.html\.php$@",
32 |
33 | // A directory list that defines files that will be excluded
34 | // from static analysis, but whose class and method
35 | // information should be included.
36 | //
37 | // Generally, you'll want to include the directories for
38 | // third-party code (such as "vendor/") in this list.
39 | //
40 | // n.b.: If you'd like to parse but not analyze 3rd
41 | // party code, directories containing that code
42 | // should be added to the `directory_list` as
43 | // to `exclude_analysis_directory_list`.
44 | "exclude_analysis_directory_list" => [
45 | 'vendor/',
46 | ],
47 |
48 | 'plugin_config' => [
49 | 'infer_pure_methods' => true,
50 | ],
51 |
52 | // A list of plugin files to execute.
53 | // See https://github.com/phan/phan/tree/master/.phan/plugins for even more.
54 | // (Pass these in as relative paths.
55 | // Base names without extensions such as 'AlwaysReturnPlugin'
56 | // can be used to refer to a plugin that is bundled with Phan)
57 | 'plugins' => [
58 | // checks if a function, closure or method unconditionally returns.
59 |
60 | // can also be written as 'vendor/phan/phan/.phan/plugins/AlwaysReturnPlugin.php'
61 | 'AlwaysReturnPlugin',
62 | // Checks for syntactically unreachable statements in
63 | // the global scope or function bodies.
64 | 'UnreachableCodePlugin',
65 | 'DollarDollarPlugin',
66 | 'DuplicateExpressionPlugin',
67 | 'DuplicateArrayKeyPlugin',
68 | 'PregRegexCheckerPlugin',
69 | 'PrintfCheckerPlugin',
70 | 'PHPUnitNotDeadCodePlugin',
71 | 'LoopVariableReusePlugin',
72 | 'UseReturnValuePlugin',
73 | 'RedundantAssignmentPlugin',
74 | 'InvalidVariableIssetPlugin',
75 | ],
76 |
77 | // Add any issue types (such as 'PhanUndeclaredMethod')
78 | // to this black-list to inhibit them from being reported.
79 | 'suppress_issue_types' => [
80 | 'PhanUnusedPublicMethodParameter',
81 | ],
82 |
83 | 'unused_variable_detection' => true,
84 | ];
85 |
--------------------------------------------------------------------------------
/src/ResponseByMethod.php:
--------------------------------------------------------------------------------
1 | $responses A map of responses keyed by their method.
32 | * @param ResponseInterface|null $defaultResponse The fallthrough response to return if a response for a
33 | * given method is not found. If this is not defined the
34 | * server will return an HTTP 501 error.
35 | */
36 | public function __construct( array $responses = [], ?ResponseInterface $defaultResponse = null ) {
37 | foreach( $responses as $method => $response ) {
38 | $this->setMethodResponse($method, $response);
39 | }
40 |
41 | if( $defaultResponse ) {
42 | $this->defaultResponse = $defaultResponse;
43 | } else {
44 | $this->defaultResponse = new Response('MethodResponse - Method Not Defined', [], 501);
45 | }
46 | }
47 |
48 | public function getRef() : string {
49 | $refBase = $this->defaultResponse->getRef();
50 | foreach( $this->responses as $response ) {
51 | $refBase .= $response->getRef();
52 | }
53 |
54 | return md5($refBase);
55 | }
56 |
57 | public function getBody( RequestInfo $request ) : string {
58 | return $this->getMethodResponse($request)->getBody($request);
59 | }
60 |
61 | public function getHeaders( RequestInfo $request ) : array {
62 | return $this->getMethodResponse($request)->getHeaders($request);
63 | }
64 |
65 | public function getStatus( RequestInfo $request ) : int {
66 | return $this->getMethodResponse($request)->getStatus($request);
67 | }
68 |
69 | private function getMethodResponse( RequestInfo $request ) : ResponseInterface {
70 | $method = $request->getRequestMethod();
71 | $this->latestMethod = $method;
72 |
73 | return $this->responses[$method] ?? $this->defaultResponse;
74 | }
75 |
76 | /**
77 | * Set the Response for the Given Method
78 | */
79 | public function setMethodResponse( string $method, ResponseInterface $response ) : void {
80 | $this->responses[$method] = $response;
81 | }
82 |
83 | public function next() : bool {
84 | $method = $this->latestMethod;
85 | if( !$method ) {
86 | return false;
87 | }
88 |
89 | if( !isset($this->responses[$method]) ) {
90 | return false;
91 | }
92 |
93 | if( !$this->responses[$method] instanceof MultiResponseInterface ) {
94 | return false;
95 | }
96 |
97 | return $this->responses[$method]->next();
98 | }
99 |
100 | }
101 |
--------------------------------------------------------------------------------
/src/RequestInfo.php:
--------------------------------------------------------------------------------
1 | server = $server;
50 | $this->get = $get;
51 | $this->post = $post;
52 | $this->files = $files;
53 | $this->cookie = $cookie;
54 | $this->HEADERS = $HEADERS;
55 | $this->INPUT = $INPUT;
56 |
57 | parse_str($INPUT, $PARSED_INPUT);
58 | $this->PARSED_INPUT = $PARSED_INPUT;
59 |
60 | if( !isset($server['REQUEST_URI']) ) {
61 | throw new RuntimeException('REQUEST_URI not set');
62 | }
63 |
64 | if( !isset($server['REQUEST_METHOD']) ) {
65 | throw new RuntimeException('REQUEST_METHOD not set');
66 | }
67 |
68 | $parsedUrl = parse_url($server['REQUEST_URI']);
69 | if( $parsedUrl === false ) {
70 | throw new RuntimeException('Failed to parse REQUEST_URI: ' . $server['REQUEST_URI']);
71 | }
72 |
73 | $this->parsedUri = $parsedUrl;
74 | }
75 |
76 | /**
77 | * Specify data which should be serialized to JSON
78 | */
79 | public function jsonSerialize() : array {
80 | return [
81 | self::JSON_KEY_GET => $this->get,
82 | self::JSON_KEY_POST => $this->post,
83 | self::JSON_KEY_FILES => $this->files,
84 | self::JSON_KEY_COOKIE => $this->cookie,
85 | self::JSON_KEY_HEADERS => $this->HEADERS,
86 | self::JSON_KEY_METHOD => $this->getRequestMethod(),
87 | self::JSON_KEY_INPUT => $this->INPUT,
88 | self::JSON_KEY_PARSED_INPUT => $this->PARSED_INPUT,
89 | self::JSON_KEY_REQUEST_URI => $this->getRequestUri(),
90 |
91 | self::JSON_KEY_PARSED_REQUEST_URI => $this->parsedUri,
92 | ];
93 | }
94 |
95 | /**
96 | * @return array
97 | */
98 | public function getParsedUri() {
99 | return $this->parsedUri;
100 | }
101 |
102 | public function getRequestUri() : string {
103 | return $this->server['REQUEST_URI'];
104 | }
105 |
106 | public function getRequestMethod() : string {
107 | return $this->server['REQUEST_METHOD'];
108 | }
109 |
110 | public function getServer() : array {
111 | return $this->server;
112 | }
113 |
114 | public function getGet() : array {
115 | return $this->get;
116 | }
117 |
118 | public function getPost() : array {
119 | return $this->post;
120 | }
121 |
122 | public function getFiles() : array {
123 | return $this->files;
124 | }
125 |
126 | public function getCookie() : array {
127 | return $this->cookie;
128 | }
129 |
130 | public function getHeaders() : array {
131 | return $this->HEADERS;
132 | }
133 |
134 | public function getInput() : string {
135 | return $this->INPUT;
136 | }
137 |
138 | public function getParsedInput() : ?array {
139 | return $this->PARSED_INPUT;
140 | }
141 |
142 | }
143 |
--------------------------------------------------------------------------------
/mddoc.xml:
--------------------------------------------------------------------------------
1 |
2 |
4 |
5 |
6 |
7 |
8 |
9 |
15 |
16 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
29 |
30 |
31 |
32 |
33 |
36 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 | Outputs:
47 |
48 |
49 |
50 |
51 | Outputs:
52 |
53 |
54 |
55 |
56 | Outputs:
57 |
58 |
59 |
62 |
63 |
64 |
65 | Outputs:
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 | If you need an ordered set of responses, that can be done using the ResponseStack.
76 |
77 | Outputs:
78 |
79 |
80 |
81 | If you need to vary responses to a single endpoint by method, you can do that using the ResponseByMethod response object.
82 |
83 | Outputs:
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
93 |
--------------------------------------------------------------------------------
/.php-cs-fixer.dist.php:
--------------------------------------------------------------------------------
1 | files()
5 | ->in(__DIR__ . '/src')
6 | ->in(__DIR__ . '/test')
7 | ->in(__DIR__ . '/example')
8 | ->name('*.php');
9 |
10 | $finder->files()->append([__DIR__ . 'composer/bin/mddoc']);
11 |
12 | return (new PhpCsFixer\Config)
13 | ->setUsingCache(true)
14 | ->setIndent("\t")
15 | ->setLineEnding("\n")
16 | //->setUsingLinter(false)
17 | ->setRiskyAllowed(true)
18 | ->setRules(
19 | [
20 | '@PHPUnit60Migration:risky' => true,
21 | 'php_unit_test_case_static_method_calls' => [
22 | 'call_type' => 'this',
23 | ],
24 |
25 | 'concat_space' => [
26 | 'spacing' => 'one',
27 | ],
28 |
29 | 'visibility_required' => true,
30 | 'indentation_type' => true,
31 | 'no_useless_return' => true,
32 |
33 | 'switch_case_space' => true,
34 | 'switch_case_semicolon_to_colon' => true,
35 |
36 | 'array_syntax' => [ 'syntax' => 'short' ],
37 | 'list_syntax' => [ 'syntax' => 'short' ],
38 |
39 | 'no_leading_import_slash' => true,
40 | 'no_leading_namespace_whitespace' => true,
41 |
42 | 'no_whitespace_in_blank_line' => true,
43 |
44 | 'phpdoc_add_missing_param_annotation' => [ 'only_untyped' => true, ],
45 | 'phpdoc_indent' => true,
46 |
47 | 'phpdoc_no_alias_tag' => true,
48 | 'phpdoc_no_package' => true,
49 | 'phpdoc_no_useless_inheritdoc' => true,
50 |
51 | 'phpdoc_order' => true,
52 | 'phpdoc_scalar' => true,
53 | 'phpdoc_single_line_var_spacing' => true,
54 |
55 | 'phpdoc_var_annotation_correct_order' => true,
56 |
57 | 'phpdoc_trim' => true,
58 | 'phpdoc_trim_consecutive_blank_line_separation' => true,
59 |
60 | 'phpdoc_types' => true,
61 | 'phpdoc_types_order' => [
62 | 'null_adjustment' => 'always_last',
63 | 'sort_algorithm' => 'alpha',
64 | ],
65 |
66 | 'phpdoc_align' => [
67 | 'align' => 'vertical',
68 | 'tags' => [ 'param' ],
69 | ],
70 |
71 | 'phpdoc_line_span' => [
72 | 'const' => 'single',
73 | 'method' => 'multi',
74 | 'property' => 'single',
75 | ],
76 |
77 | 'short_scalar_cast' => true,
78 |
79 | 'standardize_not_equals' => true,
80 | 'ternary_operator_spaces' => true,
81 | 'no_spaces_after_function_name' => true,
82 | 'no_unneeded_control_parentheses' => true,
83 |
84 | 'return_type_declaration' => [
85 | 'space_before' => 'one',
86 | ],
87 |
88 | 'single_line_after_imports' => true,
89 | 'single_blank_line_before_namespace' => true,
90 | 'blank_line_after_namespace' => true,
91 | 'single_blank_line_at_eof' => true,
92 | 'ternary_to_null_coalescing' => true,
93 | 'whitespace_after_comma_in_array' => true,
94 |
95 | 'cast_spaces' => [ 'space' => 'none' ],
96 |
97 | 'encoding' => true,
98 |
99 | 'space_after_semicolon' => [
100 | 'remove_in_empty_for_expressions' => true,
101 | ],
102 |
103 | 'align_multiline_comment' => [
104 | 'comment_type' => 'phpdocs_like',
105 | ],
106 |
107 | 'blank_line_before_statement' => [
108 | 'statements' => [ 'continue', 'try', 'switch', 'exit', 'throw', 'return', 'do' ],
109 | ],
110 |
111 | 'no_superfluous_phpdoc_tags' => [
112 | 'remove_inheritdoc' => true,
113 | ],
114 | 'no_superfluous_elseif' => true,
115 |
116 | 'no_useless_else' => true,
117 |
118 | 'combine_consecutive_issets' => true,
119 | 'escape_implicit_backslashes' => true,
120 | 'explicit_indirect_variable' => true,
121 | 'heredoc_to_nowdoc' => true,
122 |
123 |
124 | 'no_singleline_whitespace_before_semicolons' => true,
125 | 'no_null_property_initialization' => true,
126 | 'no_whitespace_before_comma_in_array' => true,
127 |
128 | 'no_empty_phpdoc' => true,
129 | 'no_empty_statement' => true,
130 | 'no_empty_comment' => true,
131 | 'no_extra_blank_lines' => true,
132 | 'no_blank_lines_after_phpdoc' => true,
133 |
134 | 'no_spaces_around_offset' => [
135 | 'positions' => [ 'outside' ],
136 | ],
137 |
138 | 'return_assignment' => true,
139 | 'lowercase_static_reference' => true,
140 |
141 | 'method_chaining_indentation' => true,
142 | 'method_argument_space' => [
143 | 'on_multiline' => 'ignore', // at least until they fix it
144 | 'keep_multiple_spaces_after_comma' => true,
145 | ],
146 |
147 | 'multiline_comment_opening_closing' => true,
148 |
149 | 'include' => true,
150 | 'elseif' => true,
151 |
152 | 'simple_to_complex_string_variable' => true,
153 |
154 | 'global_namespace_import' => [
155 | 'import_classes' => false,
156 | 'import_constants' => false,
157 | 'import_functions' => false,
158 | ],
159 |
160 | 'trailing_comma_in_multiline' => true,
161 | 'single_line_comment_style' => true,
162 |
163 | 'is_null' => true,
164 | 'yoda_style' => [
165 | 'equal' => false,
166 | 'identical' => false,
167 | 'less_and_greater' => null,
168 | ],
169 | ]
170 | )
171 | ->setFinder($finder);
172 |
173 |
174 |
--------------------------------------------------------------------------------
/src/InternalServer.php:
--------------------------------------------------------------------------------
1 | tmpPath = $tmpPath;
42 |
43 | $count = self::incrementRequestCounter($this->tmpPath);
44 | $this->logRequest($request, $count);
45 |
46 | $this->request = $request;
47 | $this->header = $header;
48 | $this->httpResponseCode = $httpResponseCode;
49 | }
50 |
51 | /**
52 | * @internal
53 | */
54 | public static function incrementRequestCounter( string $tmpPath, ?int $int = null ) : int {
55 | $countFile = $tmpPath . DIRECTORY_SEPARATOR . MockWebServer::REQUEST_COUNT_FILE;
56 |
57 | if( $int === null ) {
58 | $newInt = file_get_contents($countFile);
59 | if( !is_string($newInt) ) {
60 | throw new ServerException('failed to fetch request count');
61 | }
62 |
63 | $int = (int)$newInt + 1;
64 | }
65 |
66 | file_put_contents($countFile, (string)$int);
67 |
68 | return (int)$int;
69 | }
70 |
71 | private function logRequest( RequestInfo $request, int $count ) : void {
72 | $reqStr = serialize($request);
73 | file_put_contents($this->tmpPath . DIRECTORY_SEPARATOR . MockWebServer::LAST_REQUEST_FILE, $reqStr);
74 | file_put_contents($this->tmpPath . DIRECTORY_SEPARATOR . 'request.' . $count, $reqStr);
75 | }
76 |
77 | /**
78 | * @internal
79 | */
80 | public static function aliasPath( string $tmpPath, string $path ) : string {
81 | $path = '/' . ltrim($path, '/');
82 |
83 | return sprintf('%s%salias.%s',
84 | $tmpPath,
85 | DIRECTORY_SEPARATOR,
86 | md5($path)
87 | );
88 | }
89 |
90 | private function responseForRef( string $ref ) : ?ResponseInterface {
91 | $path = $this->tmpPath . DIRECTORY_SEPARATOR . $ref;
92 | if( !is_readable($path) ) {
93 | return null;
94 | }
95 |
96 | $content = file_get_contents($path);
97 | if( $content === false ) {
98 | throw new ServerException('failed to read response content');
99 | }
100 |
101 | $response = unserialize($content);
102 | if( !$response instanceof ResponseInterface ) {
103 | throw new ServerException('invalid serialized response');
104 | }
105 |
106 | return $response;
107 | }
108 |
109 | public function __invoke() : void {
110 | $ref = $this->getRefForUri($this->request->getParsedUri()['path']);
111 |
112 | if( $ref !== null ) {
113 | $response = $this->responseForRef($ref);
114 | if( $response ) {
115 | $this->sendResponse($response);
116 |
117 | return;
118 | }
119 |
120 | $this->sendResponse(new NotFoundResponse);
121 |
122 | return;
123 | }
124 |
125 | $response = $this->responseForRef(self::DEFAULT_REF);
126 | if( $response ) {
127 | $this->sendResponse($response);
128 |
129 | return;
130 | }
131 |
132 | $this->sendResponse(new DefaultResponse);
133 | }
134 |
135 | protected function sendResponse( ResponseInterface $response ) : void {
136 | if( $response instanceof InitializingResponseInterface ) {
137 | $response->initialize($this->request);
138 | }
139 |
140 | ($this->httpResponseCode)($response->getStatus($this->request));
141 |
142 | foreach( $response->getHeaders($this->request) as $key => $header ) {
143 | if( is_int($key) ) {
144 | ($this->header)($header);
145 | } else {
146 | ($this->header)("{$key}: {$header}");
147 | }
148 | }
149 |
150 | echo $response->getBody($this->request);
151 |
152 | if( $response instanceof MultiResponseInterface ) {
153 | $response->next();
154 | self::storeResponse($this->tmpPath, $response);
155 | }
156 | }
157 |
158 | protected function getRefForUri( string $uriPath ) : ?string {
159 | $aliasPath = self::aliasPath($this->tmpPath, $uriPath);
160 |
161 | if( file_exists($aliasPath) ) {
162 | if( $path = file_get_contents($aliasPath) ) {
163 | return $path;
164 | }
165 | } elseif( preg_match('%^/' . preg_quote(MockWebServer::VND, '%') . '/([0-9a-fA-F]{32})$%', $uriPath, $matches) ) {
166 | return $matches[1];
167 | }
168 |
169 | return null;
170 | }
171 |
172 | public static function getPathOfRef( string $ref ) : string {
173 | return '/' . MockWebServer::VND . '/' . $ref;
174 | }
175 |
176 | /**
177 | * @internal
178 | */
179 | public static function storeResponse( string $tmpPath, ResponseInterface $response ) : string {
180 | $ref = $response->getRef();
181 | self::storeRef($response, $tmpPath, $ref);
182 |
183 | return $ref;
184 | }
185 |
186 | /**
187 | * @internal
188 | */
189 | public static function storeDefaultResponse( string $tmpPath, ResponseInterface $response ) : void {
190 | self::storeRef($response, $tmpPath, self::DEFAULT_REF);
191 | }
192 |
193 | private static function storeRef( ResponseInterface $response, string $tmpPath, string $ref ) : void {
194 | $content = serialize($response);
195 |
196 | if( !file_put_contents($tmpPath . DIRECTORY_SEPARATOR . $ref, $content) ) {
197 | throw new Exceptions\RuntimeException('Failed to write temporary content');
198 | }
199 | }
200 |
201 | }
202 |
--------------------------------------------------------------------------------
/test/Integration/InternalServer_IntegrationTest.php:
--------------------------------------------------------------------------------
1 | $method,
34 | 'REQUEST_URI' => $uri,
35 | ],
36 | $GET, $POST, $FILES, $COOKIE, $HEADERS, ''
37 | );
38 | }
39 |
40 | public function testInternalServer_DefaultResponse() : void {
41 | $tmp = $this->getTempDirectory();
42 |
43 | $headers = [];
44 | $header = static function ( $header ) use ( &$headers ) {
45 | $headers[] = $header;
46 | };
47 |
48 | $statusCode = null;
49 | $httpResponseCode = static function ( $code ) use ( &$statusCode ) {
50 | $statusCode = $code;
51 | };
52 |
53 | $r = $this->getRequestInfo('/test?foo=bar&baz[]=qux&baz[]=quux', [ 'foo' => 1 ], [ 'baz' => 2 ]);
54 |
55 | $server = new InternalServer($tmp, $r, $header, $httpResponseCode);
56 |
57 | ob_start();
58 | $server();
59 | $contents = ob_get_clean();
60 |
61 | $body = json_decode($contents, true);
62 |
63 | $this->assertSame(200, $statusCode);
64 |
65 | $this->assertSame([
66 | 'Content-Type: application/json',
67 | ], $headers);
68 |
69 | $expectedBody = [
70 | '_GET' => [
71 | 'foo' => 1,
72 | ],
73 | '_POST' => [
74 | 'baz' => 2,
75 | ],
76 | '_FILES' => [
77 | ],
78 | '_COOKIE' => [
79 | ],
80 | 'HEADERS' => [
81 | ],
82 | 'METHOD' => 'GET',
83 | 'INPUT' => '',
84 | 'PARSED_INPUT' => [
85 | ],
86 | 'REQUEST_URI' => '/test?foo=bar&baz[]=qux&baz[]=quux',
87 | 'PARSED_REQUEST_URI' => [
88 | 'path' => '/test',
89 | 'query' => 'foo=bar&baz[]=qux&baz[]=quux',
90 | ],
91 | ];
92 |
93 | $this->assertSame($expectedBody, $body);
94 | }
95 |
96 | /**
97 | * @dataProvider provideBodyWithContentType
98 | */
99 | public function testInternalServer_CustomResponse( string $body, string $contentType ) : void {
100 | $tmp = $this->getTempDirectory();
101 |
102 | $headers = [];
103 | $header = static function ( $header ) use ( &$headers ) {
104 | $headers[] = $header;
105 | };
106 |
107 | $statusCode = null;
108 | $httpResponseCode = static function ( $code ) use ( &$statusCode ) {
109 | $statusCode = $code;
110 | };
111 |
112 | $response = new Response($body, [ 'Content-Type' => $contentType ], 200);
113 |
114 | $r = $this->getRequestInfo(InternalServer::getPathOfRef($response->getRef()));
115 |
116 | InternalServer::storeResponse($tmp, $response);
117 | $server = new InternalServer($tmp, $r, $header, $httpResponseCode);
118 |
119 | ob_start();
120 | $server();
121 | $contents = ob_get_clean();
122 |
123 | $this->assertSame(200, $statusCode);
124 |
125 | $this->assertSame([
126 | 'Content-Type: ' . $contentType,
127 | ], $headers);
128 |
129 | $this->assertSame($body, $contents);
130 | }
131 |
132 | public function provideBodyWithContentType() : \Generator {
133 | yield [ 'Hello World!', 'text/plain; charset=UTF-8' ];
134 | yield [ '{"foo":"bar"}', 'application/json' ];
135 | yield [ '
Test
', 'text/html' ];
136 | }
137 |
138 | public function testInternalServer_DefaultResponseFallthrough() : void {
139 | $tmp = $this->getTempDirectory();
140 |
141 | $headers = [];
142 | $header = static function ( $header ) use ( &$headers ) {
143 | $headers[] = $header;
144 | };
145 |
146 | $statusCode = null;
147 | $httpResponseCode = static function ( $code ) use ( &$statusCode ) {
148 | $statusCode = $code;
149 | };
150 |
151 | $response = new Response('Default Response!!!', [ 'Default' => 'Response!' ], 400);
152 |
153 | $r = $this->getRequestInfo('/any/invalid/response');
154 |
155 | InternalServer::storeDefaultResponse($tmp, $response);
156 | $server = new InternalServer($tmp, $r, $header, $httpResponseCode);
157 |
158 | ob_start();
159 | $server();
160 | $contents = ob_get_clean();
161 |
162 | $this->assertSame(400, $statusCode);
163 |
164 | $this->assertSame([
165 | 'Default: Response!',
166 | ], $headers);
167 |
168 | $this->assertSame('Default Response!!!', $contents);
169 | }
170 |
171 | public function testInternalServer_InitializingResponse() : void {
172 | $tmp = $this->getTempDirectory();
173 |
174 | $response = new ExampleInitializingResponse;
175 |
176 | $headers = [];
177 | $header = static function ( $header ) use ( &$headers ) {
178 | $headers[] = $header;
179 | };
180 |
181 | $r = $this->getRequestInfo(InternalServer::getPathOfRef($response->getRef()));
182 |
183 | InternalServer::storeResponse($tmp, $response);
184 | $server = new InternalServer($tmp, $r, $header, function () { });
185 |
186 | ob_start();
187 | $server();
188 | ob_end_clean();
189 |
190 | $this->assertSame([ 'X-Did-Call-Init: YES' ], $headers);
191 | }
192 |
193 | public function testInternalServer_InvalidRef404() : void {
194 | $tmp = $this->getTempDirectory();
195 |
196 | $headers = [];
197 | $header = static function ( $header ) use ( &$headers ) {
198 | $headers[] = $header;
199 | };
200 |
201 | $statusCode = null;
202 | $httpResponseCode = static function ( $code ) use ( &$statusCode ) {
203 | $statusCode = $code;
204 | };
205 |
206 | $r = $this->getRequestInfo(InternalServer::getPathOfRef(str_repeat('a', 32)));
207 |
208 | $server = new InternalServer($tmp, $r, $header, $httpResponseCode);
209 |
210 | ob_start();
211 | $server();
212 | $contents = ob_get_clean();
213 |
214 | $this->assertSame(404, $statusCode);
215 | $this->assertSame([], $headers);
216 | $this->assertSame("VND.DonatStudios.MockWebServer: Resource '/VND.DonatStudios.MockWebServer/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' not found!\n", $contents);
217 | }
218 |
219 | }
220 |
--------------------------------------------------------------------------------
/docs/docs.md:
--------------------------------------------------------------------------------
1 | # Documentation
2 |
3 | ## Class: donatj\MockWebServer\MockWebServer
4 |
5 | ```php
6 | __construct
18 |
19 | ```php
20 | function __construct([ int $port = 0 [, string $host = '127.0.0.1']])
21 | ```
22 |
23 | TestWebServer constructor.
24 |
25 | #### Parameters:
26 |
27 | - ***int*** `$port` - Network port to run on
28 | - ***string*** `$host` - Listening hostname
29 |
30 | ---
31 |
32 | ### Method: MockWebServer->start
33 |
34 | ```php
35 | function start() : void
36 | ```
37 |
38 | Start the Web Server on the selected port and host
39 |
40 | ---
41 |
42 | ### Method: MockWebServer->isRunning
43 |
44 | ```php
45 | function isRunning() : bool
46 | ```
47 |
48 | Is the Web Server currently running?
49 |
50 | ---
51 |
52 | ### Method: MockWebServer->stop
53 |
54 | ```php
55 | function stop() : void
56 | ```
57 |
58 | Stop the Web Server
59 |
60 | ---
61 |
62 | ### Method: MockWebServer->getServerRoot
63 |
64 | ```php
65 | function getServerRoot() : string
66 | ```
67 |
68 | Get the HTTP root of the webserver
69 | e.g.: http://127.0.0.1:8123
70 |
71 | ---
72 |
73 | ### Method: MockWebServer->getUrlOfResponse
74 |
75 | ```php
76 | function getUrlOfResponse(\donatj\MockWebServer\ResponseInterface $response) : string
77 | ```
78 |
79 | Get a URL providing the specified response.
80 |
81 | #### Returns:
82 |
83 | - ***string*** - URL where response can be found
84 |
85 | ---
86 |
87 | ### Method: MockWebServer->setResponseOfPath
88 |
89 | ```php
90 | function setResponseOfPath(string $path, \donatj\MockWebServer\ResponseInterface $response) : string
91 | ```
92 |
93 | Set a specified path to provide a specific response
94 |
95 | ---
96 |
97 | ### Method: MockWebServer->setDefaultResponse
98 |
99 | ```php
100 | function setDefaultResponse(\donatj\MockWebServer\ResponseInterface $response) : void
101 | ```
102 |
103 | Override the default server response, e.g. Fallback or 404
104 |
105 | ---
106 |
107 | ### Method: MockWebServer->getLastRequest
108 |
109 | ```php
110 | function getLastRequest() : ?\donatj\MockWebServer\RequestInfo
111 | ```
112 |
113 | Get the previous requests associated request data.
114 |
115 | ---
116 |
117 | ### Method: MockWebServer->getRequestByOffset
118 |
119 | ```php
120 | function getRequestByOffset(int $offset) : ?\donatj\MockWebServer\RequestInfo
121 | ```
122 |
123 | Get request by offset
124 |
125 | If offset is non-negative, the request will be the index from the start of the server.
126 | If offset is negative, the request will be that from the end of the requests.
127 |
128 | ---
129 |
130 | ### Method: MockWebServer->getHost
131 |
132 | ```php
133 | function getHost() : string
134 | ```
135 |
136 | Get the host of the server.
137 |
138 | ---
139 |
140 | ### Method: MockWebServer->getPort
141 |
142 | ```php
143 | function getPort() : int
144 | ```
145 |
146 | Get the port the network server is to be ran on.
147 |
148 | ## Class: donatj\MockWebServer\Response
149 |
150 | ### Method: Response->__construct
151 |
152 | ```php
153 | function __construct(string $body [, array $headers = [] [, int $status = 200]])
154 | ```
155 |
156 | Response constructor.
157 |
158 | ## Class: donatj\MockWebServer\ResponseStack
159 |
160 | ResponseStack is used to store multiple responses for a request issued by the server in order.
161 |
162 | When the stack is empty, the server will return a customizable response defaulting to a 404.
163 |
164 | ### Method: ResponseStack->__construct
165 |
166 | ```php
167 | function __construct(\donatj\MockWebServer\ResponseInterface ...$responses)
168 | ```
169 |
170 | ResponseStack constructor.
171 |
172 | Accepts a variable number of ResponseInterface objects
173 |
174 | ---
175 |
176 | ### Method: ResponseStack->getPastEndResponse
177 |
178 | ```php
179 | function getPastEndResponse() : \donatj\MockWebServer\ResponseInterface
180 | ```
181 |
182 | Gets the response returned when the stack is exhausted.
183 |
184 | ---
185 |
186 | ### Method: ResponseStack->setPastEndResponse
187 |
188 | ```php
189 | function setPastEndResponse(\donatj\MockWebServer\ResponseInterface $pastEndResponse) : void
190 | ```
191 |
192 | Set the response to return when the stack is exhausted.
193 |
194 | ## Class: donatj\MockWebServer\ResponseByMethod
195 |
196 | ResponseByMethod is used to vary the response to a request by the called HTTP Method.
197 |
198 | ```php
199 | __construct
215 |
216 | ```php
217 | function __construct([ array $responses = [] [, ?\donatj\MockWebServer\ResponseInterface $defaultResponse = null]])
218 | ```
219 |
220 | MethodResponse constructor.
221 |
222 | #### Parameters:
223 |
224 | - ***array*** `$responses` - A map of responses keyed by their method.
225 | - ***\donatj\MockWebServer\ResponseInterface*** | ***null*** `$defaultResponse` - The fallthrough response to return if a response for a given
226 | method is not found. If this is not defined the server will
227 | return an HTTP 501 error.
228 |
229 | ---
230 |
231 | ### Method: ResponseByMethod->setMethodResponse
232 |
233 | ```php
234 | function setMethodResponse(string $method, \donatj\MockWebServer\ResponseInterface $response) : void
235 | ```
236 |
237 | Set the Response for the Given Method
238 |
239 | ## Class: donatj\MockWebServer\DelayedResponse
240 |
241 | DelayedResponse wraps a response, causing it when called to be delayed by a specified number of microseconds.
242 |
243 | This is useful for simulating slow responses and testing timeouts.
244 |
245 | ### Method: DelayedResponse->__construct
246 |
247 | ```php
248 | function __construct(\donatj\MockWebServer\ResponseInterface $response, int $delay [, ?callable $usleep = null])
249 | ```
250 |
251 | #### Parameters:
252 |
253 | - ***int*** `$delay` - Microseconds to delay the response
254 |
255 | ## Built-In Responses
256 |
257 | ### Class: donatj\MockWebServer\Responses\DefaultResponse
258 |
259 | The Built-In Default Response.
260 |
261 | Results in an HTTP 200 with a JSON encoded version of the incoming Request
262 |
263 | ### Class: donatj\MockWebServer\Responses\NotFoundResponse
264 |
265 | Basic Built-In 404 Response
--------------------------------------------------------------------------------
/src/MockWebServer.php:
--------------------------------------------------------------------------------
1 | host = $host;
47 | $this->port = $port;
48 | if( $this->port === 0 ) {
49 | $this->port = $this->findOpenPort();
50 | }
51 |
52 | $this->tmpDir = $this->getTmpDir();
53 | }
54 |
55 | /**
56 | * Start the Web Server on the selected port and host
57 | */
58 | public function start() : void {
59 | if( $this->isRunning() ) {
60 | return;
61 | }
62 |
63 | $script = __DIR__ . DIRECTORY_SEPARATOR . '..' . DIRECTORY_SEPARATOR . 'server' . DIRECTORY_SEPARATOR . 'server.php';
64 |
65 | $stdout = tempnam(sys_get_temp_dir(), 'mockserv-stdout-');
66 | $cmd = sprintf("php -S %s:%d %s", $this->host, $this->port, escapeshellarg($script));
67 |
68 | if( !putenv(self::TMP_ENV . '=' . $this->tmpDir) ) {
69 | throw new Exceptions\RuntimeException('Unable to put environmental variable');
70 | }
71 |
72 | $fullCmd = sprintf('%s > %s 2>&1',
73 | $cmd,
74 | $stdout
75 | );
76 |
77 | InternalServer::incrementRequestCounter($this->tmpDir, 0);
78 |
79 | [ $this->process, $this->descriptors ] = $this->startServer($fullCmd);
80 |
81 | for( $i = 0; $i <= 20; $i++ ) {
82 | usleep(100000);
83 |
84 | $open = @fsockopen($this->host, $this->port);
85 | if( is_resource($open) ) {
86 | fclose($open);
87 | break;
88 | }
89 | }
90 |
91 | if( !$this->isRunning() ) {
92 | throw new Exceptions\ServerException("Failed to start server. Is something already running on port {$this->port}?");
93 | }
94 |
95 | register_shutdown_function(function () {
96 | if( $this->isRunning() ) {
97 | $this->stop();
98 | }
99 | });
100 | }
101 |
102 | /**
103 | * Is the Web Server currently running?
104 | */
105 | public function isRunning() : bool {
106 | if( !is_resource($this->process) ) {
107 | return false;
108 | }
109 |
110 | $processStatus = proc_get_status($this->process);
111 |
112 | if( !$processStatus ) {
113 | return false;
114 | }
115 |
116 | return $processStatus['running'];
117 | }
118 |
119 | /**
120 | * Stop the Web Server
121 | */
122 | public function stop() : void {
123 | if( $this->isRunning() ) {
124 | proc_terminate($this->process);
125 |
126 | $attempts = 0;
127 | while( $this->isRunning() ) {
128 | if( ++$attempts > 1000 ) {
129 | throw new Exceptions\ServerException('Failed to stop server.');
130 | }
131 |
132 | usleep(10000);
133 | }
134 | }
135 |
136 | foreach( $this->descriptors as $descriptor ) {
137 | @fclose($descriptor);
138 | }
139 |
140 | $this->descriptors = [];
141 | }
142 |
143 | /**
144 | * Get the HTTP root of the webserver
145 | * e.g.: http://127.0.0.1:8123
146 | */
147 | public function getServerRoot() : string {
148 | return "http://{$this->host}:{$this->port}";
149 | }
150 |
151 | /**
152 | * Get a URL providing the specified response.
153 | *
154 | * @return string URL where response can be found
155 | */
156 | public function getUrlOfResponse( ResponseInterface $response ) : string {
157 | $ref = InternalServer::storeResponse($this->tmpDir, $response);
158 |
159 | return $this->getServerRoot() . InternalServer::getPathOfRef($ref);
160 | }
161 |
162 | /**
163 | * Set a specified path to provide a specific response
164 | */
165 | public function setResponseOfPath( string $path, ResponseInterface $response ) : string {
166 | $ref = InternalServer::storeResponse($this->tmpDir, $response);
167 |
168 | $aliasPath = InternalServer::aliasPath($this->tmpDir, $path);
169 |
170 | if( !file_put_contents($aliasPath, $ref) ) {
171 | throw new \RuntimeException('Failed to store path alias');
172 | }
173 |
174 | return $this->getServerRoot() . $path;
175 | }
176 |
177 | /**
178 | * Override the default server response, e.g. Fallback or 404
179 | */
180 | public function setDefaultResponse( ResponseInterface $response ) : void {
181 | InternalServer::storeDefaultResponse($this->tmpDir, $response);
182 | }
183 |
184 | /**
185 | * @internal
186 | */
187 | private function getTmpDir() : string {
188 | $tmpDir = sys_get_temp_dir() ?: '/tmp';
189 | if( !is_dir($tmpDir) || !is_writable($tmpDir) ) {
190 | throw new \RuntimeException('Unable to find system tmp directory');
191 | }
192 |
193 | $tmpPath = $tmpDir . DIRECTORY_SEPARATOR . 'MockWebServer';
194 | if( !is_dir($tmpPath) ) {
195 | if( !mkdir($tmpPath) && !is_dir($tmpPath) ) {
196 | throw new \RuntimeException(sprintf('Directory "%s" was not created', $tmpPath));
197 | }
198 | }
199 |
200 | $tmpPath .= DIRECTORY_SEPARATOR . $this->port;
201 | if( !is_dir($tmpPath) ) {
202 | if( !mkdir($tmpPath) && !is_dir($tmpPath) ) {
203 | throw new \RuntimeException(sprintf('Directory "%s" was not created', $tmpPath));
204 | }
205 | }
206 |
207 | $tmpPath .= DIRECTORY_SEPARATOR . md5(microtime(true) . ':' . rand(0, 100000));
208 | if( !is_dir($tmpPath) ) {
209 | if( !mkdir($tmpPath) && !is_dir($tmpPath) ) {
210 | throw new \RuntimeException(sprintf('Directory "%s" was not created', $tmpPath));
211 | }
212 | }
213 |
214 | return $tmpPath;
215 | }
216 |
217 | /**
218 | * Get the previous requests associated request data.
219 | */
220 | public function getLastRequest() : ?RequestInfo {
221 | $path = $this->tmpDir . DIRECTORY_SEPARATOR . self::LAST_REQUEST_FILE;
222 | if( file_exists($path) ) {
223 | $content = file_get_contents($path);
224 | if( $content === false ) {
225 | throw new RuntimeException('failed to read last request');
226 | }
227 |
228 | $data = @unserialize($content);
229 | if( $data instanceof RequestInfo ) {
230 | return $data;
231 | }
232 | }
233 |
234 | return null;
235 | }
236 |
237 | /**
238 | * Get request by offset
239 | *
240 | * If offset is non-negative, the request will be the index from the start of the server.
241 | * If offset is negative, the request will be that from the end of the requests.
242 | */
243 | public function getRequestByOffset( int $offset ) : ?RequestInfo {
244 | $reqs = glob($this->tmpDir . DIRECTORY_SEPARATOR . 'request.*') ?: [];
245 | natsort($reqs);
246 |
247 | $item = array_slice($reqs, $offset, 1);
248 | if( !$item ) {
249 | return null;
250 | }
251 |
252 | $path = reset($item);
253 | if( !$path ) {
254 | return null;
255 | }
256 |
257 | $content = file_get_contents($path);
258 | if( $content === false ) {
259 | throw new RuntimeException("failed to read request from '{$path}'");
260 | }
261 |
262 | $data = @unserialize($content);
263 | if( $data instanceof RequestInfo ) {
264 | return $data;
265 | }
266 |
267 | return null;
268 | }
269 |
270 | /**
271 | * Get the host of the server.
272 | */
273 | public function getHost() : string {
274 | return $this->host;
275 | }
276 |
277 | /**
278 | * Get the port the network server is to be ran on.
279 | */
280 | public function getPort() : int {
281 | return $this->port;
282 | }
283 |
284 | /**
285 | * Let the OS find an open port for you.
286 | */
287 | private function findOpenPort() : int {
288 | $sock = socket_create(AF_INET, SOCK_STREAM, 0);
289 | if( $sock === false ) {
290 | throw new RuntimeException('Failed to create socket');
291 | }
292 |
293 | // Bind the socket to an address/port
294 | if( !socket_bind($sock, $this->getHost(), 0) ) {
295 | throw new RuntimeException('Could not bind to address');
296 | }
297 |
298 | socket_getsockname($sock, $checkAddress, $checkPort);
299 | socket_close($sock);
300 |
301 | if( $checkPort > 0 ) {
302 | return $checkPort;
303 | }
304 |
305 | throw new RuntimeException('Failed to find open port');
306 | }
307 |
308 | private function isWindowsPlatform() : bool {
309 | return defined('PHP_WINDOWS_VERSION_MAJOR');
310 | }
311 |
312 | /**
313 | * @return array{resource,array{resource,resource,resource}}
314 | */
315 | private function startServer( string $fullCmd ) : array {
316 | if( !$this->isWindowsPlatform() ) {
317 | // We need to prefix exec to get the correct process http://php.net/manual/ru/function.proc-get-status.php#93382
318 | $fullCmd = 'exec ' . $fullCmd;
319 | }
320 |
321 | $pipes = [];
322 | $env = null;
323 | $cwd = null;
324 |
325 | $stdoutf = tempnam(sys_get_temp_dir(), 'MockWebServer.stdout');
326 | if( $stdoutf === false ) {
327 | throw new RuntimeException('error creating stdout temp file');
328 | }
329 |
330 | $stderrf = tempnam(sys_get_temp_dir(), 'MockWebServer.stderr');
331 | if( $stderrf === false ) {
332 | throw new RuntimeException('error creating stderr temp file');
333 | }
334 |
335 | $stdin = fopen('php://stdin', 'rb');
336 | if( $stdin === false ) {
337 | throw new RuntimeException('error opening stdin');
338 | }
339 |
340 | $stdout = fopen($stdoutf, 'ab');
341 | if( $stdout === false ) {
342 | throw new RuntimeException('error opening stdout');
343 | }
344 |
345 | $stderr = fopen($stderrf, 'ab');
346 | if( $stderr === false ) {
347 | throw new RuntimeException('error opening stderr');
348 | }
349 |
350 | $descriptorSpec = [ $stdin, $stdout, $stderr ];
351 |
352 | $process = proc_open($fullCmd, $descriptorSpec, $pipes, $cwd, $env, [
353 | 'suppress_errors' => false,
354 | 'bypass_shell' => true,
355 | ]);
356 |
357 | if( $process === false ) {
358 | throw new Exceptions\ServerException('Error starting server');
359 | }
360 |
361 | return [ $process, $descriptorSpec ];
362 | }
363 |
364 | }
365 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Mock Web Server
2 |
3 | [](https://packagist.org/packages/donatj/mock-webserver)
4 | [](https://packagist.org/packages/donatj/mock-webserver)
5 | [](https://github.com/donatj/mock-webserver/actions/workflows/ci.yml)
6 | [](https://coveralls.io/github/donatj/mock-webserver)
7 |
8 |
9 | Simple, easy to use Mock Web Server for PHP unit testing. Gets along simply with PHPUnit and other unit testing frameworks.
10 |
11 | Unit testing HTTP requests can be difficult, especially in cases where injecting a request library is difficult or not ideal. This helps greatly simplify the process.
12 |
13 | Mock Web Server creates a local Web Server you can make predefined requests against.
14 |
15 |
16 | ## Documentation
17 |
18 | [See: docs/docs.md](docs/docs.md)
19 |
20 | ## Requirements
21 |
22 | - **php**: >=7.1
23 | - **ext-sockets**: *
24 | - **ext-json**: *
25 | - **ralouphie/getallheaders**: ~2.0 || ~3.0
26 |
27 | ## Installing
28 |
29 | Install the latest version with:
30 |
31 | ```bash
32 | composer require --dev 'donatj/mock-webserver'
33 | ```
34 |
35 | ## Examples
36 |
37 | ### Basic Usage
38 |
39 | The following example shows the most basic usage. If you do not define a path, the server will simply bounce a JSON body describing the request back to you.
40 |
41 | ```php
42 | start();
50 |
51 | $url = $server->getServerRoot() . '/endpoint?get=foobar';
52 |
53 | echo "Requesting: $url\n\n";
54 | echo file_get_contents($url);
55 |
56 | ```
57 |
58 | Outputs:
59 |
60 | ```
61 | Requesting: http://127.0.0.1:61874/endpoint?get=foobar
62 |
63 | {
64 | "_GET": {
65 | "get": "foobar"
66 | },
67 | "_POST": [],
68 | "_FILES": [],
69 | "_COOKIE": [],
70 | "HEADERS": {
71 | "Host": "127.0.0.1:61874",
72 | "Connection": "close"
73 | },
74 | "METHOD": "GET",
75 | "INPUT": "",
76 | "PARSED_INPUT": [],
77 | "REQUEST_URI": "\/endpoint?get=foobar",
78 | "PARSED_REQUEST_URI": {
79 | "path": "\/endpoint",
80 | "query": "get=foobar"
81 | }
82 | }
83 | ```
84 |
85 | ### Simple
86 |
87 | ```php
88 | start();
97 |
98 | // We define the server's response to requests of the /definedPath endpoint
99 | $url = $server->setResponseOfPath(
100 | '/definedPath',
101 | new Response(
102 | 'This is our http body response',
103 | [ 'Cache-Control' => 'no-cache' ],
104 | 200
105 | )
106 | );
107 |
108 | echo "Requesting: $url\n\n";
109 |
110 | $content = file_get_contents($url);
111 |
112 | // $http_response_header is a little known variable magically defined
113 | // in the current scope by file_get_contents with the response headers
114 | echo implode("\n", $http_response_header) . "\n\n";
115 | echo $content . "\n";
116 |
117 | ```
118 |
119 | Outputs:
120 |
121 | ```
122 | Requesting: http://127.0.0.1:61874/definedPath
123 |
124 | HTTP/1.0 200 OK
125 | Host: 127.0.0.1:61874
126 | Date: Tue, 31 Aug 2021 19:50:15 GMT
127 | Connection: close
128 | X-Powered-By: PHP/7.3.25
129 | Cache-Control: no-cache
130 | Content-type: text/html; charset=UTF-8
131 |
132 | This is our http body response
133 | ```
134 |
135 | ### Change Default Response
136 |
137 | ```php
138 | start();
147 |
148 | // The default response is donatj\MockWebServer\Responses\DefaultResponse
149 | // which returns an HTTP 200 and a descriptive JSON payload.
150 | //
151 | // Change the default response to donatj\MockWebServer\Responses\NotFoundResponse
152 | // to get a standard 404.
153 | //
154 | // Any other response may be specified as default as well.
155 | $server->setDefaultResponse(new NotFoundResponse);
156 |
157 | $content = file_get_contents($server->getServerRoot() . '/PageDoesNotExist', false, stream_context_create([
158 | 'http' => [ 'ignore_errors' => true ], // allow reading 404s
159 | ]));
160 |
161 | // $http_response_header is a little known variable magically defined
162 | // in the current scope by file_get_contents with the response headers
163 | echo implode("\n", $http_response_header) . "\n\n";
164 | echo $content . "\n";
165 |
166 | ```
167 |
168 | Outputs:
169 |
170 | ```
171 | HTTP/1.0 404 Not Found
172 | Host: 127.0.0.1:61874
173 | Date: Tue, 31 Aug 2021 19:50:15 GMT
174 | Connection: close
175 | X-Powered-By: PHP/7.3.25
176 | Content-type: text/html; charset=UTF-8
177 |
178 | VND.DonatStudios.MockWebServer: Resource '/PageDoesNotExist' not found!
179 |
180 | ```
181 |
182 | ### PHPUnit
183 |
184 | ```php
185 | start();
198 | }
199 |
200 | public function testGetParams() : void {
201 | $result = file_get_contents(self::$server->getServerRoot() . '/autoEndpoint?foo=bar');
202 | $decoded = json_decode($result, true);
203 | $this->assertSame('bar', $decoded['_GET']['foo']);
204 | }
205 |
206 | public function testGetSetPath() : void {
207 | // $url = http://127.0.0.1:61874/definedEndPoint
208 | $url = self::$server->setResponseOfPath('/definedEndPoint', new Response('foo bar content'));
209 | $result = file_get_contents($url);
210 | $this->assertSame('foo bar content', $result);
211 | }
212 |
213 | public static function tearDownAfterClass() : void {
214 | // stopping the web server during tear down allows us to reuse the port for later tests
215 | self::$server->stop();
216 | }
217 |
218 | }
219 |
220 | ```
221 |
222 | ### Delayed Response Usage
223 |
224 | By default responses will happen instantly. If you're looking to test timeouts, the DelayedResponse response wrapper may be useful.
225 |
226 | ```php
227 | start();
237 |
238 | $response = new Response(
239 | 'This is our http body response',
240 | [ 'Cache-Control' => 'no-cache' ],
241 | 200
242 | );
243 |
244 | // Wrap the response in a DelayedResponse object, which will delay the response
245 | $delayedResponse = new DelayedResponse(
246 | $response,
247 | 100000 // sets a delay of 100000 microseconds (.1 seconds) before returning the response
248 | );
249 |
250 | $realtimeUrl = $server->setResponseOfPath('/realtime', $response);
251 | $delayedUrl = $server->setResponseOfPath('/delayed', $delayedResponse);
252 |
253 | echo "Requesting: $realtimeUrl\n\n";
254 |
255 | // This request will run as quickly as possible
256 | $start = microtime(true);
257 | file_get_contents($realtimeUrl);
258 | echo "Realtime Request took: " . (microtime(true) - $start) . " seconds\n\n";
259 |
260 | echo "Requesting: $delayedUrl\n\n";
261 |
262 | // The request will take the delayed time + the time it takes to make and transfer the request
263 | $start = microtime(true);
264 | file_get_contents($delayedUrl);
265 | echo "Delayed Request took: " . (microtime(true) - $start) . " seconds\n\n";
266 |
267 | ```
268 |
269 | Outputs:
270 |
271 | ```
272 | Requesting: http://127.0.0.1:61874/realtime
273 |
274 | Realtime Request took: 0.015669107437134 seconds
275 |
276 | Requesting: http://127.0.0.1:61874/delayed
277 |
278 | Delayed Request took: 0.10729098320007 seconds
279 |
280 | ```
281 |
282 | ## Multiple Responses from the Same Endpoint
283 |
284 | ### Response Stack
285 |
286 | If you need an ordered set of responses, that can be done using the ResponseStack.
287 |
288 | ```php
289 | start();
299 |
300 | // We define the servers response to requests of the /definedPath endpoint
301 | $url = $server->setResponseOfPath(
302 | '/definedPath',
303 | new ResponseStack(
304 | new Response("Response One"),
305 | new Response("Response Two")
306 | )
307 | );
308 |
309 | echo "Requesting: $url\n\n";
310 |
311 | $contentOne = file_get_contents($url);
312 | $contentTwo = file_get_contents($url);
313 | // This third request is expected to 404 which will error if errors are not ignored
314 | $contentThree = file_get_contents($url, false, stream_context_create([ 'http' => [ 'ignore_errors' => true ] ]));
315 |
316 | // $http_response_header is a little known variable magically defined
317 | // in the current scope by file_get_contents with the response headers
318 | echo $contentOne . "\n";
319 | echo $contentTwo . "\n";
320 | echo $contentThree . "\n";
321 |
322 | ```
323 |
324 | Outputs:
325 |
326 | ```
327 | Requesting: http://127.0.0.1:61874/definedPath
328 |
329 | Response One
330 | Response Two
331 | Past the end of the ResponseStack
332 | ```
333 |
334 | ### Response by Method
335 |
336 | If you need to vary responses to a single endpoint by method, you can do that using the ResponseByMethod response object.
337 |
338 | ```php
339 | start();
349 |
350 | // Create a response for both a POST and GET request to the same URL
351 |
352 | $response = new ResponseByMethod([
353 | ResponseByMethod::METHOD_GET => new Response("This is our http GET response"),
354 | ResponseByMethod::METHOD_POST => new Response("This is our http POST response", [], 201),
355 | ]);
356 |
357 | $url = $server->setResponseOfPath('/foo/bar', $response);
358 |
359 | foreach( [ ResponseByMethod::METHOD_GET, ResponseByMethod::METHOD_POST ] as $method ) {
360 | echo "$method request to $url:\n";
361 |
362 | $context = stream_context_create([ 'http' => [ 'method' => $method ] ]);
363 | $content = file_get_contents($url, false, $context);
364 |
365 | echo $content . "\n\n";
366 | }
367 |
368 | ```
369 |
370 | Outputs:
371 |
372 | ```
373 | GET request to http://127.0.0.1:61874/foo/bar:
374 | This is our http GET response
375 |
376 | POST request to http://127.0.0.1:61874/foo/bar:
377 | This is our http POST response
378 |
379 | ```
--------------------------------------------------------------------------------
/test/Integration/MockWebServer_IntegrationTest.php:
--------------------------------------------------------------------------------
1 | start();
20 | }
21 |
22 | public function testBasic() : void {
23 | $url = self::$server->getServerRoot() . '/endpoint?get=foobar';
24 | $content = file_get_contents($url);
25 |
26 | // Some versions of PHP send it with file_get_contents, others do not.
27 | // Might be removable with a context but until I figure that out, terrible hack
28 | $content = preg_replace('/,\s*"Connection": "close"/', '', $content);
29 |
30 | $body = [
31 | '_GET' => [ 'get' => 'foobar', ],
32 | '_POST' => [],
33 | '_FILES' => [],
34 | '_COOKIE' => [],
35 | 'HEADERS' => [ 'Host' => '127.0.0.1:' . self::$server->getPort(), ],
36 | 'METHOD' => 'GET',
37 | 'INPUT' => '',
38 | 'PARSED_INPUT' => [],
39 | 'REQUEST_URI' => '/endpoint?get=foobar',
40 | 'PARSED_REQUEST_URI' => [ 'path' => '/endpoint', 'query' => 'get=foobar', ],
41 | ];
42 |
43 | $this->assertJsonStringEqualsJsonString($content, json_encode($body));
44 |
45 | $lastReq = self::$server->getLastRequest()->jsonSerialize();
46 | foreach( $body as $key => $val ) {
47 | if( $key === 'HEADERS' ) {
48 | // This is the same horrible connection hack as above. Fix in time.
49 | unset($lastReq[$key]['Connection']);
50 | }
51 |
52 | $this->assertSame($lastReq[$key], $val);
53 | }
54 | }
55 |
56 | public function testSimple() : void {
57 | // We define the servers response to requests of the /definedPath endpoint
58 | $url = self::$server->setResponseOfPath(
59 | '/definedPath',
60 | new Response(
61 | 'This is our http body response',
62 | [ 'X-Foo-Bar' => 'BazBazBaz' ],
63 | 200
64 | )
65 | );
66 |
67 | $content = file_get_contents($url);
68 | $this->assertContains('X-Foo-Bar: BazBazBaz', $http_response_header);
69 | $this->assertEquals("This is our http body response", $content);
70 | }
71 |
72 | public function testMulti() : void {
73 | $url = self::$server->getUrlOfResponse(
74 | new ResponseStack(
75 | new Response("Response One", [ 'X-Boop-Bat' => 'Sauce' ], 500),
76 | new Response("Response Two", [ 'X-Slaw-Dawg: FranCran' ], 400)
77 | )
78 | );
79 |
80 | $ctx = stream_context_create([ 'http' => [ 'ignore_errors' => true ] ]);
81 |
82 | $content = file_get_contents($url, false, $ctx);
83 |
84 | if( !(
85 | in_array('HTTP/1.0 500 Internal Server Error', $http_response_header, true)
86 | || in_array('HTTP/1.1 500 Internal Server Error', $http_response_header, true))
87 | ) {
88 | $this->fail('must contain 500 Internal Server Error');
89 | }
90 |
91 | $this->assertContains('X-Boop-Bat: Sauce', $http_response_header);
92 | $this->assertEquals("Response One", $content);
93 |
94 | $content = file_get_contents($url, false, $ctx);
95 | if( !(
96 | in_array('HTTP/1.0 400 Bad Request', $http_response_header, true)
97 | || in_array('HTTP/1.1 400 Bad Request', $http_response_header, true))
98 | ) {
99 | $this->fail('must contain 400 Bad Request');
100 | }
101 |
102 | $this->assertContains('X-Slaw-Dawg: FranCran', $http_response_header);
103 | $this->assertEquals("Response Two", $content);
104 |
105 | // this is expected to fail as we only have two responses in said stack
106 | $content = file_get_contents($url, false, $ctx);
107 | if( !(
108 | in_array('HTTP/1.0 404 Not Found', $http_response_header, true)
109 | || in_array('HTTP/1.1 404 Not Found', $http_response_header, true))
110 | ) {
111 | $this->fail('must contain 404 Not Found');
112 | }
113 |
114 | $this->assertEquals("Past the end of the ResponseStack", $content);
115 | }
116 |
117 | public function testHttpMethods() : void {
118 | $methods = [
119 | ResponseByMethod::METHOD_GET,
120 | ResponseByMethod::METHOD_POST,
121 | ResponseByMethod::METHOD_PUT,
122 | ResponseByMethod::METHOD_PATCH,
123 | ResponseByMethod::METHOD_DELETE,
124 | ResponseByMethod::METHOD_HEAD,
125 | ResponseByMethod::METHOD_OPTIONS,
126 | ResponseByMethod::METHOD_TRACE,
127 | ];
128 |
129 | $response = new ResponseByMethod;
130 |
131 | foreach( $methods as $method ) {
132 | $response->setMethodResponse($method, new Response(
133 | "This is our http $method body response",
134 | [ 'X-Foo-Bar' => 'Baz ' . $method ],
135 | 200
136 | ));
137 | }
138 |
139 | $url = self::$server->setResponseOfPath('/definedPath', $response);
140 |
141 | foreach( $methods as $method ) {
142 | $context = stream_context_create([ 'http' => [ 'method' => $method ] ]);
143 | $content = file_get_contents($url, false, $context);
144 |
145 | $this->assertContains('X-Foo-Bar: Baz ' . $method, $http_response_header);
146 |
147 | if( $method !== ResponseByMethod::METHOD_HEAD ) {
148 | $this->assertEquals("This is our http $method body response", $content);
149 | }
150 | }
151 |
152 | $context = stream_context_create([ 'http' => [ 'method' => 'PROPFIND' ] ]);
153 | $content = @file_get_contents($url, false, $context);
154 |
155 | $this->assertFalse($content);
156 | $this->assertStringEndsWith('501 Not Implemented', $http_response_header[0]);
157 | }
158 |
159 | public function testHttpMethods_fallthrough() : void {
160 | $response = new ResponseByMethod([], new Response('Default Fallthrough', [], 400));
161 |
162 | $url = self::$server->setResponseOfPath('/definedPath', $response);
163 |
164 | $context = stream_context_create([ 'http' => [ 'method' => 'PROPFIND', 'ignore_errors' => true ] ]);
165 | $content = @file_get_contents($url, false, $context);
166 |
167 | $this->assertSame('Default Fallthrough', $content);
168 | $this->assertStringEndsWith('400 Bad Request', $http_response_header[0]);
169 | }
170 |
171 | public function testDelayedResponse() : void {
172 |
173 | $realtimeResponse = new Response(
174 | 'This is our http body response',
175 | [ 'X-Foo-Bar' => 'BazBazBaz' ],
176 | 200
177 | );
178 |
179 | $delayedResponse = new DelayedResponse($realtimeResponse, 1000000);
180 |
181 | $this->assertNotSame($realtimeResponse->getRef(), $delayedResponse->getRef(),
182 | 'DelayedResponse should change the ref. If they are the same, using both causes issues.');
183 |
184 | $realtimeUrl = self::$server->setResponseOfPath('/realtimePath', $realtimeResponse);
185 | $delayedUrl = self::$server->setResponseOfPath('/delayedPath', $delayedResponse);
186 |
187 | $realtimeStart = microtime(true);
188 | $content = @file_get_contents($realtimeUrl);
189 | $this->assertNotFalse($content);
190 |
191 | $delayedStart = microtime(true);
192 | $delayedContent = file_get_contents($delayedUrl);
193 |
194 | $end = microtime(true);
195 |
196 | $this->assertGreaterThan(.9, ($end - $delayedStart) - ($delayedStart - $realtimeStart), 'Delayed response should take ~1 seconds longer than realtime response');
197 |
198 | $this->assertEquals('This is our http body response', $delayedContent);
199 | $this->assertContains('X-Foo-Bar: BazBazBaz', $http_response_header);
200 | }
201 |
202 | public function testDelayedMultiResponse() : void {
203 | $multi = new ResponseStack(
204 | new Response('Response One', [ 'X-Boop-Bat' => 'Sauce' ], 200),
205 | new Response('Response Two', [ 'X-Slaw-Dawg: FranCran' ], 200)
206 | );
207 |
208 | $delayed = new DelayedResponse($multi, 1000000);
209 |
210 | $path = self::$server->setResponseOfPath('/delayedMultiPath', $delayed);
211 |
212 | $start = microtime(true);
213 | $contentOne = file_get_contents($path);
214 | $this->assertSame($contentOne, 'Response One');
215 | $this->assertContains('X-Boop-Bat: Sauce', $http_response_header);
216 | $this->assertGreaterThan(.9, microtime(true) - $start, 'Delayed response should take ~1 seconds longer than realtime response');
217 |
218 | $start = microtime(true);
219 | $contentTwo = file_get_contents($path);
220 | $this->assertSame($contentTwo, 'Response Two');
221 | $this->assertContains('X-Slaw-Dawg: FranCran', $http_response_header);
222 | $this->assertGreaterThan(.9, microtime(true) - $start, 'Delayed response should take ~1 seconds longer than realtime response');
223 | }
224 |
225 | public function testMultiResponseWithPartialDelay() : void {
226 | $multi = new ResponseStack(
227 | new Response('Response One', [ 'X-Boop-Bat' => 'Sauce' ], 200),
228 | new DelayedResponse(new Response('Response Two', [ 'X-Slaw-Dawg: FranCran' ], 200), 1000000)
229 | );
230 |
231 | $path = self::$server->setResponseOfPath('/delayedMultiPath', $multi);
232 |
233 | $start = microtime(true);
234 | $contentOne = file_get_contents($path);
235 | $this->assertSame($contentOne, 'Response One');
236 | $this->assertContains('X-Boop-Bat: Sauce', $http_response_header);
237 | $this->assertLessThan(.2, microtime(true) - $start, 'Delayed response should take less than 200ms');
238 |
239 | $start = microtime(true);
240 | $contentTwo = file_get_contents($path);
241 | $this->assertSame($contentTwo, 'Response Two');
242 | $this->assertContains('X-Slaw-Dawg: FranCran', $http_response_header);
243 | $this->assertGreaterThan(.9, microtime(true) - $start, 'Delayed response should take ~1 seconds longer than realtime response');
244 | }
245 |
246 | /**
247 | * Regression Test - Was a problem in 1.0.0-beta.2
248 | */
249 | public function testEmptySingle() : void {
250 | $url = self::$server->getUrlOfResponse(new Response(''));
251 | $this->assertSame('', file_get_contents($url));
252 | }
253 |
254 | public function testBinaryResponse() : void {
255 | $response = new Response(
256 | gzencode('This is our http body response'),
257 | [ 'Content-Encoding: gzip' ],
258 | 200
259 | );
260 |
261 | $url = self::$server->setResponseOfPath('/', $response);
262 | $content = @file_get_contents($url);
263 |
264 | $this->assertNotFalse($content);
265 | $this->assertSame('This is our http body response', gzdecode($content));
266 | $this->assertContains('Content-Encoding: gzip', $http_response_header);
267 | }
268 |
269 | /**
270 | * @dataProvider requestInfoProvider
271 | */
272 | public function testRequestInfo(
273 | $method,
274 | $uri,
275 | $respBody,
276 | $reqBody,
277 | array $headers,
278 | $status,
279 | $query,
280 | array $expectedCookies,
281 | array $serverVars
282 | ) {
283 | $url = self::$server->setResponseOfPath($uri, new Response($respBody, $headers, $status));
284 |
285 | // Get cURL resource
286 | $ch = curl_init();
287 |
288 | // Set url
289 | curl_setopt($ch, CURLOPT_URL, $url . '?' . $query);
290 | curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
291 | curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
292 |
293 | $xheaders = [];
294 | foreach( $headers as $hkey => $hval ) {
295 | $xheaders[] = "{$hkey}: $hval";
296 | }
297 |
298 | curl_setopt($ch, CURLOPT_HTTPHEADER, $xheaders);
299 | // Create body
300 |
301 | if( is_array($reqBody) ) {
302 | $encReqBody = http_build_query($reqBody);
303 | } else {
304 | $encReqBody = $reqBody ?: '';
305 | }
306 |
307 | if( $encReqBody ) {
308 | curl_setopt($ch, CURLOPT_POST, 1);
309 | curl_setopt($ch, CURLOPT_POSTFIELDS, $encReqBody);
310 | }
311 |
312 | // Send the request & save response to $resp
313 | $resp = curl_exec($ch);
314 | $this->assertNotEmpty($resp, "response body is empty, request failed");
315 |
316 | $this->assertSame($status, curl_getinfo($ch, CURLINFO_HTTP_CODE));
317 |
318 | // Close request to clear up some resources
319 | curl_close($ch);
320 |
321 | $request = self::$server->getLastRequest();
322 |
323 | $this->assertSame($uri . '?' . $query, $request->getRequestUri());
324 | $this->assertSame([ 'path' => $uri, 'query' => ltrim($query, '?') ], $request->getParsedUri());
325 | $this->assertContains(self::$server->getHost() . ':' . self::$server->getPort(),
326 | $request->getHeaders());
327 |
328 | $reqHeaders = $request->getHeaders();
329 | foreach( $headers as $hkey => $hval ) {
330 | $this->assertSame($reqHeaders[$hkey], $hval);
331 | }
332 |
333 | $this->assertSame($query, http_build_query($request->getGet()));
334 | $this->assertSame($method, $request->getRequestMethod());
335 |
336 | $this->assertSame($expectedCookies, $request->getCookie());
337 |
338 | $this->assertSame($encReqBody, $request->getInput());
339 |
340 | parse_str($encReqBody, $decReqBody);
341 | $this->assertSame($decReqBody, $request->getParsedInput());
342 | if( $method === 'POST' ) {
343 | $this->assertSame($decReqBody, $request->getPost());
344 | }
345 |
346 | $server = $request->getServer();
347 |
348 | $this->assertEquals(self::$server->getHost(), $server['SERVER_NAME']);
349 | $this->assertEquals(self::$server->getPort(), $server['SERVER_PORT']);
350 |
351 | foreach( $serverVars as $sKey => $sVal ) {
352 | $this->assertSame($server[$sKey], $sVal);
353 | }
354 | }
355 |
356 | public function requestInfoProvider() : array {
357 | return [
358 | [
359 | 'GET',
360 | '/requestInfoPath',
361 | 'This is our http body response',
362 | null,
363 | [ 'X-Foo-Bar' => 'BazBazBaz', 'Accept' => 'Juice' ],
364 | 200,
365 | 'foo=bar',
366 | [],
367 | [ 'HTTP_ACCEPT' => 'Juice', 'QUERY_STRING' => 'foo=bar' ],
368 | ],
369 | [
370 | 'POST',
371 | '/requestInfoPath',
372 | 'This is my POST response',
373 | [ 'a' => 1 ],
374 | [ 'X-Boo-Bop' => 'Beep Boop', 'Cookie' => 'juice=mango' ],
375 | 301,
376 | 'x=1',
377 | [
378 | 'juice' => 'mango',
379 | ],
380 | [ 'REQUEST_METHOD' => 'POST', 'QUERY_STRING' => 'x=1' ],
381 | ],
382 | [
383 | 'PUT',
384 | '/put/path/90210',
385 | 'Put put put',
386 | [ 'a' => 1 ],
387 | [ 'X-Boo-Bop' => 'Beep Boop', 'Cookie' => 'a=b; c=d; e=f; what="soup"' ],
388 | 301,
389 | 'x=1',
390 | [
391 | 'a' => 'b',
392 | 'c' => 'd',
393 | 'e' => 'f',
394 | 'what' => '"soup"',
395 | ],
396 | [ 'REQUEST_METHOD' => 'PUT', 'QUERY_STRING' => 'x=1' ],
397 | ],
398 | ];
399 | }
400 |
401 | public function testStartStopServer() : void {
402 | $server = new MockWebServer;
403 |
404 | $server->start();
405 | $this->assertTrue($server->isRunning());
406 |
407 | $server->stop();
408 | $this->assertFalse($server->isRunning());
409 | }
410 |
411 | }
412 |
--------------------------------------------------------------------------------