├── .gitattributes
├── tests
├── Response
│ ├── fake-content.txt
│ ├── Assertions.php
│ ├── HeaderTest.php
│ ├── JsonResponseTest.php
│ ├── CookieTest.php
│ └── FileResponseTest.php
├── FakeValueObject.php
├── FakeValueCollection.php
├── Request
│ ├── Header
│ │ ├── ForwardedTest.php
│ │ ├── XForwardedTest.php
│ │ ├── AuthorizationTest.php
│ │ └── AcceptTest.php
│ ├── MethodTest.php
│ ├── ContentTest.php
│ ├── UrlTest.php
│ └── UploadsTest.php
├── ValueObjectTest.php
├── ValueCollectionTest.php
├── RequestTest.php
└── ResponseTest.php
├── .gitignore
├── src
├── Exception.php
├── Request
│ ├── Header
│ │ ├── Accept
│ │ │ ├── TypeCollection.php
│ │ │ ├── CharsetCollection.php
│ │ │ ├── EncodingCollection.php
│ │ │ ├── Type.php
│ │ │ ├── Charset.php
│ │ │ ├── Encoding.php
│ │ │ ├── Language.php
│ │ │ ├── LanguageCollection.php
│ │ │ └── AcceptCollection.php
│ │ ├── Authorization
│ │ │ ├── None.php
│ │ │ ├── Generic.php
│ │ │ ├── Scheme
│ │ │ │ ├── Bearer.php
│ │ │ │ ├── Basic.php
│ │ │ │ └── Digest.php
│ │ │ ├── Scheme.php
│ │ │ └── Factory.php
│ │ ├── ForwardedCollection.php
│ │ ├── Forwarded.php
│ │ ├── Accept.php
│ │ └── XForwarded.php
│ ├── Upload.php
│ ├── Method.php
│ ├── UploadCollection.php
│ ├── Url.php
│ └── Content.php
├── ValueObject.php
├── Response
│ ├── Header.php
│ ├── JsonResponse.php
│ ├── FileResponse.php
│ └── Cookie.php
├── ValueCollection.php
├── Request.php
└── Response.php
├── docs
├── _bookdown.json
├── response
│ ├── _bookdown.json
│ ├── code.md
│ ├── version.md
│ ├── content.md
│ ├── extending.md
│ ├── callbacks.md
│ ├── overview.md
│ ├── sending.md
│ ├── headers.md
│ ├── special.md
│ └── cookies.md
└── request
│ ├── _bookdown.json
│ ├── overview.md
│ ├── headers.md
│ ├── method.md
│ ├── url.md
│ ├── accept.md
│ ├── forward.md
│ ├── content.md
│ ├── globals.md
│ ├── authorization.md
│ ├── extending.md
│ └── uploads.md
├── .editorconfig
├── CONTRIBUTING.md
├── php-styler.php
├── phpstan.neon
├── phpunit.php
├── phpunit.xml.dist
├── README.md
├── CHANGELOG.md
├── LICENSE.md
├── .github
└── workflows
│ └── ci.yml
└── composer.json
/.gitattributes:
--------------------------------------------------------------------------------
1 | * text eol=lf
2 |
--------------------------------------------------------------------------------
/tests/Response/fake-content.txt:
--------------------------------------------------------------------------------
1 | Hello World!
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /.php-styler.cache
2 | /.phpunit.cache
3 | /composer.lock
4 | /tmp
5 | /vendor
6 |
--------------------------------------------------------------------------------
/src/Exception.php:
--------------------------------------------------------------------------------
1 | scheme = null;
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/phpunit.php:
--------------------------------------------------------------------------------
1 | scheme = end($parts);
17 | }
18 |
19 | public function is(?string $scheme) : bool
20 | {
21 | return strtolower($this->scheme ?? '') === strtolower($scheme ?? '');
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/phpunit.xml.dist:
--------------------------------------------------------------------------------
1 |
2 |
9 |
10 |
11 |
12 | ./tests
13 |
14 |
15 |
16 |
17 | ./src
18 |
19 |
20 |
21 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Sapien
2 |
3 | This package provides server API (SAPI) request and response objects for PHP
4 | 8.1:
5 |
6 | - _Sapien\Request_, composed of readonly copies of PHP superglobals, and some
7 | other commonly-used values such as Accept, Forwarded, and Upload collections
8 |
9 | - _Sapien\Response_, a wrapper around (and buffer for) response-related PHP
10 | functions
11 |
12 | These are *not* HTTP message objects proper. Instead, they are wrappers and
13 | buffers for existing global PHP variables and functions.
14 |
15 | Install this package via Composer:
16 |
17 | ```
18 | composer require sapien/sapien
19 | ```
20 |
21 | Read the docs at .
22 |
--------------------------------------------------------------------------------
/tests/Response/Assertions.php:
--------------------------------------------------------------------------------
1 | send();
22 | $output = ob_get_clean();
23 | $this->assertSame($code, http_response_code());
24 | $this->assertSame($headers, xdebug_get_headers());
25 | $this->assertSame($content, $output);
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/tests/Response/HeaderTest.php:
--------------------------------------------------------------------------------
1 | assertSame('foo', $header->value);
14 | $header->add('bar');
15 | $this->assertSame('foo, bar', $header->value);
16 |
17 | $this->expectException(Exception::CLASS);
18 | $this->expectExceptionMessage('Sapien\Response\Header::$nonesuch does not exist.');
19 | $header->nonesuch; // @phpstan-ignore-line intentional get of undefined property
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Change Log
2 |
3 | ## 1.1.2
4 |
5 | - improve handling of deeply-grouped upload arrays
6 |
7 | - add FilesArray and FilesArrayNested phpstan types, and modifies FileArray type
8 |
9 | - remove PHP 8.4 notices by explicitly allowing nulls for implicitly nullable params
10 |
11 | ## 1.1.1
12 |
13 | This is a hygiene release, with improved static analysis typehinting
14 | and upgraded testing.
15 |
16 | ## 1.1.0
17 |
18 | - add JsonResponse::setFlags() and setDepth()
19 |
20 | - add Response::setCookies()
21 |
22 | - Response::setCookie() now takes a Cookie instance
23 |
24 | - JsonResponse now honors any pre-existing content-type
25 |
26 | ## 1.0.0
27 |
28 | Initial release.
29 |
30 |
--------------------------------------------------------------------------------
/src/Request/Header/Accept/LanguageCollection.php:
--------------------------------------------------------------------------------
1 | username = $pos === false ? null : substr($decoded, 0, $pos);
20 | $this->password = $pos === false ? null : substr($decoded, $pos + 1);
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/src/Request/Header/ForwardedCollection.php:
--------------------------------------------------------------------------------
1 | headers['forwarded'] ?? null;
14 |
15 | if ($header === null) {
16 | return new static();
17 | }
18 |
19 | $items = [];
20 | $forwards = explode(',', $header);
21 |
22 | foreach ($forwards as $forward) {
23 | $items[] = Forwarded::new($forward);
24 | }
25 |
26 | return new static($items);
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/docs/response/content.md:
--------------------------------------------------------------------------------
1 | # Content
2 |
3 | ## Setting
4 |
5 | `public setContent(mixed $content) : static`
6 |
7 | Sets the content of the _Response_.
8 |
9 | The `$content` may may be `null`, a string, a resource, an object, or anything
10 | else. How the content will be sent is determined by the _Response_ sending
11 | logic.
12 |
13 | The method is fluent, allowing you to chain a call to another _Response_ method.
14 |
15 | Note that unlike almost all the other _Response_ methods, `setContent()` is
16 | **not** declared as `final`. This means you can override it in extended
17 | _Response_ classes (though of course the signature must remain).
18 |
19 | ## Getting
20 |
21 | `final public getContent() : mixed`
22 |
23 | Returns the content of the _Response_.
24 |
--------------------------------------------------------------------------------
/tests/Response/JsonResponseTest.php:
--------------------------------------------------------------------------------
1 | setContent(['Hello ', 'World!']);
14 |
15 | $response->setJsonFlags(JSON_THROW_ON_ERROR);
16 | $this->assertSame(JSON_THROW_ON_ERROR, $response->getJsonFlags());
17 |
18 | $response->setJsonDepth(128);
19 | $this->assertSame(128, $response->getJsonDepth());
20 |
21 | $this->assertSent(
22 | $response,
23 | 200,
24 | [
25 | 'content-type: application/json'
26 | ],
27 | (string) json_encode(['Hello ', 'World!'])
28 | );
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/docs/request/overview.md:
--------------------------------------------------------------------------------
1 | # Overview
2 |
3 | The Sapien _Request_ is a value object, composed of other value objects,
4 | representing the PHP request received by the server. Use a _Request_ instead of
5 | the various PHP superglobals.
6 |
7 | ## Instantiation
8 |
9 | Instantiation of _Request_ is straightforward:
10 |
11 | ```php
12 | use Sapien\Request;
13 |
14 | $request = new Request();
15 | ```
16 |
17 | ## Further Reading
18 |
19 | The _Request_ provides public readonly properties related to these areas:
20 |
21 | - [globals](./globals.md)
22 | - [file uploads](./uploads.md)
23 | - [request method](./method.md)
24 | - [request url](./url.md)
25 | - [headers](./headers.md)
26 | - [`accept*`](./accept.md)
27 | - [`authorization`](./authorization.md)
28 | - [`forwarded` and `x-forwarded`](./forward.md)
29 | - [content](./content)
30 |
31 | You can also [extend the _Request_](./extending.md) for your own purposes.
32 |
--------------------------------------------------------------------------------
/src/ValueObject.php:
--------------------------------------------------------------------------------
1 | 'example.com',
13 | 'HTTP_FOO_BAR_BAZ' => 'dib,zim,gir',
14 | 'CONTENT_LENGTH' => '123',
15 | 'CONTENT_TYPE' => 'text/plain',
16 | ];
17 |
18 | $request = new Request();
19 |
20 | assert($request->headers['host'] === $_SERVER['HTTP_HOST']);
21 | assert($request->headers['foo-bar-baz'] === 'dib,zim,gir');
22 | assert($request->headers['content-length'] === '123');
23 | assert($request->headers['content-type'] === 'text/plain');
24 | ```
25 |
26 | You can work with `$headers` as you would with any readonly array:
27 |
28 | ```php
29 | $fooBarBaz = $request->headers['foo-bar-baz'] ?? null;
30 | ```
31 |
--------------------------------------------------------------------------------
/src/Response/Header.php:
--------------------------------------------------------------------------------
1 | value = $value;
26 | }
27 |
28 | public function __get(string $key) : mixed
29 | {
30 | if ($key === 'value') {
31 | return $this->value;
32 | }
33 |
34 | return parent::__get($key);
35 | }
36 |
37 | public function __toString() : string
38 | {
39 | return $this->value;
40 | }
41 |
42 | public function add(Header|string $value) : void
43 | {
44 | $this->value .= ', ' . $value;
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/src/Request/Header/Forwarded.php:
--------------------------------------------------------------------------------
1 | headers['authorization'] ?? null;
13 |
14 | if ($header === null) {
15 | return new None();
16 | }
17 |
18 | $pos = strpos($header, ' ');
19 |
20 | if ($pos === false) {
21 | return new None();
22 | }
23 |
24 | $scheme = trim(substr($header, 0, $pos));
25 | $credentials = trim(substr($header, $pos + 1));
26 |
27 | switch (strtolower($scheme)) {
28 | case 'basic':
29 | return new Scheme\Basic($credentials);
30 |
31 | case 'digest':
32 | return new Scheme\Digest($credentials);
33 |
34 | case 'bearer':
35 | return new Scheme\Bearer($credentials);
36 |
37 | default:
38 | return new Generic($scheme, $credentials);
39 | }
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/tests/Response/CookieTest.php:
--------------------------------------------------------------------------------
1 | 123,
15 | 'path' => '/',
16 | 'domain' => '.example.com',
17 | 'secure' => true,
18 | 'httponly' => true,
19 | 'samesite' => "Strict",
20 | 'nonesuch' => 'nonesuch',
21 | ]
22 | );
23 |
24 | $expect = [
25 | 'func' => 'setcookie',
26 | 'value' => 'bar',
27 | 'options' => [
28 | 'expires' => 123,
29 | 'path' => '/',
30 | 'domain' => '.example.com',
31 | 'secure' => true,
32 | 'httponly' => true,
33 | 'samesite' => 'Strict',
34 | ],
35 | ];
36 |
37 | $this->assertSame($expect, $cookie->asArray());
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/docs/request/method.md:
--------------------------------------------------------------------------------
1 | # Method
2 |
3 | The _Request_ `$method` property is a _Sapien\Request\Method_ instance derived
4 | from the `$server` values.
5 |
6 | The _Method_ `$name` property is a readonly string.
7 |
8 | ```php
9 | // returns the derived method value
10 | $requestMethod = $request->method->name;
11 |
12 | // the method object is stringable:
13 | assert($request->method->name === (string) $request->method);
14 | ```
15 |
16 | The `$name` value is computed from the _Request_ `$server['REQUEST_METHOD']`
17 | element, or the _Request_ `$server['HTTP_X_HTTP_METHOD_OVERRIDE']` element, as
18 | appropriate.
19 |
20 | In addition, the _Method_ object has an `is()` method for checking the method
21 | name:
22 |
23 | ```php
24 | $isPost = $request->method->is('post');
25 | ```
26 |
27 | You can override the default method value with a custom one via the _Request_
28 | constructor ...
29 |
30 | ```php
31 | $request = new Request(
32 | method: 'delete',
33 | );
34 | ```
35 |
36 | ... or you can provide a _Method_ object of your own construction:
37 |
38 | ```php
39 | $request = new Request(
40 | method: new Request\Method('delete'),
41 | );
42 |
--------------------------------------------------------------------------------
/docs/request/url.md:
--------------------------------------------------------------------------------
1 | # URL
2 |
3 | The _Request_ `$url` property is an instance of a _Url_ object.
4 |
5 | Each _Url_ object has these properties:
6 |
7 | - `?string $scheme`
8 | - `?string $host`
9 | - `?int $port`
10 | - `?string $user`
11 | - `?string $pass`
12 | - `?string $path`
13 | - `?string $query`
14 | - `?string $fragment`
15 |
16 | The property values are derived from applying
17 | [`parse_url()`](https://www.php.net/parse_url) to the various _Request_
18 | `$server` elements:
19 |
20 | - If `$server['HTTPS'] === 'on'`, the scheme is 'https'; otherwise, it is
21 | 'http'.
22 |
23 | - If `$server['HTTP_HOST']` is present, it is used as the host name; otherwise,
24 | `$server['SERVER_NAME']` is used.
25 |
26 | - If a port number is present on the host name, it is used as the port;
27 | otherwise, `$server['SERVER_PORT']` is used.
28 |
29 | - `$server['REQUEST_URI']` is used for the path and query string.
30 |
31 | If the parsing attempt fails, all _Url_ properties will be null.
32 |
33 | The _Url_ is stringable, and will return the full URL string:
34 |
35 | ```php
36 | $url = (string) $request->url; // https://example.com/path/etc
37 | ```
38 |
--------------------------------------------------------------------------------
/src/Request/Upload.php:
--------------------------------------------------------------------------------
1 | name = $name;
32 | $this->fullPath = $fullPath;
33 | $this->type = $type;
34 | $this->size = $size === null ? null : (int) $size;
35 | $this->tmpName = $tmpName;
36 | $this->error = $error === null ? null : (int) $error;
37 | }
38 |
39 | final public function move(string $destination) : bool
40 | {
41 | return move_uploaded_file((string) $this->tmpName, $destination);
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/src/Request/Header/Accept.php:
--------------------------------------------------------------------------------
1 | headers['accept'] ?? null),
15 | charsets: Accept\CharsetCollection::new(
16 | $request->headers['accept-charset'] ?? null,
17 | ),
18 | encodings: Accept\EncodingCollection::new(
19 | $request->headers['accept-encoding'] ?? null,
20 | ),
21 | languages: Accept\LanguageCollection::new(
22 | $request->headers['accept-language'] ?? null,
23 | ),
24 | );
25 | }
26 |
27 | public function __construct(
28 | public readonly Accept\TypeCollection $types,
29 | public readonly Accept\CharsetCollection $charsets,
30 | public readonly Accept\EncodingCollection $encodings,
31 | public readonly Accept\LanguageCollection $languages,
32 | ) {
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 | =====================
3 |
4 | Copyright (c) `2016-2025` `Paul M. Jones` `John Boehr`
5 |
6 | Permission is hereby granted, free of charge, to any person obtaining a copy of
7 | this software and associated documentation files (the "Software"), to deal in
8 | the Software without restriction, including without limitation the rights to
9 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
10 | of the Software, and to permit persons to whom the Software is furnished to do
11 | so, subject to the following conditions:
12 |
13 | The above copyright notice and this permission notice shall be included in all
14 | 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 THE
22 | SOFTWARE.
23 |
--------------------------------------------------------------------------------
/docs/request/accept.md:
--------------------------------------------------------------------------------
1 | # Accept
2 |
3 | The _Request_ `$accept` property is a _Sapien\Request\Header\Accept_ object.
4 |
5 | The _Accept_ object has these readonly _ValueCollection_ properties:
6 |
7 | - `TypeCollection $types`: A collection of _Accept\Type_ objects computed from
8 | `$header['accept']`.
9 |
10 | - `CharsetCollection $charsets`: A collection of _Accept\Charset_ objects computed from
11 | `$header['accept-charset']`.
12 |
13 | - `EncodingCollection $encodings`: A collection of _Accept\Encoding_ objects computed from
14 | `$header['accept-encoding']`.
15 |
16 | - `LanguageCollection $languages`: A collection of _Accept\Language_ objects computed from
17 | `$header['accept-language']`.
18 |
19 | Each collection is sorted from highest `q` parameter value to lowest.
20 |
21 | Each _Accept\\*_ object has these readonly properties:
22 |
23 | - `string $value`: The main value of the accept header.
24 |
25 | - `string $quality`: The 'q=' parameter value.
26 |
27 | - `array $params`: A key-value array of all other parameters.
28 |
29 | Each _Accept\Language_ object has these additional readonly properties:
30 |
31 | - `string $type`: The language type.
32 |
33 | - `?string $subtype`: The language subtype, if any.
34 |
--------------------------------------------------------------------------------
/tests/Request/Header/ForwardedTest.php:
--------------------------------------------------------------------------------
1 | 'For="[2001:db8:cafe::17]:4711", for=192.0.2.60;proto=http;by=203.0.113.43, for=192.0.2.43, 12.34.56.78',
15 | ];
16 |
17 | $request = new Request();
18 | $expect = new ForwardedCollection([
19 | new Forwarded(
20 | for: '[2001:db8:cafe::17]:4711',
21 | ),
22 | new Forwarded(
23 | for: "192.0.2.60",
24 | proto: "http",
25 | by: "203.0.113.43",
26 | ),
27 | new Forwarded(
28 | for: "192.0.2.43",
29 | ),
30 | new Forwarded(
31 | ),
32 | ]);
33 | $this->assertEquals($expect, $request->forwarded);
34 | }
35 |
36 | public function testEmpty() : void
37 | {
38 | $_SERVER = [];
39 | $request = new Request();
40 | $this->assertTrue($request->forwarded->isEmpty());
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: sapien/sapien
2 |
3 | on:
4 | push:
5 | branches: [ 1.x ]
6 | pull_request:
7 | branches: [ 1.x ]
8 |
9 | jobs:
10 | build:
11 |
12 | runs-on: ${{ matrix.operating-system }}
13 | strategy:
14 | matrix:
15 | operating-system: [ubuntu-latest, windows-latest]
16 | php-versions: ['8.1', '8.2', '8.3', '8.4']
17 | name: PHP ${{ matrix.php-versions }} Test on ${{ matrix.operating-system }}
18 |
19 | steps:
20 | - uses: actions/checkout@v4
21 |
22 | - name: Install PHP
23 | uses: shivammathur/setup-php@v2
24 | with:
25 | php-version: ${{ matrix.php-versions }}
26 | coverage: xdebug
27 | - name: Check PHP Version
28 | run: php -v
29 |
30 | - name: Validate composer.json and composer.lock
31 | run: composer validate --strict
32 |
33 | - name: Cache Composer packages
34 | id: composer-cache
35 | uses: actions/cache@v3
36 | with:
37 | path: vendor
38 | key: ${{ runner.os }}-php-${{ hashFiles('**/composer.lock') }}
39 | restore-keys: |
40 | ${{ runner.os }}-php-
41 |
42 | - name: Install dependencies
43 | run: composer install
44 |
45 | - name: QA checks
46 | run: composer check
47 |
--------------------------------------------------------------------------------
/tests/ValueObjectTest.php:
--------------------------------------------------------------------------------
1 | expectException(Exception::CLASS);
12 | $this->expectExceptionMessage('Sapien\FakeValueObject::$foo does not exist.');
13 | $fake->foo; // @phpstan-ignore-line intentional get of undefined property
14 | }
15 |
16 | public function testSet() : void
17 | {
18 | $fake = new FakeValueObject();
19 | $this->expectException(Exception::CLASS);
20 | $this->expectExceptionMessage('Sapien\FakeValueObject::$foo does not exist.');
21 | $fake->foo = 'bar'; // @phpstan-ignore-line intentional set of undefined property
22 | }
23 |
24 | public function testIsset() : void
25 | {
26 | $fake = new FakeValueObject();
27 | $this->assertFalse(isset($fake->foo));
28 | }
29 |
30 | public function testUnset() : void
31 | {
32 | $fake = new FakeValueObject();
33 | $this->expectException(Exception::CLASS);
34 | $this->expectExceptionMessage('Sapien\FakeValueObject::$foo does not exist.');
35 | unset($fake->foo);
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/tests/Request/MethodTest.php:
--------------------------------------------------------------------------------
1 | assertSame('GET', $request->method->name);
15 | $this->assertSame('GET', (string) $request->method);
16 | $this->assertTrue($request->method->is('get'));
17 | $this->assertFalse($request->method->is('post'));
18 | }
19 |
20 | public function testOverride() : void
21 | {
22 | $_SERVER['REQUEST_METHOD'] = 'POST';
23 | $_SERVER['HTTP_X_HTTP_METHOD_OVERRIDE'] = 'PATCH';
24 | $request = new Request();
25 | $this->assertSame('PATCH', $request->method->name);
26 | }
27 |
28 | public function testExplicit() : void
29 | {
30 | $request = new Request(method: 'DELETE');
31 | $this->assertSame('DELETE', $request->method->name);
32 | }
33 |
34 | public function testEmpty() : void
35 | {
36 | $_SERVER = [];
37 | $request = new Request();
38 | $this->assertNull($request->method->name);
39 |
40 | $_SERVER = null;
41 | $request = new Request();
42 | $this->assertNull($request->method->name);
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/src/ValueCollection.php:
--------------------------------------------------------------------------------
1 | items);
27 | }
28 |
29 | public function getIterator() : Traversable
30 | {
31 | return new ArrayIterator($this->items);
32 | }
33 |
34 | public function isEmpty() : bool
35 | {
36 | return empty($this->items);
37 | }
38 |
39 | public function offsetExists(mixed $key) : bool
40 | {
41 | return isset($this->items[$key]);
42 | }
43 |
44 | public function offsetGet(mixed $key) : mixed
45 | {
46 | return $this->items[$key];
47 | }
48 |
49 | public function offsetSet(mixed $key, mixed $value) : void
50 | {
51 | throw new Exception(get_class($this) . ' is readonly');
52 | }
53 |
54 | public function offsetUnset(mixed $key) : void
55 | {
56 | throw new Exception(get_class($this) . ' is readonly');
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/tests/ValueCollectionTest.php:
--------------------------------------------------------------------------------
1 | 'bar',
12 | 'baz' => 'dib',
13 | 'zim' => 'gir',
14 | ];
15 |
16 | $fakeValueCollection = new FakeValueCollection($expect);
17 |
18 | foreach ($fakeValueCollection as $key => $val) {
19 | $this->assertSame($expect[$key], $val);
20 | $this->assertTrue(isset($fakeValueCollection[$key]));
21 | $this->assertSame($expect[$key], $fakeValueCollection[$key]);
22 | }
23 |
24 | $this->assertSame(3, count($fakeValueCollection));
25 | $this->assertFalse(isset($fakeValueCollection['nonesuch']));
26 | $this->assertFalse($fakeValueCollection->isEmpty());
27 | }
28 |
29 | public function testOffsetSet() : void
30 | {
31 | $fakeValueCollection = new FakeValueCollection();
32 | $this->expectException(Exception::CLASS);
33 | $fakeValueCollection['foo'] = 'bar';
34 | }
35 |
36 | public function testOffsetUnset() : void
37 | {
38 | $fakeValueCollection = new FakeValueCollection(['foo' => 'bar']);
39 | $this->expectException(Exception::CLASS);
40 | unset($fakeValueCollection['foo']);
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/docs/request/forward.md:
--------------------------------------------------------------------------------
1 | # Forward
2 |
3 | The _Request_ object has two readonly properties related to forwarding.
4 |
5 | ## `$xForwarded`
6 |
7 | The `$xForwarded` property is an instance of _Sapien\Request\Header\XForwarded_.
8 |
9 | The _XForwarded_ object has these readonly properties:
10 |
11 | - `array $for`: An array computed from treating `$header['x-forwarded-for']` as
12 | comma-separated values.
13 |
14 | - `?string $host`: The `$headers['x-forwarded-host']` value, if any.
15 |
16 | - `?int $port`: The `$headers['x-forwarded-port']` value, if any.
17 |
18 | - `?string $prefix`: The `$headers['x-forwarded-prefix']` value, if any.
19 |
20 | - `?string $proto`: The `$headers['x-forwarded-proto']` value, if any.
21 |
22 |
23 | ## `$forwarded`
24 |
25 | The `$forwarded` property is a readonly _ValueCollection_ of _Sapien\Request\Header\Forwarded_
26 | objects.
27 |
28 | Each _Forwarded_ object has the following readonly properties computed from the
29 | `$headers['forwarded']` element:
30 |
31 | - `?string $by`: The interface where the request came in to the proxy server.
32 |
33 | - `?string $for`: Discloses information about the client that initiated the request.
34 |
35 | - `?string $host`: The original value of the Host header field.
36 |
37 | - `?string $proto`: The value of the used protocol type.
38 |
39 | > **Note:**
40 | >
41 | > Cf. the [Forwarded HTTP Extension](https://tools.ietf.org/html/rfc7239).
42 |
--------------------------------------------------------------------------------
/tests/Response/FileResponseTest.php:
--------------------------------------------------------------------------------
1 | setContent(__DIR__ . '/fake-content.txt');
17 | $this->assertSent(
18 | $response,
19 | 200,
20 | [
21 | 'content-disposition: attachment; filename="fake-content.txt"',
22 | 'content-type: application/octet-stream',
23 | 'content-transfer-encoding: binary',
24 | 'content-length: 12',
25 | ],
26 | ResponseTest::OUTPUT
27 | );
28 | }
29 |
30 | public function testBadContent() : void
31 | {
32 | $response = new FileResponse();
33 | $this->expectException(Exception::CLASS);
34 | $this->expectExceptionMessage('Sapien\Response\FileResponse content must be string or SplFileObject');
35 | $response->setContent(null);
36 | }
37 |
38 | public function testSendWithoutContent() : void
39 | {
40 | $response = new FileResponse();
41 | $this->expectException(Exception::CLASS);
42 | $this->expectExceptionMessage('Sapien\Response\FileResponse has no file to send');
43 | $response->send();
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/composer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "sapien/sapien",
3 | "description": "Server API (SAPI) request and response objects for PHP.",
4 | "type": "library",
5 | "license": "MIT",
6 | "authors": [
7 | {
8 | "name": "John Boehr",
9 | "email": "jbboehr@php.net",
10 | "role": "Lead"
11 | },
12 | {
13 | "name": "Paul M. Jones",
14 | "email": "pmjones@pmjones.io",
15 | "role": "Lead"
16 | }
17 | ],
18 | "require": {
19 | "php": "^8.1 | ^8.2 | ^8.3 | ^8.4"
20 | },
21 | "require-dev": {
22 | "ext-xdebug": "*",
23 | "pds/composer-script-names": "^1.0",
24 | "pds/skeleton": "^1.0",
25 | "phpstan/phpstan": "^1.0",
26 | "phpunit/phpunit": "^10.0",
27 | "pmjones/php-styler": "0.x-dev"
28 | },
29 | "autoload": {
30 | "psr-4": {
31 | "Sapien\\": "./src"
32 | }
33 | },
34 | "autoload-dev": {
35 | "psr-4": {
36 | "Sapien\\": "./tests"
37 | }
38 | },
39 | "scripts": {
40 | "analyze": "./vendor/bin/phpstan analyze --memory-limit=1G -c phpstan.neon",
41 | "check": "composer test && composer analyze && composer cs-check",
42 | "cs-check": "./vendor/bin/php-styler check",
43 | "cs-fix": "./vendor/bin/php-styler apply",
44 | "test": "./vendor/bin/phpunit",
45 | "test-coverage": "./vendor/bin/phpunit --coverage-html=./tmp/coverage"
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/src/Request/Method.php:
--------------------------------------------------------------------------------
1 | server;
19 |
20 | if (empty($server)) {
21 | return new static();
22 | }
23 |
24 | $name = null;
25 |
26 | if (isset($server['REQUEST_METHOD'])) {
27 | $name = $server['REQUEST_METHOD'];
28 | }
29 |
30 | if ($name === 'POST' && isset($server['HTTP_X_HTTP_METHOD_OVERRIDE'])) {
31 | $name = $server['HTTP_X_HTTP_METHOD_OVERRIDE'];
32 | }
33 |
34 | if ($name === null) {
35 | return new static();
36 | }
37 |
38 | return new static($name);
39 | }
40 |
41 | public readonly ?string $name;
42 |
43 | public function __construct(?string $name = null)
44 | {
45 | if (is_string($name)) {
46 | $name = strtoupper($name);
47 | }
48 |
49 | $this->name = $name;
50 | }
51 |
52 | public function __toString() : string
53 | {
54 | return (string) $this->name;
55 | }
56 |
57 | public function is(string $name) : bool
58 | {
59 | return $this->name === strtoupper($name);
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/docs/response/extending.md:
--------------------------------------------------------------------------------
1 | # Extending the _Response_
2 |
3 | The Sapien _Response_ class can be extended to provide other userland
4 | functionality.
5 |
6 | ## Properties
7 |
8 | The properties on _Response_ are private, which means you may not access
9 | them, except through the existing _Response_ methods. You may add child
10 | properties as desired, though they would best be `protected` or `private`.
11 |
12 | ## Constructor
13 |
14 | _Response_ is constructorless. You may add any constructor you
15 | like, and do not have to call a parent constructor.
16 |
17 | ## Methods
18 |
19 | Most of the methods on _Response_ are public **and final**, which means you
20 | cannot extend or override them in child classes. This keeps their behavior
21 | consistent.
22 |
23 | However, these _Response_ methods are **not** final, and thus are open to
24 | extension:
25 |
26 | - `public function setContent(mixed $content) : void`
27 | - `public function send() : void`
28 | - `public function sendContent() : void`
29 |
30 | You may override them at will (though of course you cannot change the
31 | signatures). In general:
32 |
33 | - Override `setContent()` to set up the _Response_ properties in relation to
34 | the content. Be sure to call `parent::setContent()` to actually retain the
35 | content value.
36 |
37 | - Override `send()` to perform pre- and post-sending behaviors. Be sure to call
38 | `parent::send()` to actually send the _Response_.
39 |
40 | - Override `sendContent()` for custom or specialized emitting of the content
41 | value.
42 |
--------------------------------------------------------------------------------
/docs/request/content.md:
--------------------------------------------------------------------------------
1 | # Content
2 |
3 | The _Request_ object `$content` property is a _Sapien\Request\Content_ object.
4 |
5 | The _Content_ object has these readonly properties:
6 |
7 | - `?string $body`: The content body; see below for the value of this property.
8 |
9 | - `?string $charset`: The `charset` parameter value of `$headers['content-type']`, if any.
10 |
11 | - `?int $length`: The value of `$headers['content-length']`, if any.
12 |
13 | - `?string $md5`: The value of `$headers['content-md5']`, if any.
14 |
15 | - `?string $type`: The value of `$headers['content-type']`, if any, minus any parameters.
16 |
17 | When the `$body` property is null, _Content_ will read from `php://input`
18 | instead:
19 |
20 | ```php
21 | $request = new Request();
22 | $body = $request->content->body; // returns `file_get_contents('php://input')`
23 | ```
24 |
25 | If you want to provide a custom content body string instead, pass it as a
26 | _Request_ argument ...
27 |
28 | ```php
29 | $request = new Request(
30 | content: 'custom-php-input-string'
31 | );
32 | ```
33 |
34 | ... or pass an entire _Content_ object of your own construction:
35 |
36 | ```php
37 | $body = 'custom-php-input-string';
38 | $request = new Request(
39 | content: new Request\Content(
40 | body: $body,
41 | length: strlen($body),
42 | type: 'text/plain',
43 | charset: 'utf-8',
44 | md5: md5($body),
45 | )
46 | );
47 | ```
48 |
49 | Note that the `$headers` values are not modified when you pass in custom content
50 | bodies or objects.
51 |
--------------------------------------------------------------------------------
/docs/request/globals.md:
--------------------------------------------------------------------------------
1 | # Globals
2 |
3 | The _Request_ object presents these readonly properties as copies of the PHP
4 | superglobals:
5 |
6 | - `array $cookies`: A copy of `$_COOKIE`.
7 |
8 | - `array $files`: A copy of `$_FILES`.
9 |
10 | - `array $input`: A copy of `$_POST`, *or* a `json_decode()`d array from the
11 | content body (see below).
12 |
13 | - `array $query`: A copy of `$_GET`.
14 |
15 | - `array $server`: A copy of `$_SERVER`.
16 |
17 | You can work with them the same as you would with any readonly array:
18 |
19 | ```php
20 | // get the `?q=` value, defaulting to an empty string
21 | $searchTerm = $request->query['q'] ?? '';
22 | ```
23 |
24 | ## JSON Decoding
25 |
26 | The `$_POST` superglobal is populated by PHP when it can decode the content
27 | body as `application/x-www-form-urlencoded` or `multipart/form-data`.
28 | However, it is often the case that content bodies are JSON encoded instead.
29 |
30 | Thus, as a convenience, if the _Request_ `content-type` is `application/json`,
31 | then `$request->input` will be an array computed by applying `json_decode()` to
32 | the content body.
33 |
34 | ## Custom Values
35 |
36 | You can provide alternative or custom values via the `$globals` constructor
37 | parameter:
38 |
39 | ```php
40 | $request = new Request(
41 | globals: [
42 | '_COOKIE' => [...],
43 | '_FILES' => [...],
44 | '_GET' => [...],
45 | '_POST' => [...],
46 | '_SERVER' => [...],
47 | ]
48 | );
49 | ```
50 |
51 | Any values not present in the `$globals` constructor parameter will be provided
52 | by the existing superglobal.
53 |
--------------------------------------------------------------------------------
/src/Response/JsonResponse.php:
--------------------------------------------------------------------------------
1 | setJson($content);
17 | }
18 |
19 | public function setJson(
20 | mixed $value,
21 | ?string $type = null,
22 | ?int $flags = null,
23 | ?int $depth = null,
24 | ) : static
25 | {
26 | $type = $type ?? $this->getHeader('content-type') ?? 'application/json';
27 | $this->setHeader('content-type', $type);
28 | $this->flags = $flags ?? $this->flags;
29 | $this->depth = $depth ?? $this->depth;
30 | return parent::setContent($value);
31 | }
32 |
33 | public function setJsonFlags(int $flags) : static
34 | {
35 | $this->flags = $flags;
36 | return $this;
37 | }
38 |
39 | public function getJsonFlags() : int
40 | {
41 | return $this->flags;
42 | }
43 |
44 | public function setJsonDepth(int $depth) : static
45 | {
46 | $this->depth = $depth;
47 | return $this;
48 | }
49 |
50 | public function getJsonDepth() : int
51 | {
52 | return $this->depth;
53 | }
54 |
55 | protected function sendContent() : void
56 | {
57 | echo json_encode(
58 | $this->getContent(),
59 | $this->flags,
60 | $this->depth <= 1 ? 1 : $this->depth,
61 | );
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/docs/response/callbacks.md:
--------------------------------------------------------------------------------
1 | # Header Callbacks
2 |
3 | ## Setting
4 |
5 | `final public setHeaderCallbacks(array $callbacks) : static`
6 |
7 | Sets an array of callbacks to be invoked just before headers are sent by
8 | the _Response_, replacing any existing callbacks.
9 |
10 | This method is similar to
11 | [`header_register_callback()`](https://secure.php.net/header_register_callback),
12 | except that *multiple* callbacks may be registered with the Response.
13 |
14 | Each value in the `$callbacks` array is expected to be a callable with the
15 | following signature:
16 |
17 | `function (Response $response) : void`
18 |
19 | The method is fluent, allowing you to chain a call to another _Response_ method.
20 |
21 | ## Adding
22 |
23 | `final public addHeaderCallback(callable $callback) : static`
24 |
25 | Appends one callback to the current array of header callbacks in the _Response_.
26 |
27 | The `$callback` is expected to be a callable with the following signature:
28 |
29 | `function (Response $response) : void`
30 |
31 | The method is fluent, allowing you to chain a call to another _Response_ method.
32 |
33 | ## Getting
34 |
35 | `final public getHeaderCallbacks() : array`
36 |
37 | Returns the array of header callbacks in the _Response_.
38 |
39 | ## Checking
40 |
41 | `final public hasHeaderCallbacks() : bool`
42 |
43 | Returns `true` if there are any header callbacks in the _Response_, false if not.
44 |
45 | ## Removing
46 |
47 | `final public unsetHeaderCallbacks() : static`
48 |
49 | Removes all header callbacks from the _Response_.
50 |
51 | The method is fluent, allowing you to chain a call to another _Response_ method.
52 |
--------------------------------------------------------------------------------
/tests/Request/Header/XForwardedTest.php:
--------------------------------------------------------------------------------
1 | '1.2.3.4, 5.6.7.8, 9.10.11.12',
15 | 'HTTP_X_FORWARDED_HOST' => 'example.net',
16 | 'HTTP_X_FORWARDED_PROTO' => 'https',
17 | 'HTTP_X_FORWARDED_PORT' => '123',
18 | 'HTTP_X_FORWARDED_PREFIX' => '/prefix'
19 | ];
20 |
21 | $request = new Request();
22 |
23 | $expect = [
24 | "1.2.3.4",
25 | "5.6.7.8",
26 | "9.10.11.12",
27 | ];
28 | $this->assertSame($expect, $request->xForwarded->for);
29 |
30 | $expect = "example.net";
31 | $this->assertSame($expect, $request->xForwarded->host);
32 |
33 | $expect = "https";
34 | $this->assertSame($expect, $request->xForwarded->proto);
35 |
36 | $expect = 123;
37 | $this->assertSame($expect, $request->xForwarded->port);
38 |
39 | $expect = '/prefix';
40 | $this->assertSame($expect, $request->xForwarded->prefix);
41 | }
42 |
43 | public function testEmpty() : void
44 | {
45 | $_SERVER = [];
46 | $request = new Request();
47 | $this->assertTrue(empty($request->xForwarded->for));
48 | $this->assertNull($request->xForwarded->host);
49 | $this->assertNull($request->xForwarded->proto);
50 | $this->assertNull($request->xForwarded->port);
51 | $this->assertNull($request->xForwarded->prefix);
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/docs/response/overview.md:
--------------------------------------------------------------------------------
1 | # Overview
2 |
3 | The Sapien _Response_ is a mutable object representing the PHP response to be
4 | sent from the server.
5 |
6 | It provides a retention space for the HTTP response version, code,
7 | headers, cookies, and content, so they can be inspected before sending.
8 |
9 | Use a _Response_ in place of the `header()`, `setcookie()`, `setrawcookie()`,
10 | etc. functions.
11 |
12 | ## Instantiation
13 |
14 | Instantation is straightforward:
15 |
16 | ```php
17 | use Sapien\Response;
18 |
19 | $response = new Response();
20 | ```
21 |
22 | ## Examples
23 |
24 | Here are some basic examples of creating and sending a response:
25 |
26 | ```php
27 | // a "200 OK" response with some body content:
28 | $response
29 | ->setHeader('content-type', 'text/plain')
30 | ->setContent('Hello World!')
31 | ->send();
32 |
33 | // a "303 See Other" response
34 | $response
35 | ->setCode(303)
36 | ->setHeader('location', '/path/to/resource')
37 | ->send();
38 |
39 |
40 | // sending a cookie with the response; note how the setter methods
41 | // are fluent, allowing you to chain calls to the Response
42 | $response
43 | ->setCookie(name: 'foo', value: 'bar')
44 | ->setContent("Cookie has been set!")
45 | ->send();
46 | ```
47 |
48 | ## Further Reading
49 |
50 | The _Response_ provides public methods related to these areas:
51 |
52 | - [protocol version](./version.md)
53 | - [status code](./code.md)
54 | - [headers](./headers.md)
55 | - [cookies](./cookies.md)
56 | - [header callbacks](./callbacks.md)
57 | - [content](./content)
58 | - [sending the response](./sending.md)
59 |
60 | You can [extend the _Response_](./extending.md) for your own purposes, though
61 | you should check out one of the [specialized responses](./special.md) before
62 | doing so.
63 |
--------------------------------------------------------------------------------
/src/Response/FileResponse.php:
--------------------------------------------------------------------------------
1 | setFile($content);
16 | }
17 |
18 | throw new Exception(__CLASS__ . ' content must be string or SplFileObject');
19 | }
20 |
21 | public function setFile(
22 | SplFileObject|string $file,
23 | ?string $disposition = null,
24 | ?string $name = null,
25 | ?string $type = null,
26 | ?string $encoding = null,
27 | ) : static
28 | {
29 | if (is_string($file)) {
30 | $file = new SplFileObject($file, 'rb');
31 | }
32 |
33 | parent::setContent($file);
34 |
35 | // disposition
36 | $filename = rawurlencode($name ?? $file->getFilename());
37 | $disposition ??= 'attachment';
38 | $disposition = "{$disposition}; filename=\"{$filename}\"";
39 | $this->setHeader('content-disposition', $disposition);
40 |
41 | // mime type
42 | $type ??= $this->getHeader('content-type') ?? 'application/octet-stream';
43 | $this->setHeader('content-type', $type);
44 |
45 | // transfer encoding
46 | $encoding ??= $this->getHeader('content-transfer-encoding') ?? 'binary';
47 | $this->setHeader('content-transfer-encoding', $encoding);
48 |
49 | // content-length
50 | $size = $file->getSize();
51 |
52 | if ($size !== false) {
53 | $this->setHeader('content-length', (string) $size);
54 | }
55 |
56 | return $this;
57 | }
58 |
59 | protected function sendContent() : void
60 | {
61 | if (empty($this->getContent())) {
62 | throw new Exception(__CLASS__ . " has no file to send");
63 | }
64 |
65 | parent::sendContent();
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/docs/request/authorization.md:
--------------------------------------------------------------------------------
1 | # Authorization
2 |
3 | The _Request_ `$authorization` property is
4 | a _Sapien\Request\Header\Authorization\Scheme_ object.
5 |
6 | The _Scheme_ class itself is a marker, and may be one of several
7 | different implementations. The implementation is based on the scheme indicated
8 | by the _Request_ `$headers['authorization']` scheme.
9 |
10 | > **Warning:**
11 | >
12 | > The _Scheme_ objects **do not** indicate a user has been authenticated or
13 | > authorized. They only carry the untrusted user inputs provided by the client.
14 | > Use them to perform your own authentication and authorization logic.
15 |
16 | ## Basic
17 |
18 | The _Basic_ scheme presents these readonly properties computed from
19 | the _Request_ `$headers['authorization']` credentials:
20 |
21 | - `string $username`: The base64-decoded username.
22 | - `string $password`: The base64-decoded password.
23 |
24 | ## Bearer
25 |
26 | The _Bearer_ scheme presents this readonly property computed from the _Request_
27 | `$headers['authorization']` credentials:
28 |
29 | - `string $token`: The bearer token.
30 |
31 | ## Digest
32 |
33 | The _Digest_ scheme presents these readonly properties computed from
34 | the _Request_ `$headers['authorization']` credentials:
35 |
36 | - `?string $cnonce`: The client nonce.
37 | - `?int $nc`: The nonce count.
38 | - `?string $nonce`: The server nonce.
39 | - `?string $opaque`: The server opaque string.
40 | - `?string $qop`: The quality of protection.
41 | - `?string $realm`: The authentication realm.
42 | - `?string $response`: The client response.
43 | - `?string $uri`: The effective request URI.
44 | - `?bool $userhash`: Whether or not the username has been hashed.
45 | - `?string $username`: The username in the realm.
46 |
47 | ## Generic
48 |
49 | The _Generic_ scheme is used when the authorization scheme does not
50 | have a corresponding class. It presents these readonly properties:
51 |
52 | - `string $scheme`: The authorization scheme.
53 | - `string $credentials`: The authorization credentials.
54 |
55 | ## None
56 |
57 | The _None_ scheme is empty, and indicates there was no authorization header.
58 |
--------------------------------------------------------------------------------
/src/Response/Cookie.php:
--------------------------------------------------------------------------------
1 | func = $func;
39 | $this->value = $value;
40 |
41 | foreach ($options as $key => $value) {
42 | $this->parseOption($options, $key, $value);
43 | }
44 |
45 | $this->options = $options;
46 | }
47 |
48 | /**
49 | * @param CookieOptionsArray $options
50 | */
51 | protected function parseOption(array &$options, mixed $key, mixed $value) : void
52 | {
53 | if ($value === null) {
54 | unset($options[$key]);
55 | return;
56 | }
57 |
58 | switch ($key) {
59 | case 'expires':
60 | settype($value, 'int');
61 | $options[$key] = $value;
62 | return;
63 |
64 | case 'path':
65 | case 'domain':
66 | case 'samesite':
67 | settype($value, 'string');
68 | $options[$key] = $value;
69 | return;
70 |
71 | case 'secure':
72 | case 'httponly':
73 | settype($value, 'bool');
74 | $options[$key] = $value;
75 | return;
76 |
77 | default:
78 | unset($options[$key]);
79 | return;
80 | }
81 | }
82 | }
83 |
--------------------------------------------------------------------------------
/docs/request/extending.md:
--------------------------------------------------------------------------------
1 | # Extending the _Request_
2 |
3 | The Sapien _Request_ class can be extended to provide other userland
4 | functionality.
5 |
6 | ## Constructor
7 |
8 | The _Request_ class has a constructor. Child classes overriding `__construct()`
9 | should be sure to call `parent::__construct()`, or else the parent readonly
10 | properties will remain uninitialized. Likewise, child classes specifying a
11 | constructor will need to duplicate the parent parameters.
12 |
13 | ## Properties and Methods
14 |
15 | The parent _Request_ properties are readonly and cannot be modified or
16 | overridden. However, child classes may add new properties as desired. Even so,
17 | Sapien reserves the right to add new properties named for HTTP headers, along
18 | with `new*()` methods to populate those properties on demand.
19 |
20 | For example, try not to not add properties and methods named for HTTP headers ...
21 |
22 | ```php
23 | class MyRequest extends \Sapien\Request
24 | {
25 | // ...
26 |
27 | protected MyRequest\Authorization $authorization;
28 |
29 | protected function newAuthorization() : MyRequest\Authorization
30 | {
31 | // ...
32 | }
33 | }
34 | ```
35 |
36 | ... but you may add methods and properties for application-specific needs.
37 | For example, immutable application attributes:
38 |
39 | ```php
40 | class MyRequest extends \Sapien\Request
41 | {
42 | // ...
43 |
44 | protected $attributes = [];
45 |
46 | public function withAttributes(array $attribues) : static
47 | {
48 | $clone = clone $this;
49 | $clone->attributes = $attributes;
50 | return $clone;
51 | }
52 |
53 | public function withAttribute(string $key, mixed $value) : static
54 | {
55 | $clone = clone $this;
56 | $clone->attributes[$key] = $value;
57 | return $clone;
58 | }
59 | }
60 | ```
61 |
62 | ## Magic Methods
63 |
64 | Protected properties in child classes will automatically be available via magic
65 | `__get()`, though private properties will not be.
66 |
67 | The methods `__set()`, `__isset()`, and `__unset()` are declared `final` and so
68 | cannot be overridden. This is to help prevent subversion of the readonly nature
69 | of _Request_.
70 |
--------------------------------------------------------------------------------
/docs/response/sending.md:
--------------------------------------------------------------------------------
1 | # Sending
2 |
3 | `public function send() : void`
4 |
5 | To send the _Response_, call its `send()` method. Doing so will:
6 |
7 | - call each of the `$headerCallbacks` in order
8 |
9 | - send the status line `$version` and `$code` using [`header()`](https://php.net/header)
10 | calls; the default version is 1.1 and the default code is 200
11 |
12 | - send each of the `$headers` using [`header()`](https://php.net/header) calls
13 |
14 | - send each of the `$cookies` using [`setcookie()`](https://php.net/setcookie)
15 | and [`setrawcookie()`](https://php.net/setrawcookie) as appropriate
16 |
17 | - send the content using the _Response_ `sendContent()` method (see below for
18 | details).
19 |
20 | Note that the `send()` method, unlike most _Response_ methods, is **not**
21 | declared as final. This means you can override it in extended _Response_ classes
22 | (though of course the signature must remain).
23 |
24 | ## Content Handling
25 |
26 | `protected function sendContent() : void`
27 |
28 | Recall that the `setContent()` method allows anything to be content: a string,
29 | an object, a resource, etc. It is the `sendContent()` method that determines
30 | how to actually send the _Response_ content.
31 |
32 | If the content is ...
33 |
34 | - **a resource or _SplFileObject_**, then `sendContent()` will `rewind()` it and
35 | send it with `fpassthru()`.
36 |
37 | - **a non-string callable**, then `sendContent()` will invoke it. Further,
38 | `sendContent()` will echo the return value (if any) from that invocation. This
39 | means the callable may emit output itself, or it may return a string for
40 | `sendContent()` to echo, or do both.
41 |
42 | - **an iterable**, then `sendContent()` will `foreach()` through it, and echo
43 | each value.
44 |
45 | - **a string or a _Stringable_**, then `sendContent()` will merely echo it.
46 |
47 | - **anything else**, then `sendContent()` will do nothing, and return.
48 |
49 | The above conditions are in precedence order. That is, if the content is
50 | *both* callable *and* iterable, the callable handling will take precedence
51 | over the iterable handling.
52 |
53 | Note that the `sendContent()` method, unlike most _Response_ methods, is **not**
54 | declared as final. This means you can override it in extended _Response_ classes
55 | (though of course the signature must remain).
56 |
--------------------------------------------------------------------------------
/src/Request/Header/Authorization/Scheme/Digest.php:
--------------------------------------------------------------------------------
1 | null,
36 | 'nc' => null,
37 | 'nonce' => null,
38 | 'opaque' => null,
39 | 'qop' => null,
40 | 'realm' => null,
41 | 'response' => null,
42 | 'uri' => null,
43 | 'userhash' => null,
44 | 'username' => null,
45 | ];
46 |
47 | preg_match_all(
48 | '@(\w+)\s*=\s*(?:([\'"])([^\2]+?)\2|([^\s,]+))@',
49 | $credentials,
50 | $matches,
51 | PREG_SET_ORDER,
52 | );
53 |
54 | /** @var array{string, string, string, string, string} $param */
55 | foreach ($matches as $param) {
56 | $key = $param[1];
57 |
58 | if (array_key_exists($key, $args)) {
59 | $args[$key] = $param[3] ? $param[3] : $param[4];
60 | }
61 | }
62 |
63 | if ($args['nc'] !== null) {
64 | $args['nc'] = ctype_digit($args['nc']) ? (int) $args['nc'] : null;
65 | }
66 |
67 | if ($args['userhash'] !== null) {
68 | $args['userhash'] = strtolower($args['userhash']) === 'true';
69 | }
70 |
71 | $this->cnonce = $args['cnonce'];
72 | $this->nc = $args['nc'];
73 | $this->nonce = $args['nonce'];
74 | $this->opaque = $args['opaque'];
75 | $this->qop = $args['qop'];
76 | $this->realm = $args['realm'];
77 | $this->response = $args['response'];
78 | $this->uri = $args['uri'];
79 | $this->userhash = $args['userhash'];
80 | $this->username = $args['username'];
81 | }
82 | }
83 |
--------------------------------------------------------------------------------
/docs/response/headers.md:
--------------------------------------------------------------------------------
1 | # Headers
2 |
3 | The header field labels are retained internally in lower-case. This is to
4 | [comply with HTTP/2 requirements](https://tools.ietf.org/html/rfc7540#section-8.1.2);
5 | while HTTP/1.x has no such requirement, lower-case is also recognized as valid.
6 |
7 | Further, the header field values are retained and returned
8 | as _Sapien\Request\Header_ objects, not strings.
9 |
10 | ## Setting
11 |
12 | ### Setting One Header
13 |
14 | `final public setHeader(string $label, Header|string $value) : static`
15 |
16 | Overwrites the `$label` HTTP header in the _Response_; a buffered equivalent of
17 | `header("$label: $value", true)`.
18 |
19 | The method is fluent, allowing you to chain a call to another _Response_ method.
20 |
21 | ### Setting All Headers
22 |
23 | `final public setHeaders(array $headers) : static`
24 |
25 | Overwrites all previous headers on the _Response_, replacing them with the
26 | new `$headers` array. Each `$headers` element key is the field label, and
27 | the corresponding element value is the field value.
28 |
29 | The method is fluent, allowing you to chain a call to another _Response_ method.
30 |
31 | ## Adding
32 |
33 | `final public addHeader(string $label, Header|string $value) : static`
34 |
35 | Appends to the `$label` HTTP header in the _Response_, comma-separating it from
36 | the existing value; a buffered equivalent of `header("$label: $value", false)`.
37 |
38 | The method is fluent, allowing you to chain a call to another _Response_ method.
39 |
40 | ## Getting
41 |
42 | ### Getting One Header
43 |
44 | `final public getHeader(string $label) : ?Header`
45 |
46 | Returns the `$label` header from the _Response_, if it exists.
47 |
48 | ### Getting All Headers
49 |
50 | `final public getHeaders() : array`
51 |
52 | Returns the array of _Header_ objects in the _Response_.
53 |
54 | ## Checking
55 |
56 | `final public hasHeader(string $label) : bool`
57 |
58 | Returns `true` if the `$label` header exists in the _Response_, `false` if not.
59 |
60 | ## Removing
61 |
62 | ### Removing One Header
63 |
64 | `final public unsetHeader(string $label) : static`
65 |
66 | Removes the `$label` header from the _Response_.
67 |
68 | The method is fluent, allowing you to chain a call to another _Response_ method.
69 |
70 | ### Removing All Headers
71 |
72 | `final public unsetHeaders() : static`
73 |
74 | Removes all headers from the buffer.
75 |
76 | The method is fluent, allowing you to chain a call to another _Response_ method.
77 |
--------------------------------------------------------------------------------
/src/Request/Header/Accept/AcceptCollection.php:
--------------------------------------------------------------------------------
1 | $args) {
21 | $items[$key] = new $class(...$args);
22 | }
23 |
24 | return new static($items);
25 | }
26 |
27 | /**
28 | * @return mixed[]
29 | */
30 | protected static function parse(string $header) : array
31 | {
32 | if (trim($header) === '') {
33 | return [];
34 | }
35 |
36 | $buckets = [];
37 | $values = explode(',', $header);
38 |
39 | foreach ($values as $value) {
40 | $pairs = explode(';', $value);
41 | $value = $pairs[0];
42 | unset($pairs[0]);
43 | $params = [];
44 |
45 | foreach ($pairs as $pair) {
46 | $param = [];
47 |
48 | preg_match(
49 | '/^(?P.+?)=(?P"|\')?(?P.*?)(?:\k)?$/',
50 | $pair,
51 | $param,
52 | );
53 |
54 | /** @var array{name:string, value:string} $param */
55 | $params[$param['name']] = $param['value'];
56 | }
57 |
58 | $quality = '1.0';
59 |
60 | if (isset($params['q'])) {
61 | $quality = $params['q'];
62 | unset($params['q']);
63 | }
64 |
65 | $buckets[$quality][] = [
66 | 'value' => trim($value),
67 | 'quality' => $quality,
68 | 'params' => $params,
69 | ];
70 | }
71 |
72 | // reverse-sort the buckets so that q=1 is first and q=0 is last,
73 | // but the values in the buckets stay in the original order.
74 | krsort($buckets);
75 |
76 | // flatten the buckets back into the return array
77 | $items = [];
78 |
79 | foreach ($buckets as $q => $bucket) {
80 | foreach ($bucket as $spec) {
81 | $items[] = $spec;
82 | }
83 | }
84 |
85 | // done
86 | return $items;
87 | }
88 | }
89 |
--------------------------------------------------------------------------------
/src/Request/Header/XForwarded.php:
--------------------------------------------------------------------------------
1 |
24 | */
25 | protected static function newFor(Request $request) : array
26 | {
27 | if (! isset($request->headers['x-forwarded-for'])) {
28 | return [];
29 | }
30 |
31 | $forwardedFor = [];
32 | $ips = explode(',', $request->headers['x-forwarded-for']);
33 |
34 | foreach ($ips as $ip) {
35 | $forwardedFor[] = trim($ip);
36 | }
37 |
38 | return $forwardedFor;
39 | }
40 |
41 | protected static function newHost(Request $request) : ?string
42 | {
43 | if (! isset($request->headers['x-forwarded-host'])) {
44 | return null;
45 | }
46 |
47 | return trim($request->headers['x-forwarded-host']);
48 | }
49 |
50 | protected static function newProto(Request $request) : ?string
51 | {
52 | if (! isset($request->headers['x-forwarded-proto'])) {
53 | return null;
54 | }
55 |
56 | return trim($request->headers['x-forwarded-proto']);
57 | }
58 |
59 | protected static function newPort(Request $request) : ?int
60 | {
61 | if (! isset($request->headers['x-forwarded-port'])) {
62 | return null;
63 | }
64 |
65 | $port = null;
66 | $value = trim($request->headers['x-forwarded-port']);
67 | $noint = trim($value, '01234567890');
68 |
69 | if ($noint === '') {
70 | $port = (int) $value;
71 | }
72 |
73 | return $port;
74 | }
75 |
76 | protected static function newPrefix(Request $request) : ?string
77 | {
78 | if (! isset($request->headers['x-forwarded-prefix'])) {
79 | return null;
80 | }
81 |
82 | return trim($request->headers['x-forwarded-prefix']);
83 | }
84 |
85 | /**
86 | * @param array $for
87 | */
88 | public function __construct(
89 | public readonly ?array $for,
90 | public readonly ?string $proto,
91 | public readonly ?string $host,
92 | public readonly ?int $port,
93 | public readonly ?string $prefix,
94 | ) {
95 | }
96 | }
97 |
--------------------------------------------------------------------------------
/docs/response/special.md:
--------------------------------------------------------------------------------
1 | # Specialized Responses
2 |
3 | ## FileResponse
4 |
5 | The _FileResponse_ is customized for sending back downloads.
6 |
7 | Use `setContent()` to specify a string path to the file to be sent, or an
8 | _SplFileObject_ object:
9 |
10 | ```php
11 | use Sapien\Response\FileResponse;
12 |
13 | $fileResponse = new FileResponse();
14 |
15 | // use a string path ...
16 | $fileResponse->setContent('/path/to/file.txt');
17 |
18 | // ... or an SplFileObject:
19 | $fileResponse->setContent(new \SplFileObject('/path/to/file.txt'));
20 | ```
21 |
22 | The _FileResponse_ will set itself up to send the file ...
23 |
24 | - disposed as an 'attachment',
25 | - with whatever `content-type` is already set (or `application/octet-stream` if none),
26 | - using whatever `content-transfer-encoding` is already set (or `binary` if none),
27 | - naming the download for the filename.
28 |
29 | Alternatively, call the `setFile()` method for better control over some
30 | aspects of the _FileResponse_:
31 |
32 | ```php
33 | $fileResponse->setFile(
34 | file: '/path/to/file.b64', // or an SplFileObject instance
35 | disposition: 'attachment', // or 'inline'
36 | name: 'SomeOtherName.b64', // an alternative name for the download
37 | type: 'text/plain' // set this content-type
38 | encoding: 'base64' // set this content-transfer-encoding
39 | );
40 | ```
41 |
42 | In any case, you may always modify the _FileResponse_ values after
43 | `setContent()` or `setFile()`.
44 |
45 | ## JsonResponse
46 |
47 | The _JsonResponse_ is customized for sending back JSON content.
48 |
49 | Use `setContent()` to specify a value to be JSON-encoded at sending time:
50 |
51 | ```php
52 | use Sapien\Response\JsonResponse;
53 |
54 | $jsonResponse = new JsonResponse();
55 |
56 | // set the content to be encoded
57 | $jsonResponse->setContent(['foo' => 'bar']);
58 | ```
59 |
60 | The _JsonResponse_ will set itself up with ...
61 |
62 | - a `content-type` of `application/json`,
63 | - the default `json_encode()` flags and depth.
64 |
65 | Alternatively, call the `setJson()` method for better control over some aspects
66 | of the _JsonResponse_:
67 |
68 | ```php
69 | $jsonResponse->setJson(
70 | value: ['foo' => 'bar'], // the value to be encoded
71 | type: 'application/foo+json', // set this content-type
72 | flags: JSON_PRETTY_PRINT, // alternative json_encode() flags
73 | depth: 128 // alternative json_encode() depth
74 | );
75 | ```
76 |
77 | In any case, you may always modify the _JsonResponse_ values after
78 | `setContent()` or `setJson()`.
79 |
80 | Further, you may call `setJsonFlags()` and `setJsonDepth()` to modify the
81 | flags and depth respectively.
82 |
83 | Finally, when you actually `send()` it, the _JsonResponse_ will echo the
84 | results passing the content through `json_encode()`.
85 |
--------------------------------------------------------------------------------
/tests/Request/ContentTest.php:
--------------------------------------------------------------------------------
1 | assertSame('', $request->content->body);
17 | $this->assertNull($request->content->charset);
18 | $this->assertNull($request->content->length);
19 | $this->assertNull($request->content->md5);
20 | $this->assertNull($request->content->type);
21 | }
22 |
23 | public function testNoSuchProperty() : void
24 | {
25 | $request = new Request();
26 | $this->expectException(Exception::CLASS);
27 | $this->expectExceptionMessage('Sapien\Request\Content::$nonesuch does not exist.');
28 | $request->content->nonesuch; // @phpstan-ignore-line intentional get of undefined property
29 | }
30 |
31 | public function testAll() : void
32 | {
33 | $content = 'Hello World!';
34 | $length = strlen($content);
35 | $md5 = md5($content);
36 |
37 | $_SERVER = [
38 | 'CONTENT_LENGTH' => (string) $length,
39 | 'CONTENT_TYPE' => 'text/plain; charset=utf-8',
40 | 'HTTP_CONTENT_MD5' => $md5,
41 | ];
42 |
43 | $request = new Request(content: $content);
44 |
45 | $this->assertSame($content, $request->content->body);
46 | $this->assertSame('utf-8', $request->content->charset);
47 | $this->assertSame($length, $request->content->length);
48 | $this->assertSame($md5, $request->content->md5);
49 | $this->assertSame('text/plain', $request->content->type);
50 | }
51 |
52 | public function testEmptyType() : void
53 | {
54 | $_SERVER = [
55 | 'CONTENT_TYPE' => '',
56 | ];
57 |
58 | $request = new Request();
59 |
60 | $this->assertNull($request->content->type);
61 | $this->assertNull($request->content->charset);
62 | }
63 |
64 | public function testTypeButNoCharset() : void
65 | {
66 | $_SERVER = [
67 | 'CONTENT_TYPE' => 'text/plain; foo=bar',
68 | ];
69 |
70 | $request = new Request();
71 |
72 | $this->assertSame('text/plain', $request->content->type);
73 | $this->assertNull($request->content->charset);
74 | }
75 |
76 | public function testParsedBodyJson() : void
77 | {
78 | $_SERVER = ['CONTENT_TYPE' => 'application/json;charset=utf-8'];
79 | $expect = ['foo' => 'bar'];
80 | $request = new Request(content: (string) json_encode($expect));
81 | $this->assertSame($expect, $request->input);
82 |
83 | $request = new Request(content: 'null');
84 | $this->assertSame([], $request->input);
85 | }
86 | }
87 |
--------------------------------------------------------------------------------
/docs/response/cookies.md:
--------------------------------------------------------------------------------
1 | # Cookies
2 |
3 | The _Response_ retains each cookie as a _Sapien\Response\Cookie_ value object.
4 |
5 | ## Setting
6 |
7 | ### Setting One Encoded Cookie
8 |
9 | ```
10 | final public setCookie(
11 | string $name,
12 | string $value = '',
13 | int $expires = null,
14 | string $path = null,
15 | string $domain = null,
16 | bool $secure = null,
17 | bool $httponly = null,
18 | string $samesite = null
19 | ) : static
20 | ```
21 |
22 | A buffered equivalent of [`setcookie()`](http://php.net/setcookie), with the
23 | various options expanded out to method parameters.
24 |
25 | The method is fluent, allowing you to chain a call to another _Response_ method.
26 |
27 | ### Setting One Raw Cookie
28 |
29 | ```
30 | final public setRawCookie(
31 | string $name,
32 | string $value = '',
33 | int $expires = null,
34 | string $path = null,
35 | string $domain = null,
36 | bool $secure = null,
37 | bool $httponly = null,
38 | string $samesite = null
39 | ) : static
40 | ```
41 |
42 | A buffered equivalent of [`setrawcookie()`](http://php.net/setrawcookie), with
43 | the various options expanded out to method parameters.
44 |
45 | The method is fluent, allowing you to chain a call to another _Response_ method.
46 |
47 | ### Setting One Cookie Instance
48 |
49 | ```
50 | final public setCookie(
51 | string $name,
52 | Cookie $value
53 | ) : static
54 | ```
55 |
56 | If you have a _Cookie_ instance in hand, you may set it into the _Reponse_ using
57 | `setCookie()`.
58 |
59 | The method is fluent, allowing you to chain a call to another _Response_ method.
60 |
61 | ### Setting All Cookies
62 |
63 | `final public setCookies(array $cookies) : static`
64 |
65 | Resets the _Response_ cookies to the key-value pairs of `$cookies`. The value
66 | may be a string, in which case the value will be encoded, or it may be a
67 | _Cookie_ instance, in which case it will be retained as-is.
68 |
69 | The method is fluent, allowing you to chain a call to another _Response_ method.
70 |
71 | ## Getting
72 |
73 | ### Getting One Cookie
74 |
75 | `final public getCookie(string $name) : ?Cookie`
76 |
77 | Returns the `$name` _Cookie_ from the _Response_.
78 |
79 | ### Getting All Cookies
80 |
81 | `final public getCookies() : array`
82 |
83 | Returns the array of _Cookie_ objects in the _Response_.
84 |
85 | ## Checking
86 |
87 | `final public hasCookie(string $name) : bool`
88 |
89 | Returns `true` if the `$name` _Cookie_ exists in the _Response_, `false` if not.
90 |
91 | ## Removing
92 |
93 | ### Removing One Cookie
94 |
95 | `final public unsetCookie(string $name) : static`
96 |
97 | Removes the `$name` _Cookie_ from the _Response_.
98 |
99 | The method is fluent, allowing you to chain a call to another _Response_ method.
100 |
101 | ### Removing All Cookies
102 |
103 | `final public unsetCookies() : static`
104 |
105 | Removes all _Cookie_ objects from the _Response_.
106 |
107 | The method is fluent, allowing you to chain a call to another _Response_ method.
108 |
--------------------------------------------------------------------------------
/src/Request/UploadCollection.php:
--------------------------------------------------------------------------------
1 | files)) {
37 | return new static();
38 | }
39 |
40 | return static::newFromFiles($request->files);
41 | }
42 |
43 | /**
44 | * @param FilesArray $files
45 | */
46 | public static function newFromFiles(array $files) : static
47 | {
48 | $items = [];
49 |
50 | /** @var FilesArray|FilesArrayNested|FileArray $file */
51 | foreach ($files as $key => $file) {
52 | $items[$key] = static::newFromFile($file);
53 | }
54 |
55 | return new static($items);
56 | }
57 |
58 | /**
59 | * @param FilesArray|FilesArrayNested|FileArray $file
60 | */
61 | protected static function newFromFile(array $file) : static|Upload
62 | {
63 | if (is_array($file['tmp_name'] ?? null)) {
64 | /** @var FilesArrayNested $file */
65 | return static::newFromNested($file);
66 | }
67 |
68 | if (! is_string($file['tmp_name'] ?? null)) {
69 | /** @var FilesArray $file */
70 | return static::newFromFiles($file);
71 | }
72 |
73 | /** @var FileArray $file */
74 | return new Upload(
75 | $file['name'],
76 | $file['full_path'],
77 | $file['type'],
78 | $file['size'],
79 | $file['tmp_name'],
80 | $file['error'],
81 | );
82 | }
83 |
84 | /**
85 | * @param FilesArrayNested $nested
86 | */
87 | protected static function newFromNested(array $nested) : static
88 | {
89 | $items = [];
90 | $keys = array_keys((array) $nested['tmp_name']);
91 |
92 | foreach ($keys as $key) {
93 | $file = [
94 | 'name' => $nested['name'][$key] ?? null,
95 | 'full_path' => $nested['full_path'][$key] ?? null,
96 | 'type' => $nested['type'][$key] ?? null,
97 | 'size' => $nested['size'][$key] ?? null,
98 | 'tmp_name' => $nested['tmp_name'][$key] ?? null,
99 | 'error' => $nested['error'][$key] ?? null,
100 | ];
101 |
102 | $items[$key] = static::newFromFile($file);
103 | }
104 |
105 | return new static($items);
106 | }
107 | }
108 |
--------------------------------------------------------------------------------
/docs/request/uploads.md:
--------------------------------------------------------------------------------
1 | # Uploads
2 |
3 | The _Request_ `$uploads` property is a _ValueCollection_ of _Sapien\Request\Upload_ objects.
4 |
5 | Each _Upload_ object is composed of these public readonly properties:
6 |
7 | - `?string $name`: The original name of the file on the client machine.
8 |
9 | - `?string $fullPath`: The original path of the file on the client machine.
10 |
11 | - `?string $type`: The mime type of the file, if the client provided this
12 | information.
13 |
14 | - `?int $size`: The size, in bytes, of the uploaded file.
15 |
16 | - `?string $tmpName`: The temporary filename of the file in which the uploaded
17 | file was stored on the server.
18 |
19 | - `?int $error`: The [error code](https://www.php.net/manual/en/features.file-upload.errors.php)
20 | associated with this file upload.
21 |
22 | These values are derived from the _Request_ `$files` array.
23 |
24 | In addition, each _Upload_ object has this public method:
25 |
26 | - `move(string $destination) : bool`: The equivalent of
27 | [`move_uploaded_file`](https://www.php.net/move_uploaded_file).
28 |
29 | ## Motivation
30 |
31 | The _Request_ `$files` property is an identical copy of `$_FILES`. Normally,
32 | `$_FILES` looks like this with multi-file uploads:
33 |
34 | ```php
35 | // $_FILES ...
36 | [
37 | 'images' => [
38 | 'name' => [
39 | 0 => 'image1.png',
40 | 1 => 'image2.gif',
41 | 2 => 'image3.jpg',
42 | ],
43 | 'full_path' => [
44 | 0 => 'image1.png',
45 | 1 => 'image2.gif',
46 | 2 => 'image3.jpg',
47 | ],
48 | 'type' => [
49 | 0 => 'image/png',
50 | 1 => 'image/gif',
51 | 2 => 'image/jpeg',
52 | ],
53 | 'tmp_name' [
54 | 0 => '/tmp/path/phpABCDEF',
55 | 1 => '/tmp/path/phpGHIJKL',
56 | 2 => '/tmp/path/phpMNOPQR',
57 | ],
58 | 'error' => [
59 | 0 => 0,
60 | 1 => 0,
61 | 2 => 0,
62 | ],
63 | 'size' =>[
64 | 0 => 123456,
65 | 1 => 234567,
66 | 2 => 345678,
67 | ],
68 | ],
69 | ];
70 | ```
71 |
72 | However, that structure is surprising when we are used to working with `$_POST`.
73 |
74 | Therefore, the _Request_ `$uploads` property restructures the data in
75 | `$_FILES` to look like `$_POST` does ...
76 |
77 | ```php
78 | // $request->uploads in transition ...
79 | [
80 | 'images' => [
81 | 0 => [
82 | 'name' => 'image1.png',
83 | 'full_path' => 'image1.png',
84 | 'type' => 'image/png',
85 | 'tmp_name' => '/tmp/path/phpABCDEF',
86 | 'error' => 0,
87 | 'size' => 123456,
88 | ],
89 | 1 => [
90 | 'name' => 'image2.gif',
91 | 'full_path' => 'image2.gif',
92 | 'type' => 'image/gif',
93 | 'tmp_name' => '/tmp/path/phpGHIJKL',
94 | 'error' => 0,
95 | 'size' => 234567,
96 | ],
97 | 2 => [
98 | 'name' => 'image3.jpg',
99 | 'full_path' => 'image3.jpg',
100 | 'type' => 'image/jpeg',
101 | 'tmp_name' => '/tmp/path/phpMNOPQR',
102 | 'error' => 0,
103 | 'size' => 345678,
104 | ],
105 | ],
106 | ];
107 | ```
108 |
109 | ... and then replaces each array-based descriptor with an _Upload_ instance.
110 |
--------------------------------------------------------------------------------
/tests/Request/Header/AuthorizationTest.php:
--------------------------------------------------------------------------------
1 | assertInstanceOf(Authorization\Scheme\Basic::CLASS, $request->authorization);
16 | $this->assertSame('boshag', $request->authorization->username);
17 | $this->assertSame('bopass', $request->authorization->password);
18 | $this->assertTrue($request->authorization->is('bASIc'));
19 | }
20 |
21 | public function testBearer() : void
22 | {
23 | $_SERVER['HTTP_AUTHORIZATION'] = 'Bearer foobarbaz';
24 | $request = new Request();
25 | $this->assertInstanceOf(Authorization\Scheme\Bearer::CLASS, $request->authorization);
26 | $this->assertSame('foobarbaz', $request->authorization->token);
27 | $this->assertTrue($request->authorization->is('bearer'));
28 | }
29 |
30 | public function testDigest() : void
31 | {
32 | $parts = [
33 | 'username="boshag"',
34 | 'realm="test@example.org"',
35 | 'nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093"',
36 | 'uri="/foo/bar"',
37 | 'qop=auth',
38 | 'nc=00000001',
39 | 'cnonce="0a4f113b"',
40 | 'response="6629fae49393a05397450978507c4ef1"',
41 | 'opaque="5ccc069c403ebaf9f0171e9517f40e41"',
42 | 'userhash=false',
43 | ];
44 | $_SERVER['HTTP_AUTHORIZATION'] = 'Digest ' . implode(', ', $parts);
45 | $request = new Request();
46 | $this->assertInstanceOf(Authorization\Scheme\Digest::CLASS, $request->authorization);
47 | $this->assertSame('boshag', $request->authorization->username);
48 | $this->assertSame('test@example.org', $request->authorization->realm);
49 | $this->assertSame('dcd98b7102dd2f0e8b11d0f600bfb0c093', $request->authorization->nonce);
50 | $this->assertSame('/foo/bar', $request->authorization->uri);
51 | $this->assertSame('auth', $request->authorization->qop);
52 | $this->assertSame(1, $request->authorization->nc);
53 | $this->assertSame('0a4f113b', $request->authorization->cnonce);
54 | $this->assertSame('6629fae49393a05397450978507c4ef1', $request->authorization->response);
55 | $this->assertSame('5ccc069c403ebaf9f0171e9517f40e41', $request->authorization->opaque);
56 | $this->assertTrue($request->authorization->is('digest'));
57 | }
58 |
59 | public function testNone() : void
60 | {
61 | $request = new Request();
62 | $this->assertInstanceOf(Authorization\None::CLASS, $request->authorization);
63 | $this->assertTrue($request->authorization->is(null));
64 |
65 | $_SERVER['HTTP_AUTHORIZATION'] = '----';
66 | $request = new Request();
67 | $this->assertInstanceOf(Authorization\None::CLASS, $request->authorization);
68 | $this->assertTrue($request->authorization->is(null));
69 | }
70 |
71 | public function testGeneric() : void
72 | {
73 | $_SERVER['HTTP_AUTHORIZATION'] = 'Foo barbazdib';
74 | $request = new Request();
75 | $this->assertInstanceOf(Authorization\Generic::CLASS, $request->authorization);
76 | $this->assertSame('Foo', $request->authorization->scheme);
77 | $this->assertSame('barbazdib', $request->authorization->credentials);
78 | $this->assertTrue($request->authorization->is('foo'));
79 | }
80 | }
81 |
--------------------------------------------------------------------------------
/src/Request/Url.php:
--------------------------------------------------------------------------------
1 | server;
34 |
35 | if (empty($server)) {
36 | return new static();
37 | }
38 |
39 | $url = [];
40 |
41 | // scheme
42 | $scheme = 'http://';
43 |
44 | if (isset($server['HTTPS']) && strtolower($server['HTTPS']) == 'on') {
45 | $scheme = 'https://';
46 | }
47 |
48 | // host
49 | if (isset($server['HTTP_HOST'])) {
50 | $host = $server['HTTP_HOST'];
51 | } elseif (isset($server['SERVER_NAME'])) {
52 | $host = $server['SERVER_NAME'];
53 | } else {
54 | $host = '___';
55 | }
56 |
57 | // port
58 | preg_match('#\:[0-9]+$#', $host, $matches);
59 |
60 | if ($matches) {
61 | $host_port = array_pop($matches);
62 | $host = substr($host, 0, -strlen($host_port));
63 | }
64 |
65 | $port = isset($server['SERVER_PORT']) ? ':' . $server['SERVER_PORT'] : '';
66 |
67 | if ($port == '' && ! empty($host_port)) {
68 | $port = $host_port;
69 | }
70 |
71 | // all else
72 | $uri = isset($server['REQUEST_URI']) ? $server['REQUEST_URI'] : '';
73 |
74 | if ($host == '___' && $port === '' && $uri === '') {
75 | return new static();
76 | }
77 |
78 | $url = $scheme . $host . $port . $uri;
79 |
80 | $base = [
81 | 'scheme' => null,
82 | 'host' => null,
83 | 'port' => null,
84 | 'user' => null,
85 | 'pass' => null,
86 | 'path' => null,
87 | 'query' => null,
88 | 'fragment' => null,
89 | ];
90 |
91 | $url = array_merge($base, (array) parse_url($url));
92 |
93 | if ($host === '___') {
94 | $url['host'] = null;
95 | }
96 |
97 | return new static(...$url);
98 | }
99 |
100 | public function __construct(
101 | public readonly ?string $scheme = null,
102 | public readonly ?string $host = null,
103 | public readonly ?int $port = null,
104 | public readonly ?string $user = null,
105 | public readonly ?string $pass = null,
106 | public readonly ?string $path = null,
107 | public readonly ?string $query = null,
108 | public readonly ?string $fragment = null,
109 | ) {
110 | }
111 |
112 | public function __toString() : string
113 | {
114 | $info = $this->user;
115 | $info .= $this->pass ? ":{$this->pass}" : "";
116 | $info .= $info ? "@" : "";
117 | $port = $this->port ? ":{$this->port}" : "";
118 | $query = $this->query ? "?{$this->query}" : "";
119 | $fragment = $this->fragment ? "#{$this->fragment}" : "";
120 | return "{$this->scheme}://{$info}{$this->host}{$port}{$this->path}{$query}{$fragment}";
121 | }
122 | }
123 |
--------------------------------------------------------------------------------
/src/Request/Content.php:
--------------------------------------------------------------------------------
1 | headers['content-type'])) {
28 | return null;
29 | }
30 |
31 | $parts = explode(';', $request->headers['content-type']);
32 | array_shift($parts);
33 |
34 | if (empty($parts)) {
35 | return null;
36 | }
37 |
38 | foreach ($parts as $part) {
39 | $part = str_replace(' ', '', $part);
40 |
41 | if (substr($part, 0, 8) === 'charset=') {
42 | return $contentCharset = trim(substr($part, 8));
43 | }
44 | }
45 |
46 | return null;
47 | }
48 |
49 | protected static function newLength(Request $request) : ?int
50 | {
51 | if (! isset($request->headers['content-length'])) {
52 | return null;
53 | }
54 |
55 | $contentLength = null;
56 | $value = trim($request->headers['content-length']);
57 | $noint = trim($value, '01234567890');
58 |
59 | if ($noint === '') {
60 | $contentLength = (int) $value;
61 | }
62 |
63 | return $contentLength;
64 | }
65 |
66 | protected static function newMd5(Request $request) : ?string
67 | {
68 | if (! isset($request->headers['content-md5'])) {
69 | return null;
70 | }
71 |
72 | return trim($request->headers['content-md5']);
73 | }
74 |
75 | protected static function newType(Request $request) : ?string
76 | {
77 | if (! isset($request->headers['content-type'])) {
78 | return null;
79 | }
80 |
81 | $parts = explode(';', $request->headers['content-type']);
82 | $contentType = null;
83 | $type = array_shift($parts);
84 | $regex = '/^[!#$%&\'*+.^_`|~0-9A-Za-z-]+\/[!#$%&\'*+.^_`|~0-9A-Za-z-]+$/';
85 |
86 | if (preg_match($regex, $type) === 1) {
87 | $contentType = $type;
88 | }
89 |
90 | return $contentType;
91 | }
92 |
93 | public function __construct(
94 | private readonly ?string $body = null,
95 | public readonly ?string $charset = null,
96 | public readonly ?int $length = null,
97 | public readonly ?string $md5 = null,
98 | public readonly ?string $type = null,
99 | ) {
100 | }
101 |
102 | public function __get(string $key) : mixed
103 | {
104 | if ($key === 'body') {
105 | return $this->getBody();
106 | }
107 |
108 | return parent::__get($key);
109 | }
110 |
111 | protected function getBody() : string
112 | {
113 | return $this->body ?? (string) file_get_contents('php://input');
114 | }
115 |
116 | /**
117 | * @return mixed[]
118 | */
119 | public function getParsedBody() : ?array
120 | {
121 | if (strtolower($this->type ?? '') !== 'application/json') {
122 | return null;
123 | }
124 |
125 | $result = json_decode($this->getBody(), true, 512, JSON_BIGINT_AS_STRING);
126 | return is_array($result) ? $result : null;
127 | }
128 | }
129 |
--------------------------------------------------------------------------------
/tests/Request/Header/AcceptTest.php:
--------------------------------------------------------------------------------
1 | 'bar',
21 | ],
22 | ),
23 | new Accept\Type(
24 | value: 'application/xml',
25 | quality: '0.8',
26 | params: [],
27 | ),
28 | new Accept\Type(
29 | value: 'text/*',
30 | quality: '0.2',
31 | params: [],
32 | ),
33 | new Accept\Type(
34 | value: '*/*',
35 | quality: '0.1',
36 | params: [],
37 | ),
38 | ]);
39 | $this->assertEquals($expect, $request->accept->types);
40 | }
41 |
42 | public function testCharsets() : void
43 | {
44 | $_SERVER['HTTP_ACCEPT_CHARSET'] = 'iso-8859-5;q=0.8, unicode-1-1';
45 | $request = new Request();
46 | $expect = new Accept\CharsetCollection([
47 | new Accept\Charset(
48 | value: 'unicode-1-1',
49 | quality: '1.0',
50 | params: [],
51 | ),
52 | new Accept\Charset(
53 | value: 'iso-8859-5',
54 | quality: '0.8',
55 | params: [],
56 | ),
57 | ]);
58 | $this->assertEquals($expect, $request->accept->charsets);
59 | }
60 |
61 | public function testEncodings() : void
62 | {
63 | $_SERVER['HTTP_ACCEPT_ENCODING'] = 'compress;q=0.5, gzip;q=1.0';
64 | $request = new Request();
65 | $expect = new Accept\EncodingCollection([
66 | new Accept\Encoding(
67 | value: 'gzip',
68 | quality: '1.0',
69 | params: [],
70 | ),
71 | new Accept\Encoding(
72 | value: 'compress',
73 | quality: '0.5',
74 | params: [],
75 | ),
76 | ]);
77 | $this->assertEquals($expect, $request->accept->encodings);
78 | }
79 |
80 | public function testLanguages() : void
81 | {
82 | $_SERVER['HTTP_ACCEPT_LANGUAGE'] = 'en-US, en-GB, en, *';
83 | $request = new Request();
84 | $expect = new Accept\LanguageCollection([
85 | new Accept\Language(
86 | value: 'en-US',
87 | quality: '1.0',
88 | params: [],
89 | type: 'en',
90 | subtype: 'US',
91 | ),
92 | new Accept\Language(
93 | value: 'en-GB',
94 | quality: '1.0',
95 | params: [],
96 | type: 'en',
97 | subtype: 'GB',
98 | ),
99 | new Accept\Language(
100 | value: 'en',
101 | quality: '1.0',
102 | params: [],
103 | type: 'en',
104 | subtype: NULL,
105 | ),
106 | new Accept\Language(
107 | value: '*',
108 | quality: '1.0',
109 | params: [],
110 | type: '*',
111 | subtype: NULL,
112 | ),
113 | ]);
114 | $this->assertEquals($expect, $request->accept->languages);
115 | }
116 |
117 | public function testEmpty() : void
118 | {
119 | $_SERVER['HTTP_ACCEPT'] = '';
120 | $request = new Request();
121 | $this->assertTrue($request->accept->types->isEmpty());
122 | }
123 | }
124 |
--------------------------------------------------------------------------------
/tests/Request/UrlTest.php:
--------------------------------------------------------------------------------
1 | 'http',
17 | 'host' => 'example.com',
18 | 'port' => 8080,
19 | 'path' => '/foo/bar',
20 | 'query' => 'baz=dib',
21 | ]);
22 |
23 | $expect = [
24 | 'scheme' => 'http',
25 | 'host' => 'example.com',
26 | 'port' => 8080,
27 | 'user' => null,
28 | 'pass' => null,
29 | 'path' => '/foo/bar',
30 | 'query' => 'baz=dib',
31 | 'fragment' => null,
32 | ];
33 |
34 | $this->assertSame($expect, $request->url->asArray());
35 | }
36 |
37 | public function testTypical() : void
38 | {
39 | $_SERVER = [
40 | 'HTTP_HOST' => 'example.com',
41 | 'SERVER_PORT' => '8080',
42 | 'REQUEST_URI' => '/foo/bar?baz=dib',
43 | ];
44 |
45 | $request = new Request();
46 |
47 | $expect = [
48 | 'scheme' => 'http',
49 | 'host' => 'example.com',
50 | 'port' => 8080,
51 | 'user' => null,
52 | 'pass' => null,
53 | 'path' => '/foo/bar',
54 | 'query' => 'baz=dib',
55 | 'fragment' => null,
56 | ];
57 |
58 | $this->assertSame($expect, $request->url->asArray());
59 | }
60 |
61 | public function testEmpty() : void
62 | {
63 | $expect = [
64 | 'scheme' => null,
65 | 'host' => null,
66 | 'port' => null,
67 | 'user' => null,
68 | 'pass' => null,
69 | 'path' => null,
70 | 'query' => null,
71 | 'fragment' => null,
72 | ];
73 |
74 | $_SERVER = [];
75 | $request = new Request();
76 | $this->assertSame($expect, $request->url->asArray());
77 |
78 | $_SERVER = null;
79 | $request = new Request();
80 | $this->assertSame($expect, $request->url->asArray());
81 | }
82 |
83 | public function testHttps() : void
84 | {
85 | $_SERVER['HTTPS'] = 'on';
86 | $_SERVER['SERVER_NAME'] = 'example.com';
87 |
88 | $request = new Request();
89 | $this->assertSame('https', $request->url->scheme);
90 | $this->assertSame('example.com', $request->url->host);
91 | }
92 |
93 | public function testNoHost() : void
94 | {
95 | $_SERVER['REQUEST_URI'] = '/';
96 | $request = new Request();
97 | $expect = [
98 | 'scheme' => 'http',
99 | 'host' => null,
100 | 'port' => null,
101 | 'user' => null,
102 | 'pass' => null,
103 | 'path' => '/',
104 | 'query' => null,
105 | 'fragment' => null,
106 | ];
107 | $this->assertSame($expect, $request->url->asArray());
108 | }
109 |
110 | public function testHostPort() : void
111 | {
112 | $_SERVER = [
113 | 'HTTP_HOST' => 'example.com:8080',
114 | ];
115 |
116 | $request = new Request();
117 | $this->assertSame('example.com', $request->url->host);
118 | $this->assertSame(8080, $request->url->port);
119 | }
120 |
121 | /**
122 | * @dataProvider provideString
123 | */
124 | public function testString(string $expect) : void
125 | {
126 | /**
127 | * @var UrlArray
128 | */
129 | $url = parse_url($expect);
130 | $request = new Request(url: $url);
131 | $this->assertSame($expect, (string) $request->url);
132 | }
133 |
134 | /**
135 | * @return array
136 | */
137 | public static function provideString() : array
138 | {
139 | return [
140 | ['http://user:pass@example.com:8000/foo?bar=baz#dib'],
141 | ['http://user:pass@example.com:8000/foo?bar=baz'],
142 | ['http://user:pass@example.com:8000/foo#dib'],
143 | ['http://user:pass@example.com:8000/?bar=baz'],
144 | ['http://user:pass@example.com:8000/foo'],
145 | ['http://user:pass@example.com:8000/'],
146 | ['http://user:pass@example.com:8000'],
147 | ['http://user:pass@example.com'],
148 | ['http://user@example.com'],
149 | ['http://example.com'],
150 | ];
151 | }
152 | }
153 |
--------------------------------------------------------------------------------
/tests/RequestTest.php:
--------------------------------------------------------------------------------
1 | assertInstanceOf(Request::CLASS, $request);
23 | $this->assertTrue(empty($request->headers));
24 | $this->assertTrue($request->uploads->isEmpty());
25 | $this->assertInstanceOf(Request\Method::CLASS, $request->method);
26 | $this->assertInstanceOf(Request\Url::CLASS, $request->url);
27 | $this->assertTrue(isset($request->accept));
28 | }
29 |
30 | public function testMissingProperty() : void
31 | {
32 | $request = new Request();
33 | $this->assertFalse(isset($request->nonesuch));
34 |
35 | $this->expectException(Exception::CLASS);
36 | $this->expectExceptionMessage('Sapien\Request::$nonesuch does not exist.');
37 | $request->nonesuch; // @phpstan-ignore-line intentional get of undefined property
38 | }
39 |
40 | /**
41 | * @dataProvider provideMostGlobals
42 | */
43 | public function testMostGlobals(string $GLOBAL, string $prop) : void
44 | {
45 | $GLOBALS[$GLOBAL] = ['foo' => 'bar'];
46 | $request = new Request();
47 | $this->assertSame($GLOBALS[$GLOBAL], $request->$prop);
48 |
49 | $GLOBALS[$GLOBAL] = ['baz' => 'dib'];
50 | $this->assertNotSame($GLOBALS[$GLOBAL], $request->$prop);
51 |
52 | $expect = ['zim' => 'gir'];
53 | $request = new Request(globals: [$GLOBAL => $expect]);
54 | $this->assertSame($expect, $request->$prop);
55 |
56 | $message = version_compare(PHP_VERSION, '8.4.0', '>=')
57 | ? 'Cannot indirectly modify readonly property Sapien\Request::$' . $prop
58 | : 'Cannot modify readonly property Sapien\Request::$' . $prop;
59 |
60 | $this->expectException(Error::CLASS);
61 | $this->expectExceptionMessage($message);
62 | $request->$prop['zim'] = 'doom';
63 | }
64 |
65 | /**
66 | * @return array
67 | */
68 | public static function provideMostGlobals() : array
69 | {
70 | return [
71 | ['_COOKIE', 'cookies'],
72 | ['_POST', 'input'],
73 | ['_GET', 'query'],
74 | ['_SERVER', 'server'],
75 | ];
76 | }
77 |
78 | public function testFiles() : void
79 | {
80 | $_FILES = [
81 | 'foo1' => [
82 | 'error' => 0,
83 | 'name' => '',
84 | 'full_path' => '',
85 | 'size' => 0,
86 | 'tmp_name' => '',
87 | 'type' => '',
88 | ],
89 | ];
90 |
91 | $request = new Request();
92 |
93 | $this->assertSame($GLOBALS['_FILES'], $request->files);
94 |
95 | $_FILES = ['baz' => 'dib'];
96 | $this->assertNotSame($GLOBALS['_FILES'], $request->files);
97 |
98 | $expect = [
99 | 'foo2' => [
100 | 'error' => 0,
101 | 'name' => '',
102 | 'full_path' => '',
103 | 'size' => 0,
104 | 'tmp_name' => '',
105 | 'type' => '',
106 | ],
107 | ];
108 |
109 | $request = new Request(globals: ['_FILES' => $expect]);
110 | $this->assertSame($expect, $request->files);
111 |
112 | $message = version_compare(PHP_VERSION, '8.4.0', '>=')
113 | ? 'Cannot indirectly modify readonly property Sapien\Request::$files'
114 | : 'Cannot modify readonly property Sapien\Request::$files';
115 |
116 | $this->expectException(Error::CLASS);
117 | $this->expectExceptionMessage($message);
118 | $request->files['zim'] = 'doom'; // @phpstan-ignore-line intentional set of readonly property
119 | }
120 |
121 | public function testImmutable() : void
122 | {
123 | $this->expectException(Exception::CLASS);
124 | $this->expectExceptionMessage('Immutable values must be null, scalar, or array.');
125 | $request = new Request(globals: ['_SERVER' => ['foo' => new \stdClass()]]);
126 | }
127 |
128 | public function testHeaders() : void
129 | {
130 | $request = new Request(
131 | globals: [
132 | '_SERVER' => [
133 | 'HTTP_HOST' => 'example.com',
134 | 'HTTP_FOO_BAR_BAZ' => 'dib,zim,gir',
135 | 'NON_HTTP_HEADER' => 'should not show',
136 | 'CONTENT_LENGTH' => '123',
137 | 'CONTENT_TYPE' => 'text/plain',
138 | ],
139 | ],
140 | );
141 |
142 | $expect = [
143 | 'host' => 'example.com',
144 | 'foo-bar-baz' => 'dib,zim,gir',
145 | 'content-length' => '123',
146 | 'content-type' => 'text/plain',
147 | ];
148 |
149 | $this->assertSame($expect, $request->headers);
150 | }
151 | }
152 |
--------------------------------------------------------------------------------
/src/Request.php:
--------------------------------------------------------------------------------
1 |
33 | */
34 | public readonly array $cookies;
35 |
36 | /**
37 | * @var mixed[]
38 | */
39 | public readonly array $files;
40 |
41 | private readonly ForwardedCollection $forwarded;
42 |
43 | /**
44 | * @var array
45 | */
46 | public readonly array $headers;
47 |
48 | /**
49 | * @var mixed[]
50 | */
51 | public readonly array $input;
52 |
53 | public readonly Method $method;
54 |
55 | /**
56 | * @var array
57 | */
58 | public readonly array $query;
59 |
60 | /**
61 | * @var array
62 | */
63 | public readonly array $server;
64 |
65 | public readonly UploadCollection $uploads;
66 |
67 | public readonly Url $url;
68 |
69 | private readonly XForwarded $xForwarded;
70 |
71 | /**
72 | * @param mixed[] $globals
73 | * @param UrlArray $url
74 | */
75 | public function __construct(
76 | ?array $globals = null,
77 | ?string $method = null,
78 | ?array $url = null,
79 | Content|string|null $content = null,
80 | ) {
81 | /** @var array */
82 | $server = $this->newGlobal($globals['_SERVER'] ?? $_SERVER);
83 | $this->server = $server;
84 | $this->headers = $this->newHeaders();
85 | $this->method = $this->newMethod($method);
86 | $this->url = $this->newUrl($url);
87 | $this->content = $this->newContent($content);
88 |
89 | /** @var array */
90 | $cookies = $this->newGlobal($globals['_COOKIE'] ?? $_COOKIE);
91 | $this->cookies = $cookies;
92 | $files = $this->newGlobal($globals['_FILES'] ?? $_FILES);
93 | $this->files = $files;
94 | $input = $globals['_POST'] ?? $this->content->getParsedBody() ?? $_POST;
95 | $this->input = $this->newGlobal($input);
96 | $this->uploads = $this->newUploads();
97 |
98 | /** @var array */
99 | $query = $this->newGlobal($globals['_GET'] ?? $_GET);
100 | $this->query = $query;
101 | }
102 |
103 | public function __get(string $key) : mixed
104 | {
105 | if (! property_exists($this, $key)) {
106 | return parent::__get($key);
107 | }
108 |
109 | if (! isset($this->{$key})) {
110 | $method = "new{$key}";
111 | $this->{$key} = $this->{$method}();
112 | }
113 |
114 | return $this->{$key};
115 | }
116 |
117 | public function __isset(string $key) : bool
118 | {
119 | return property_exists($this, $key);
120 | }
121 |
122 | protected function newAccept() : Accept
123 | {
124 | return Accept::new($this);
125 | }
126 |
127 | protected function newAuthorization() : Authorization\Scheme
128 | {
129 | return Authorization\Factory::new($this);
130 | }
131 |
132 | protected function newContent(mixed $content) : Content
133 | {
134 | return Content::new($this, $content);
135 | }
136 |
137 | protected function newForwarded() : ForwardedCollection
138 | {
139 | return ForwardedCollection::new($this);
140 | }
141 |
142 | /**
143 | * @return array
144 | */
145 | protected function newHeaders() : array
146 | {
147 | $headers = [];
148 |
149 | // headers prefixed with HTTP_*
150 | foreach ($this->server ?? [] as $key => $val) {
151 | if (substr($key, 0, 5) === 'HTTP_') {
152 | $key = substr($key, 5);
153 | $key = str_replace('_', '-', strtolower($key));
154 | $headers[$key] = (string) $val;
155 | }
156 | }
157 |
158 | // RFC 3875 headers not prefixed with HTTP_*
159 | if (isset($this->server['CONTENT_LENGTH'])) {
160 | $headers['content-length'] = (string) $this->server['CONTENT_LENGTH'];
161 | }
162 |
163 | if (isset($this->server['CONTENT_TYPE'])) {
164 | $headers['content-type'] = (string) $this->server['CONTENT_TYPE'];
165 | }
166 |
167 | return $headers;
168 | }
169 |
170 | /**
171 | * @return mixed[]
172 | */
173 | final protected function newGlobal(mixed $value) : array
174 | {
175 | if (! is_array($value)) {
176 | return [];
177 | }
178 |
179 | foreach ($value as $key => $val) {
180 | $value[$key] = $this->immutable($val);
181 | }
182 |
183 | return $value;
184 | }
185 |
186 | final protected function immutable(mixed $value) : mixed
187 | {
188 | if (is_null($value) || is_scalar($value)) {
189 | return $value;
190 | }
191 |
192 | if (is_array($value)) {
193 | foreach ($value as $key => $val) {
194 | $value[$key] = $this->immutable($val);
195 | }
196 |
197 | return $value;
198 | }
199 |
200 | throw new Exception("Immutable values must be null, scalar, or array.");
201 | }
202 |
203 | protected function newMethod(?string $method) : Method
204 | {
205 | return Method::new($this, $method);
206 | }
207 |
208 | protected function newUploads() : UploadCollection
209 | {
210 | return UploadCollection::new($this);
211 | }
212 |
213 | /**
214 | * @param ?UrlArray $url
215 | */
216 | protected function newUrl(?array $url) : Url
217 | {
218 | return Url::new($this, $url);
219 | }
220 |
221 | protected function newXForwarded() : XForwarded
222 | {
223 | return XForwarded::new($this);
224 | }
225 | }
226 |
--------------------------------------------------------------------------------
/tests/ResponseTest.php:
--------------------------------------------------------------------------------
1 | setVersion('2');
20 | $this->assertSame('2', $response->getVersion());
21 | }
22 |
23 | public function testCode() : void
24 | {
25 | $response = new Response();
26 | $response->setCode(123);
27 | $this->assertSame(123, $response->getCode());
28 | }
29 |
30 | protected function assertHeader(Response $response, string $label, string $expect) : void
31 | {
32 | /** @var Header */
33 | $actual = $response->getHeader($label);
34 | $this->assertSame($expect, $actual->value);
35 | }
36 |
37 | public function testHeaders() : void
38 | {
39 | $response = new Response();
40 |
41 | $response->setHeader('FOO', 'bar');
42 | $this->assertHeader($response, 'foo', 'bar');
43 |
44 | $response->addHeader('foo', 'baz');
45 | $this->assertHeader($response, 'foo', 'bar, baz');
46 |
47 | $response->addHeader('dib', 'zim');
48 | $this->assertHeader($response, 'dib', 'zim');
49 |
50 | $headers = $response->getHeaders();
51 | $this->assertCount(2, $response->getHeaders());
52 |
53 | $this->assertTrue($response->hasHeader('foo'));
54 | $response->unsetHeader('foo');
55 | $this->assertFalse($response->hasHeader('foo'));
56 |
57 | $response->unsetHeaders();
58 | $this->assertTrue(empty($response->getHeaders()));
59 |
60 | $response->setHeaders([
61 | 'foo' => 'bar',
62 | 'baz' => 'dib',
63 | 'zim' => 'gir',
64 | ]);
65 |
66 | $headers = $response->getHeaders();
67 | $this->assertCount(3, $response->getHeaders());
68 |
69 | $this->assertHeader($response, 'foo', 'bar');
70 | $this->assertHeader($response, 'baz', 'dib');
71 | $this->assertHeader($response, 'zim', 'gir');
72 | }
73 |
74 | /**
75 | * @dataProvider provideBadHeaderLabel
76 | */
77 | public function testBadHeaderLabel(string $method, string $label, string $value) : void
78 | {
79 | $response = new Response();
80 | $this->expectException(Exception::CLASS);
81 | $this->expectExceptionMessage('Header label cannot be blank');
82 | $response->$method($label, $value);
83 | }
84 |
85 | /**
86 | * @return array
87 | */
88 | public static function provideBadHeaderLabel() : array
89 | {
90 | return [
91 | ['setHeader', '', 'value'],
92 | ['addHeader', '', 'value'],
93 | ];
94 | }
95 |
96 | /**
97 | * @dataProvider provideBadHeaderValue
98 | */
99 | public function testBadHeaderValue(string $method, string $label, string $value) : void
100 | {
101 | $response = new Response();
102 | $this->expectException(Exception::CLASS);
103 | $this->expectExceptionMessage('Header value cannot be blank');
104 | $response->$method($label, $value);
105 | }
106 |
107 | /**
108 | * @return array
109 | */
110 | public static function provideBadHeaderValue() : array
111 | {
112 | return [
113 | ['setHeader', 'label', ''],
114 | ['addHeader', 'label', ''],
115 | ];
116 | }
117 |
118 | public function testCookies() : void
119 | {
120 | $response = new Response();
121 |
122 | $response->setCookie(
123 | name: 'foo',
124 | value: 'bar',
125 | );
126 |
127 | $expect = [
128 | 'func' => 'setcookie',
129 | 'value' => 'bar',
130 | 'options' => [
131 | ],
132 | ];
133 |
134 | /** @var Cookie */
135 | $actual = $response->getCookie('foo');
136 | $this->assertSame($expect, $actual->asArray());
137 |
138 | $response->setRawCookie(
139 | name: 'baz',
140 | value: 'dib',
141 | );
142 |
143 | $expect = [
144 | 'func' => 'setrawcookie',
145 | 'value' => 'dib',
146 | 'options' => [
147 | ],
148 | ];
149 |
150 | /** @var Cookie */
151 | $actual = $response->getCookie('baz');
152 | $this->assertSame($expect, $actual->asArray());
153 |
154 | $expect = ['foo', 'baz'];
155 | $cookies = $response->getCookies();
156 | $this->assertSame($expect, array_keys($cookies));
157 |
158 |
159 | $this->assertTrue($response->hasCookie('foo'));
160 | $response->unsetCookie('foo');
161 | $this->assertFalse($response->hasCookie('foo'));
162 |
163 | $response->unsetCookies();
164 | $this->assertTrue(empty($response->getCookies()));
165 |
166 | $response->setCookies($cookies);
167 | $this->assertEquals($cookies, $response->getCookies());
168 | }
169 |
170 | public function testHeaderCallbacks() : void
171 | {
172 | $response = new Response();
173 |
174 | $this->assertFalse($response->hasHeaderCallbacks());
175 |
176 | $response->addHeaderCallback('ltrim');
177 | $response->addHeaderCallback('rtrim');
178 | $expect = ['ltrim', 'rtrim'];
179 | $this->assertSame($expect, $response->getHeaderCallbacks());
180 |
181 | $response->setHeaderCallbacks(['rtrim', 'ltrim']);
182 | $expect = ['rtrim', 'ltrim'];
183 | $this->assertSame($expect, $response->getHeaderCallbacks());
184 |
185 | $this->assertTrue($response->hasHeaderCallbacks());
186 |
187 | $response->unsetHeaderCallbacks();
188 | $this->assertCount(0, $response->getHeaderCallbacks());
189 | }
190 |
191 | public function testContent() : void
192 | {
193 | $response = new Response();
194 | $response->setContent('foo');
195 | $this->assertSame('foo', $response->getContent());
196 | }
197 |
198 |
199 | public function testSend() : void
200 | {
201 | $response = new Response();
202 | $response->setCode(206);
203 | $response->addHeader('foo', 'bar');
204 | $response->setCookie('baz', 'dib');
205 | $response->setContent('Hello World!');
206 | $response->addHeaderCallback(function ($response) {
207 | $response->addHeader('zim', 'gir');
208 | });
209 |
210 | $this->assertSent(
211 | $response,
212 | 206,
213 | [
214 | 'foo: bar',
215 | 'zim: gir',
216 | 'Set-Cookie: baz=dib',
217 | ],
218 | static::OUTPUT
219 | );
220 | }
221 |
222 | public function testSendContentIsEmpty() : void
223 | {
224 | $response = new Response();
225 | $this->assertSent($response, 200, [], '');
226 | }
227 |
228 | public function testSendContentIsIterable() : void
229 | {
230 | $response = new Response();
231 | $response->setContent(['Hello ', 'World!']);
232 | $this->assertSent($response, 200, [], static::OUTPUT);
233 | }
234 |
235 | public function testSendContentIsResource() : void
236 | {
237 | $response = new Response();
238 | $response->setContent(fopen(__DIR__ . '/Response/fake-content.txt', 'rb'));
239 | $this->assertSent($response, 200, [], static::OUTPUT);
240 | }
241 |
242 | public function testSendContentIsSplFileObject() : void
243 | {
244 | $response = new Response();
245 |
246 | $response->setContent(new SplFileObject(
247 | __DIR__ . '/Response/fake-content.txt',
248 | 'rb'
249 | ));
250 | $this->assertSent($response, 200, [], static::OUTPUT);
251 | }
252 |
253 | public function testSendContentIsCallableReturning() : void
254 | {
255 | $response = new Response();
256 | $response->setContent(function () : string {
257 | return static::OUTPUT;
258 | });
259 | $this->assertSent($response, 200, [], static::OUTPUT);
260 | }
261 |
262 | public function testSendContentIsCallableEchoing() : void
263 | {
264 | $response = new Response();
265 | $response->setContent(function () : void {
266 | echo static::OUTPUT;
267 | });
268 | $this->assertSent($response, 200, [], static::OUTPUT);
269 | }
270 | }
271 |
--------------------------------------------------------------------------------
/src/Response.php:
--------------------------------------------------------------------------------
1 |
19 | */
20 | private array $headers = [];
21 |
22 | /**
23 | * @var array
24 | */
25 | private array $cookies = [];
26 |
27 | private mixed $content = null;
28 |
29 | /**
30 | * @var callable[]
31 | */
32 | private array $headerCallbacks = [];
33 |
34 | final public function setVersion(?string $version) : static
35 | {
36 | $this->version = $version;
37 | return $this;
38 | }
39 |
40 | final public function getVersion() : ?string
41 | {
42 | return $this->version;
43 | }
44 |
45 | final public function setCode(?int $code) : static
46 | {
47 | $this->code = $code;
48 | return $this;
49 | }
50 |
51 | final public function getCode() : ?int
52 | {
53 | return $this->code;
54 | }
55 |
56 | final public function setHeader(string $label, Header|string $value) : static
57 | {
58 | $label = strtolower(trim($label));
59 |
60 | if ($label === '') {
61 | throw new Exception('Header label cannot be blank');
62 | }
63 |
64 | if (is_string($value)) {
65 | $value = new Header($value);
66 | }
67 |
68 | $this->headers[$label] = $value;
69 | return $this;
70 | }
71 |
72 | final public function addHeader(string $label, Header|string $value) : static
73 | {
74 | $label = strtolower(trim($label));
75 |
76 | if ($label === '') {
77 | throw new Exception('Header label cannot be blank');
78 | }
79 |
80 | if (is_string($value)) {
81 | $value = new Header($value);
82 | }
83 |
84 | if (! isset($this->headers[$label])) {
85 | $this->headers[$label] = $value;
86 | } else {
87 | $this->headers[$label]->add($value);
88 | }
89 |
90 | return $this;
91 | }
92 |
93 | final public function unsetHeader(string $label) : static
94 | {
95 | $label = strtolower(trim($label));
96 | unset($this->headers[$label]);
97 | return $this;
98 | }
99 |
100 | /**
101 | * @param array $headers
102 | */
103 | final public function setHeaders(array $headers) : static
104 | {
105 | $this->headers = [];
106 |
107 | foreach ($headers as $label => $value) {
108 | $this->setHeader($label, $value);
109 | }
110 |
111 | return $this;
112 | }
113 |
114 | final public function unsetHeaders() : static
115 | {
116 | $this->headers = [];
117 | return $this;
118 | }
119 |
120 | /**
121 | * @return array
122 | */
123 | final public function getHeaders() : array
124 | {
125 | return $this->headers;
126 | }
127 |
128 | final public function getHeader(string $label) : ?Header
129 | {
130 | $label = strtolower(trim($label));
131 | return $this->headers[$label] ?? null;
132 | }
133 |
134 | final public function hasHeader(string $label) : bool
135 | {
136 | $label = strtolower(trim($label));
137 | return isset($this->headers[$label]);
138 | }
139 |
140 | final public function setCookie(
141 | string $name,
142 | string|Cookie $value = '',
143 | ?int $expires = null,
144 | ?string $path = null,
145 | ?string $domain = null,
146 | ?bool $secure = null,
147 | ?bool $httponly = null,
148 | ?string $samesite = null,
149 | ) : static
150 | {
151 | if ($value instanceof Cookie) {
152 | $this->cookies[$name] = $value;
153 | return $this;
154 | }
155 |
156 | $this->cookies[$name] = new Cookie(
157 | 'setcookie',
158 | $value,
159 | [
160 | 'expires' => $expires,
161 | 'path' => $path,
162 | 'domain' => $domain,
163 | 'secure' => $secure,
164 | 'httponly' => $httponly,
165 | 'samesite' => $samesite,
166 | ],
167 | );
168 |
169 | return $this;
170 | }
171 |
172 | final public function setRawCookie(
173 | string $name,
174 | string $value = '',
175 | ?int $expires = null,
176 | ?string $path = null,
177 | ?string $domain = null,
178 | ?bool $secure = null,
179 | ?bool $httponly = null,
180 | ?string $samesite = null,
181 | ) : static
182 | {
183 | $this->cookies[$name] = new Cookie(
184 | 'setrawcookie',
185 | $value,
186 | [
187 | 'expires' => $expires,
188 | 'path' => $path,
189 | 'domain' => $domain,
190 | 'secure' => $secure,
191 | 'httponly' => $httponly,
192 | 'samesite' => $samesite,
193 | ],
194 | );
195 |
196 | return $this;
197 | }
198 |
199 | final public function unsetCookie(string $name) : static
200 | {
201 | unset($this->cookies[$name]);
202 | return $this;
203 | }
204 |
205 | final public function unsetCookies() : static
206 | {
207 | $this->cookies = [];
208 | return $this;
209 | }
210 |
211 | /**
212 | * @return array
213 | */
214 | final public function getCookies() : array
215 | {
216 | return $this->cookies;
217 | }
218 |
219 | /**
220 | * @param array $cookies
221 | */
222 | final public function setCookies(array $cookies) : static
223 | {
224 | $this->cookies = [];
225 |
226 | foreach ($cookies as $name => $value) {
227 | $this->setCookie($name, $value);
228 | }
229 |
230 | return $this;
231 | }
232 |
233 | final public function getCookie(string $name) : ?Cookie
234 | {
235 | return $this->cookies[$name] ?? null;
236 | }
237 |
238 | final public function hasCookie(string $name) : bool
239 | {
240 | return isset($this->cookies[$name]);
241 | }
242 |
243 | /**
244 | * @param callable[] $headerCallbacks
245 | */
246 | final public function setHeaderCallbacks(array $headerCallbacks) : static
247 | {
248 | $this->headerCallbacks = [];
249 |
250 | foreach ($headerCallbacks as $headerCallback) {
251 | $this->addHeaderCallback($headerCallback);
252 | }
253 |
254 | return $this;
255 | }
256 |
257 | final public function addHeaderCallback(callable $headerCallback) : static
258 | {
259 | $this->headerCallbacks[] = $headerCallback;
260 | return $this;
261 | }
262 |
263 | /**
264 | * @return callable[]
265 | */
266 | final public function getHeaderCallbacks() : array
267 | {
268 | return $this->headerCallbacks;
269 | }
270 |
271 | final public function hasHeaderCallbacks() : bool
272 | {
273 | return ! empty($this->headerCallbacks);
274 | }
275 |
276 | final public function unsetHeaderCallbacks() : static
277 | {
278 | $this->headerCallbacks = [];
279 | return $this;
280 | }
281 |
282 | final public function getContent() : mixed
283 | {
284 | return $this->content;
285 | }
286 |
287 | public function setContent(mixed $content) : static
288 | {
289 | $this->content = $content;
290 | return $this;
291 | }
292 |
293 | public function send() : void
294 | {
295 | foreach ($this->headerCallbacks as $callback) {
296 | $callback($this);
297 | }
298 |
299 | $version = $this->version ?? '1.1';
300 | $code = $this->code ?? 200;
301 | header("HTTP/{$version} {$code}", true, $code);
302 |
303 | foreach ($this->headers as $label => $value) {
304 | header("{$label}: {$value}", false);
305 | }
306 |
307 | foreach ($this->cookies as $name => $cookie) {
308 | ($cookie->func)($name, $cookie->value, $cookie->options);
309 | }
310 |
311 | $this->sendContent();
312 | }
313 |
314 | protected function sendContent() : void
315 | {
316 | if (is_resource($this->content)) {
317 | rewind($this->content);
318 | fpassthru($this->content);
319 | return;
320 | }
321 |
322 | if ($this->content instanceof SplFileObject) {
323 | $this->content->rewind();
324 | $this->content->fpassthru();
325 | return;
326 | }
327 |
328 | if (is_callable($this->content) && ! is_string($this->content)) {
329 | echo ($this->content)();
330 | return;
331 | }
332 |
333 | if (is_iterable($this->content)) {
334 | foreach ($this->content as $output) {
335 | echo $output;
336 | }
337 |
338 | return;
339 | }
340 |
341 | if (is_string($this->content) || $this->content instanceof Stringable) {
342 | echo $this->content;
343 | }
344 | }
345 | }
346 |
--------------------------------------------------------------------------------
/tests/Request/UploadsTest.php:
--------------------------------------------------------------------------------
1 | assertTrue($request->uploads->isEmpty());
15 | }
16 |
17 | public function testTrivial() : void
18 | {
19 | $request = new Request(globals: ['_FILES' => $this->trivialFiles()]);
20 | $uploads = $request->uploads;
21 |
22 | $expect = [
23 | 'name' => '',
24 | 'fullPath' => '',
25 | 'type' => '',
26 | 'size' => 0,
27 | 'tmpName' => '',
28 | 'error' => 4,
29 | ];
30 |
31 | $this->assertUpload($expect, $uploads, 'foo1');
32 | $this->assertUpload($expect, $uploads, 'foo2');
33 | $this->assertUpload($expect, $uploads, 'foo3');
34 | $this->assertUpload($expect, $uploads, 'bar', 0);
35 | $this->assertUpload($expect, $uploads, 'bar', 1);
36 | $this->assertUpload($expect, $uploads, 'bar', 2);
37 | $this->assertUpload($expect, $uploads, 'baz', 'baz1');
38 | $this->assertUpload($expect, $uploads, 'baz', 'baz2');
39 | $this->assertUpload($expect, $uploads, 'baz', 'baz3');
40 |
41 | /** @var Upload */
42 | $upload = $request->uploads['foo1'];
43 | $actual = $upload->move('/tmp');
44 | $this->assertFalse($actual);
45 | }
46 |
47 | public function testComplex() : void
48 | {
49 | $request = new Request(globals: ['_FILES' => $this->complexFiles()]);
50 | $uploads = $request->uploads;
51 |
52 | $expect = [
53 | 'name' => 'foo_name',
54 | 'fullPath' => 'foo_name',
55 | 'type' => 'foo_type',
56 | 'size' => 0,
57 | 'tmpName' => 'foo_tmp_name',
58 | 'error' => 4,
59 | ];
60 |
61 | /** @var Upload */
62 | $actual = $uploads['foo'];
63 | $this->assertSame($expect, $actual->asArray());
64 |
65 | foreach ([1, 2, 3] as $i) {
66 | foreach (['a', 'b', 'c'] as $j) {
67 | $expect = [
68 | 'name' => "dib{$i}{$j}_name",
69 | 'fullPath' => "dib{$i}{$j}_name",
70 | 'type' => "dib{$i}{$j}_type",
71 | 'size' => 0,
72 | 'tmpName' => "dib{$i}{$j}_tmp_name",
73 | 'error' => 4,
74 | ];
75 |
76 | $k1 = "dib{$i}";
77 | $k2 = "dib{$i}{$j}";
78 |
79 | $this->assertUpload($expect, $uploads, 'dib', $k1, $k2);
80 | }
81 | }
82 | }
83 |
84 | public function testDeep() : void
85 | {
86 | $request = new Request(globals: [
87 | '_FILES' => [
88 | 'profile' => [
89 | 'details' => [
90 | 'photo' => [
91 | 'tmp_name' => '/tmp/upload/r34b5960',
92 | 'error' => 0,
93 | 'name' => 'hobbes.jpg',
94 | 'full_path' => '/Users/watterson/Pictures/hobbes.jpg',
95 | 'size' => 23456,
96 | 'type' => 'image/jpeg',
97 | ],
98 | ],
99 | ],
100 | ]
101 | ]);
102 |
103 | $uploads = $request->uploads;
104 | $this->assertCount(1, $uploads);
105 |
106 | $expect = [
107 | 'name' => 'hobbes.jpg',
108 | 'fullPath' => '/Users/watterson/Pictures/hobbes.jpg',
109 | 'type' => 'image/jpeg',
110 | 'size' => 23456,
111 | 'tmpName' => '/tmp/upload/r34b5960',
112 | 'error' => 0,
113 | ];
114 |
115 | $this->assertUpload($expect, $uploads, 'profile', 'details', 'photo');
116 | }
117 |
118 | /**
119 | * @param mixed[] $expect
120 | */
121 | protected function assertUpload(
122 | array $expect,
123 | UploadCollection $uploads,
124 | int|string $key,
125 | int|string ...$subkeys
126 | ) : void
127 | {
128 | /** @var Upload $actual */
129 | $actual = $uploads[$key];
130 |
131 | while ($subkeys) {
132 | /** @var UploadCollection $actual */
133 | $subkey = array_shift($subkeys);
134 |
135 | if ($subkeys) {
136 | /** @var UploadCollection $sub */
137 | $sub = $actual[$subkey];
138 |
139 | /** @var UploadCollection $actual */
140 | $actual = $sub;
141 | continue;
142 | }
143 |
144 | /** @var UploadCollection $sub */
145 | $sub = $actual[$subkey];
146 |
147 | /** @var Upload $actual */
148 | $actual = $sub;
149 | }
150 |
151 | $this->assertSame($expect, $actual->asArray());
152 | }
153 |
154 | /**
155 | * @return mixed[]
156 | */
157 | public function trivialFiles() : array
158 | {
159 | return [
160 | 'foo1' => [
161 | 'error' => 4,
162 | 'name' => '',
163 | 'full_path' => '',
164 | 'size' => 0,
165 | 'tmp_name' => '',
166 | 'type' => '',
167 | ],
168 | 'foo2' => [
169 | 'error' => 4,
170 | 'name' => '',
171 | 'full_path' => '',
172 | 'size' => 0,
173 | 'tmp_name' => '',
174 | 'type' => '',
175 | ],
176 | 'foo3' => [
177 | 'error' => 4,
178 | 'name' => '',
179 | 'full_path' => '',
180 | 'size' => 0,
181 | 'tmp_name' => '',
182 | 'type' => '',
183 | ],
184 | 'bar' => [
185 | 'name' => [
186 | 0 => '',
187 | 1 => '',
188 | 2 => '',
189 | ],
190 | 'full_path' => [
191 | 0 => '',
192 | 1 => '',
193 | 2 => '',
194 | ],
195 | 'type' => [
196 | 0 => '',
197 | 1 => '',
198 | 2 => '',
199 | ],
200 | 'tmp_name' => [
201 | 0 => '',
202 | 1 => '',
203 | 2 => '',
204 | ],
205 | 'error' => [
206 | 0 => 4,
207 | 1 => 4,
208 | 2 => 4,
209 | ],
210 | 'size' => [
211 | 0 => 0,
212 | 1 => 0,
213 | 2 => 0,
214 | ],
215 | ],
216 | 'baz' => [
217 | 'name' => [
218 | 'baz1' => '',
219 | 'baz2' => '',
220 | 'baz3' => '',
221 | ],
222 | 'full_path' => [
223 | 'baz1' => '',
224 | 'baz2' => '',
225 | 'baz3' => '',
226 | ],
227 | 'type' => [
228 | 'baz1' => '',
229 | 'baz2' => '',
230 | 'baz3' => '',
231 | ],
232 | 'tmp_name' => [
233 | 'baz1' => '',
234 | 'baz2' => '',
235 | 'baz3' => '',
236 | ],
237 | 'error' => [
238 | 'baz1' => 4,
239 | 'baz2' => 4,
240 | 'baz3' => 4,
241 | ],
242 | 'size' => [
243 | 'baz1' => 0,
244 | 'baz2' => 0,
245 | 'baz3' => 0,
246 | ],
247 | ],
248 | ];
249 | }
250 |
251 | /**
252 | * @return mixed[]
253 | */
254 | protected function complexFiles() : array
255 | {
256 | return [
257 | 'foo' => [
258 | 'name' => 'foo_name',
259 | 'full_path' => 'foo_name',
260 | 'type' => 'foo_type',
261 | 'tmp_name' => 'foo_tmp_name',
262 | 'error' => 4,
263 | 'size' => 0,
264 | ],
265 | 'dib' => [
266 | 'name' => [
267 | 'dib1' => [
268 | 'dib1a' => 'dib1a_name',
269 | 'dib1b' => 'dib1b_name',
270 | 'dib1c' => 'dib1c_name',
271 | ],
272 | 'dib2' => [
273 | 'dib2a' => 'dib2a_name',
274 | 'dib2b' => 'dib2b_name',
275 | 'dib2c' => 'dib2c_name',
276 | ],
277 | 'dib3' => [
278 | 'dib3a' => 'dib3a_name',
279 | 'dib3b' => 'dib3b_name',
280 | 'dib3c' => 'dib3c_name',
281 | ],
282 | ],
283 | 'full_path' => [
284 | 'dib1' => [
285 | 'dib1a' => 'dib1a_name',
286 | 'dib1b' => 'dib1b_name',
287 | 'dib1c' => 'dib1c_name',
288 | ],
289 | 'dib2' => [
290 | 'dib2a' => 'dib2a_name',
291 | 'dib2b' => 'dib2b_name',
292 | 'dib2c' => 'dib2c_name',
293 | ],
294 | 'dib3' => [
295 | 'dib3a' => 'dib3a_name',
296 | 'dib3b' => 'dib3b_name',
297 | 'dib3c' => 'dib3c_name',
298 | ],
299 | ],
300 | 'type' => [
301 | 'dib1' => [
302 | 'dib1a' => 'dib1a_type',
303 | 'dib1b' => 'dib1b_type',
304 | 'dib1c' => 'dib1c_type',
305 | ],
306 | 'dib2' => [
307 | 'dib2a' => 'dib2a_type',
308 | 'dib2b' => 'dib2b_type',
309 | 'dib2c' => 'dib2c_type',
310 | ],
311 | 'dib3' => [
312 | 'dib3a' => 'dib3a_type',
313 | 'dib3b' => 'dib3b_type',
314 | 'dib3c' => 'dib3c_type',
315 | ],
316 | ],
317 | 'tmp_name' => [
318 | 'dib1' => [
319 | 'dib1a' => 'dib1a_tmp_name',
320 | 'dib1b' => 'dib1b_tmp_name',
321 | 'dib1c' => 'dib1c_tmp_name',
322 | ],
323 | 'dib2' => [
324 | 'dib2a' => 'dib2a_tmp_name',
325 | 'dib2b' => 'dib2b_tmp_name',
326 | 'dib2c' => 'dib2c_tmp_name',
327 | ],
328 | 'dib3' => [
329 | 'dib3a' => 'dib3a_tmp_name',
330 | 'dib3b' => 'dib3b_tmp_name',
331 | 'dib3c' => 'dib3c_tmp_name',
332 | ],
333 | ],
334 | 'error' => [
335 | 'dib1' => [
336 | 'dib1a' => 4,
337 | 'dib1b' => 4,
338 | 'dib1c' => 4,
339 | ],
340 | 'dib2' => [
341 | 'dib2a' => 4,
342 | 'dib2b' => 4,
343 | 'dib2c' => 4,
344 | ],
345 | 'dib3' => [
346 | 'dib3a' => 4,
347 | 'dib3b' => 4,
348 | 'dib3c' => 4,
349 | ],
350 | ],
351 | 'size' => [
352 | 'dib1' => [
353 | 'dib1a' => 0,
354 | 'dib1b' => 0,
355 | 'dib1c' => 0,
356 | ],
357 | 'dib2' => [
358 | 'dib2a' => 0,
359 | 'dib2b' => 0,
360 | 'dib2c' => 0,
361 | ],
362 | 'dib3' => [
363 | 'dib3a' => 0,
364 | 'dib3b' => 0,
365 | 'dib3c' => 0,
366 | ],
367 | ],
368 | ],
369 | ];
370 | }
371 | }
372 |
--------------------------------------------------------------------------------