├── .editorconfig ├── .gitattributes ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE.md ├── README.md ├── composer.json ├── docs ├── _bookdown.json ├── request │ ├── _bookdown.json │ ├── accept.md │ ├── authorization.md │ ├── content.md │ ├── extending.md │ ├── forward.md │ ├── globals.md │ ├── headers.md │ ├── method.md │ ├── overview.md │ ├── uploads.md │ └── url.md └── response │ ├── _bookdown.json │ ├── callbacks.md │ ├── code.md │ ├── content.md │ ├── cookies.md │ ├── extending.md │ ├── headers.md │ ├── overview.md │ ├── sending.md │ ├── special.md │ └── version.md ├── php-styler.php ├── phpstan.neon ├── phpunit.php ├── phpunit.xml.dist ├── src ├── Exception.php ├── Request.php ├── Request │ ├── Content.php │ ├── Header │ │ ├── Accept.php │ │ ├── Accept │ │ │ ├── AcceptCollection.php │ │ │ ├── Charset.php │ │ │ ├── CharsetCollection.php │ │ │ ├── Encoding.php │ │ │ ├── EncodingCollection.php │ │ │ ├── Language.php │ │ │ ├── LanguageCollection.php │ │ │ ├── Type.php │ │ │ └── TypeCollection.php │ │ ├── Authorization │ │ │ ├── Factory.php │ │ │ ├── Generic.php │ │ │ ├── None.php │ │ │ ├── Scheme.php │ │ │ └── Scheme │ │ │ │ ├── Basic.php │ │ │ │ ├── Bearer.php │ │ │ │ └── Digest.php │ │ ├── Forwarded.php │ │ ├── ForwardedCollection.php │ │ └── XForwarded.php │ ├── Method.php │ ├── Upload.php │ ├── UploadCollection.php │ └── Url.php ├── Response.php ├── Response │ ├── Cookie.php │ ├── FileResponse.php │ ├── Header.php │ └── JsonResponse.php ├── ValueCollection.php └── ValueObject.php └── tests ├── FakeValueCollection.php ├── FakeValueObject.php ├── Request ├── ContentTest.php ├── Header │ ├── AcceptTest.php │ ├── AuthorizationTest.php │ ├── ForwardedTest.php │ └── XForwardedTest.php ├── MethodTest.php ├── UploadsTest.php └── UrlTest.php ├── RequestTest.php ├── Response ├── Assertions.php ├── CookieTest.php ├── FileResponseTest.php ├── HeaderTest.php ├── JsonResponseTest.php └── fake-content.txt ├── ResponseTest.php ├── ValueCollectionTest.php └── ValueObjectTest.php /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | insert_final_newline = true 7 | trim_trailing_whitespace = true 8 | indent_style = space 9 | indent_size = 4 10 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text eol=lf 2 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.php-styler.cache 2 | /.phpunit.cache 3 | /composer.lock 4 | /tmp 5 | /vendor 6 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | We are happy to review any contributions you want to make. 4 | 5 | The time between submitting a contribution and its review may be extensive; do 6 | not be discouraged if there is not immediate feedback. 7 | 8 | Thanks! 9 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /docs/_bookdown.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "1.x Docs", 3 | "content": [ 4 | "request/_bookdown.json", 5 | "response/_bookdown.json" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /docs/request/_bookdown.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Request", 3 | "content": [ 4 | "overview.md", 5 | "globals.md", 6 | "uploads.md", 7 | "method.md", 8 | "url.md", 9 | "headers.md", 10 | "accept.md", 11 | "authorization.md", 12 | "forward.md", 13 | "content.md", 14 | "extending.md" 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /docs/request/headers.md: -------------------------------------------------------------------------------- 1 | # Headers 2 | 3 | The _Request_ `$headers` property is a readonly array derived from various 4 | _Request_ `$server` property values. 5 | 6 | Each `$server['HTTP_*']` element will be represented in `$headers` using a 7 | lower-kebab-cased key, along with the `$server['CONTENT_LENGTH']` and 8 | `$server['CONTENT_TYPE']` values. 9 | 10 | ```php 11 | $_SERVER = [ 12 | 'HTTP_HOST' => '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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /docs/response/_bookdown.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Response", 3 | "content": [ 4 | "overview.md", 5 | "version.md", 6 | "code.md", 7 | "headers.md", 8 | "cookies.md", 9 | "callbacks.md", 10 | "content.md", 11 | "sending.md", 12 | "special.md", 13 | "extending.md" 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /docs/response/code.md: -------------------------------------------------------------------------------- 1 | # Status Code 2 | 3 | ## Setting the Code 4 | 5 | `final public setCode(?int $code) : static` 6 | 7 | Sets the status code for the _Response_; a buffered equivalent of 8 | `http_response_code($code)`. 9 | 10 | The method is fluent, allowing you to chain a call to another _Response_ method. 11 | 12 | 13 | ## Getting the Code 14 | 15 | `final public getCode() : ?int` 16 | 17 | Returns the the status code for the response. 18 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /docs/response/version.md: -------------------------------------------------------------------------------- 1 | # Protocol Version 2 | 3 | ## Setting the Version 4 | 5 | `final public setVersion(?string $version) : static` 6 | 7 | This sets the protocol version for the _Response_ (typically '1.0', '1.1', or '2'). 8 | 9 | The method is fluent, allowing you to chain a call to another _Response_ method. 10 | 11 | ## Getting the Version 12 | 13 | `final public getVersion() : ?string` 14 | 15 | Returns the protocol version for the response. 16 | -------------------------------------------------------------------------------- /php-styler.php: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 11 | 12 | ./tests 13 | 14 | 15 | 16 | 17 | ./src 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /src/Exception.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/Accept/Charset.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 | -------------------------------------------------------------------------------- /src/Request/Header/Authorization/Generic.php: -------------------------------------------------------------------------------- 1 | scheme = null; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/Request/Header/Authorization/Scheme.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 | -------------------------------------------------------------------------------- /src/Request/Header/Authorization/Scheme/Basic.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/Authorization/Scheme/Bearer.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 | -------------------------------------------------------------------------------- /src/Request/Header/Forwarded.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/ValueObject.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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /tests/Response/fake-content.txt: -------------------------------------------------------------------------------- 1 | Hello World! -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | --------------------------------------------------------------------------------