From 89325620a5cbe837d8866b37c9cdfab9fc27e36f Mon Sep 17 00:00:00 2001 From: Matias Navarro Carter Date: Wed, 11 Nov 2020 00:17:02 +0000 Subject: [PATCH] Initial commit --- .gitignore | 3 + README.md | 437 +++++++++++++++++++++++++++++++++++ composer.json | 32 +++ examples/arguments.php | 17 ++ examples/buffered.php | 10 + examples/class.php | 78 +++++++ examples/composition.php | 30 +++ examples/headers.php | 12 + examples/json.php | 18 ++ examples/simple.php | 11 + functions.php | 63 +++++ src/Encoding/Json.php | 53 +++++ src/Encoding/JsonDecoder.php | 22 ++ src/Encoding/JsonReader.php | 19 ++ src/Headers.php | 128 ++++++++++ src/Io/Reader.php | 25 ++ src/Io/ReaderError.php | 15 ++ src/Io/ResourceReader.php | 49 ++++ src/ProtocolError.php | 51 ++++ src/Response.php | 95 ++++++++ src/SocketError.php | 22 ++ src/StandardHeaders.php | 89 +++++++ src/Status.php | 59 +++++ tests/ResponseTest.php | 18 ++ 24 files changed, 1356 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 composer.json create mode 100644 examples/arguments.php create mode 100644 examples/buffered.php create mode 100644 examples/class.php create mode 100644 examples/composition.php create mode 100644 examples/headers.php create mode 100644 examples/json.php create mode 100644 examples/simple.php create mode 100644 functions.php create mode 100644 src/Encoding/Json.php create mode 100644 src/Encoding/JsonDecoder.php create mode 100644 src/Encoding/JsonReader.php create mode 100644 src/Headers.php create mode 100644 src/Io/Reader.php create mode 100644 src/Io/ReaderError.php create mode 100644 src/Io/ResourceReader.php create mode 100644 src/ProtocolError.php create mode 100644 src/Response.php create mode 100644 src/SocketError.php create mode 100644 src/StandardHeaders.php create mode 100644 src/Status.php create mode 100644 tests/ResponseTest.php diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..75c6d71 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.phpunit.result.cache +composer.lock +vendor \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..485b459 --- /dev/null +++ b/README.md @@ -0,0 +1,437 @@ +PHP Fetch +========= + +A simple, type-safe, zero dependency port of the javascript `fetch` WebApi for PHP. + +> NOTE: This library is in `< 1.0.0` version and as per the Semantic Versioning Spec, breaking +> changes might occur in minor releases before reaching `1.0.0`. Specify your constraints +> carefully. + +## Installation + +```bash +composer require mnavarrocarter/php-fetch +``` + +## Basic Usage + +A simple `GET` request can be done just calling fetch passing the url: + +```php +body()->read()) !== null) { + echo $chunk; +} +``` + +## Advanced Usage + +Like in the browser's `fetch` implementation, you can pass a map of options +as a second argument: + +```php + 'POST', + 'headers' => [ + 'Content-Type' => 'application/json', + 'User-Agent' => 'PHP Fetch' + ], + 'body' => json_encode(['data' => 'value']) +]); + +// Emit the response to stdout +while (($chunk = $response->body()->read()) !== null) { + echo $chunk; +} +``` + +At the moment, the only options supported are: + +- `method`: Sets the request method +- `body`: The request body. It can be a `resource`, a `string` or `null`. +- `headers`: An associative array of header names and values. + +### Exception Handling + +A call to `fetch` can throw two exceptions, which are property documented. + +A `MNC\Http\SocketError` is thrown when a TCP connection cannot be established +with the server. Common scenarios where this may happen include: + +- The server is down +- The domain name could not be resolved to an ip address (dns) +- The server took too long to produce a response (timeout) +- The SSL handshake failed (non trusted certificate) + +A `MNC\Http\ProtocolError` occurs when a connection could be established, and the +a response was produced by the server, but this response was an error according +to the HTTP protocol specification (a status code in the 400 or 500 range). This exception +contains the `MNC\Http\Response` object that the server produced. + +The distinction between these two kind of errors is really important since +you most likely will be reacting in different ways to each one of them. + +### Body Buffering + +When you call the `MNC\Http\Response::body()` method you get an instance of +`MNC\Http\Io\Reader`, which is a very simple interface inspired in golang's +`io.Reader`. This interface allows you to read a chunk of bytes until you reach +`EOF` in the data source. + +Often times, you don't want to read byte per byte, but get the whole contents +of the body as a string at once. This library provides the `buffer` function +as a convenience for that: + +```php +body()); // Buffers all the contents in memory and emits them. +```` + +Buffering is a very good convenience, but it needs to be used with care, since it could +increase your memory usage up to the size of the file your are fetching. Keep in mind that +and use the reader when you are fetching big files. + +### Handling Common Encodings + +Some libraries make their response implementations aware of the content type of a body in a +very unreliable way. + +For example, Symfony's HTTP client response object contains a `toArray()` method that +returns an array if the body of the response is a json. + +Apart from being a leaky abstraction, it is not a good one, since it can fail miserably +in content types like `text/plain`. However, there is big gain in user experience +when we provide helpers like these in our apis. + +This library provides an approach a bit more safe. If the response headers contain the +`application/json` content type, the `MNC\Http\Io\Reader` object of the body is internally +decorated with a `MNC\Http\Encoding\Json` object. This object implements both the +`JsonReader` and `JsonDecoder` interfaces, plus the normal `Reader` interface, +and you can check for those interfaces to conveniently handle json payloads safely. + +```php + [ + 'User-Agent' => 'PHP Fetch 1.0' // Github api requires user agent + ] +]); + +$body = $response->body(); + +if ($body instanceof JsonDecoder) { + var_dump($body->decode()); // Dumps the json as an array +} else { + // The response body is not json encoded +} +``` + +This makes the code more maintainable and evolvable, as we can support more encodings +in the future, like `csv` or `xml` without harming the base api and making more assumptions +about our content types than we should. + +This way of doing things (small interfaces that encourage composability) is another principle +that we have taken from golang's idiosyncrasies. + +### Working with Standard Headers + +HTTP is a very generic protocol in terms of structure. An HTTP response really is just +metadata in the form of key value pairs (headers) and the contents of that response itself. + +However, there is a set of standardized headers across multiple RFC's that is not good to +ignore. They are not part of the HTTP protocol specification, but they are so widespread +and commonly used that a good implementation of the protocol should acknowledge them. + +This library keeps the protocol pure but provides better apis over standard headers by +using the `MNC\Http\StandardHeaders` class. + +The `MNC\Http\Response::headers()` method returns an instance of `MNC\Http\Headers`. +This object is just a bag of string keys and string values. Names when fetching headers +should be provided by you, and as per protocol spec they are case-insensitive. + +By using the `MNC\Http\StandardHeaders` class, you can decorate a `MNC\Http\Headers` +object to provide an api over some standardized and useful headers. + +```php +getLastModified()->diff(new DateTimeImmutable(), true)->h; +echo sprintf('This html content was last modified %s hours ago...', $lastModified) . PHP_EOL; +``` + +You can use these headers information to handle chaching or avoiding reading the whole +stream body if is not necessary. + +Since these standards headers may not be present in a certain responses, they +all can return `null`. + +### Function Composition + +As a function, `fetch` can be really verbose if you do not use it with the +appropriate patterns. One of these appropriate patterns is composition. + +For example, you can compose functions the same way you can +compose objects. Wrapping `fetch` in anonymous functions that define +some common default options is, in fact, the recommended way of using `fetch`, not +only in this library but also in the browser one. + +For example, the following code defines a function that takes a token as an +argument and then returns another function that calls fetch with a simplified +api, using the token internally. + +```php + $method, + 'headers' => [ + 'Accept' => 'application/json', + 'Content-Type' => 'application/json', + 'Authorization' => 'Bearer ' . $token + ], + 'body' => is_array($contents) ? json_encode($contents) : '' + ]); + + $body = $response->body(); + if ($body instanceof JsonDecoder) { + return $body->decode(); + } + return null; + }; +}; + +$client = $authenticate('your-api-token'); + +$ordersArray = $client('GET', '/orders'); +$createdOrderArray = $client('POST', '/orders', ['id' => '1234556']); +``` + +Note how the `$client` function does not expose any details of how fetch works +and reduces the interaction with the client classes to PHP primitive types +only. Of course, this example lacks exception handling, but the idea is the same. + +You can pass that `$client` variable anywhere in your application and you won't +be tying your code to this library, but to a callable with the same signature. + +### Dependency Injection + +Following the previous example, we do not recommend you call fetch directly in +your code. At least, not if you are too worried about coupling with a specific +HTTP client library that you might replace in the future. + +A common pattern I personally use, is that I create an interface for the +api client that I need to use. + +```php + $method, + 'headers' => [ + 'Accept' => 'application/json', + 'Content-Type' => 'application/json', + 'Authorization' => 'Bearer ' . $token + ], + 'body' => is_array($contents) ? json_encode($contents) : '' + ]); + + $body = $response->body(); + if ($body instanceof JsonDecoder) { + return $body->decode(); + } + return null; + }; + return new self($client); + } + + /** + * FetchApiClient constructor. + * @param callable $client + */ + public function __construct(callable $client) + { + $this->client = $client; + } + + public function getOrder(string $id): array + { + return ($this->client)('GET', '/orders/'.$id); + } + + public function createOrder(string $id): array + { + return ($this->client)('POST', '/orders', [ + 'id' => $id + ]); + } + + public function deleteOrder(string $id): void + { + ($this->client)('DELETE', '/orders/'.$id); + } +} +``` + +You can use the interface in all the services that depend on this connecting +to the api service it implements. + +## Why another HTTP Client? + +Maybe you are wondering "Is another HTTP client for PHP necessary"? I think this one is. + +Before building it, I did an honest review of the currently available options and enumerated +the things that, for me, were lacking in them. I also listed the desired features I would have +loved to have by looking at other languages and implementations. + +At the end, I came up with a list of 4 principles/reasons for building this client that, +**considered in combination**, are only met in this client. + +### You don't need PSR-18 HTTP client in your apps + +I can only have words of praise for the PHP-FIG and all the standards they have produced +for PHP. I'm personally a big fan of all things PSR-7 and I'm always hoping the community +sees it's benefits and starts moving to them. + +But, when I'm developing my own application and I just need to do a simple HTTP request, +I would try to avoid at all costs the verbosity and the bloatness of PSR-18 and their +implementations. This is why I made php fetch: for the 90% of simple use cases. If you +need an HTTP client to do web scraping, don't use this (you need redirect following, +multiplexing, cookie support, plugins for bypassing csrf, javascript engine embedded, etc). +But if you need a simple http client to make some requests to an api, you'll find +using this library more than enough. + +"But what about interoperability and vendor lock-in?" Well, truth is that if you are a +responsible programmer, you should be building the code that makes requests to a http +endpoint behind a proper abstraction, like an interface. Think something like: `ApiService` +with these possible implementations: `GuzzleApiService`, `CurlApiService` or `FetchApiService`. +If you do this, there is no vendor lock-in to be afraid of. On the contrary, if you don't keep +your dependencies hidden behind interfaces that serve your own contract and requirements, +you will suffer not only when doing HTTP, but with pretty much anything else. + +PSR-18 was made for libraries mainly, to avoid dependency conflicts. This does not mean that +it cannot be used in applications; many people do, and it works! +What it does mean is that its reason to be is to serve libraries, like HTTP SDKS or others. +If you are familiar with the whole Guzzle fiasco from a few years ago, you'll +know that HTTPPlug (the inspiration for PSR-18) was made with the sole purpose of +becoming protection from dependency conflicts in some libraries, mainly caused by a +very aggressive release policy from Guzzle, and a very relaxed release policy from Amazon. + +So, the simplicity of this library is more than enough for most of my applications. + +### Most HTTP clients are too bloated + +Again, this is not a defect of HTTP clients per se. A client that has many features will +have a lot of code and dependencies. The question is whether you need those features for +your use case or not. In my experience, most of the time I don't need them, and I +always ended up doing simple HTTP requests with PHP streams. I built this library so +I don't have to do that anymore. + +### No HTTP client is just a function + +One thing I love about working with javascript is its more functional friendly approach. Even +though is light-years away of being a pure functional language, the declarative nature +of most of it's apis make it really nice to work with (oh if only had proper typing and +encapsulation!). + +The `fetch` api is one of my favourites, and I always wanted to have something like that in +PHP. I searched for it, but to no avail, hence this library. Sometimes, most of our single +method classes could perfectly be functions. + +Some people in PHP are starting to grasp this and using more functions, especially since +functions can be namespaced now (please never add functions in the global namespace!). + +### Immutability + +Well, PSR-18 favours immutability in the form of reference clonation. This library does it in +the form of read-only state. There is nothing you can change in an already constructed +response. Everything is read only. + +> Well, you could change it using nasty php tricks like closure scope binding; but don't +> do that, okay? + +There is no reason why you should need to change a response from a server. The only thing +you can do with the response is compose it into other types: nothing more. + +### Other Nit-Pickyness + +It really annoys the freak out of me when a library that implements a protocol does not +throw exceptions when a protocol error happens. Like, which SMTP client library gives you +a `Response` containing an error when no target address has been specified instead of throwing +an exception? How are you supposed to know that an error happened? + +The purpose of implementing a protocol in a language is to mimic the idiosyncrasies of the +protocol in the available language constructs. If there is status codes defined for errors, the +language construct for errors should be used: in this case, an exception should be raised. + +[This is one of the biggest problems for me with PSR-18](https://www.php-fig.org/psr/psr-18/#error-handling). + I think it was a terrible design decision that harms user experience. + +### In Closing + +This client is simple, small, functional, immutable, type-safe, well designed and achieves a +good balance between protocol strictness and convenience. I think there is nothing like that +in the PHP ecosystem right now, so there might be a user base for this. + +Hope you enjoy using it as much as I enjoyed building it. \ No newline at end of file diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..9e8fcc9 --- /dev/null +++ b/composer.json @@ -0,0 +1,32 @@ +{ + "name": "mnavarrocarter/php-fetch", + "description": "A simple, type-safe, zero dependency port of the javascript fetch WebApi for PHP", + "minimum-stability": "stable", + "license": "MIT", + "keywords": ["php", "php7", "http", "http-client", "streams", "fetch", "web-api", "request", "response"], + "authors": [ + { + "name": "Matias Navarro Carter", + "email": "mnavarrocarter@gmail.com" + } + ], + "autoload": { + "psr-4": { + "MNC\\Http\\": "src" + }, + "files": ["functions.php"] + }, + "autoload-dev": { + "psr-4": { + "MNC\\Http\\": "tests" + } + }, + "require": { + "php": ">=7.4", + "ext-json": "*" + }, + "require-dev": { + "phpunit/phpunit": "^9.4", + "symfony/var-dumper": "^5.1" + } +} diff --git a/examples/arguments.php b/examples/arguments.php new file mode 100644 index 0000000..a13ae4b --- /dev/null +++ b/examples/arguments.php @@ -0,0 +1,17 @@ + 'POST', + 'headers' => [ + 'Content-Type' => 'application/json', + 'User-Agent' => 'PHP Fetch' + ], + 'body' => json_encode(['data' => 'value']) +]); + +echo buffer($response->body()); // Emits the response \ No newline at end of file diff --git a/examples/buffered.php b/examples/buffered.php new file mode 100644 index 0000000..2951674 --- /dev/null +++ b/examples/buffered.php @@ -0,0 +1,10 @@ +body()); \ No newline at end of file diff --git a/examples/class.php b/examples/class.php new file mode 100644 index 0000000..3f3aa20 --- /dev/null +++ b/examples/class.php @@ -0,0 +1,78 @@ + $method, + 'headers' => [ + 'Accept' => 'application/json', + 'Content-Type' => 'application/json', + 'Authorization' => 'Bearer ' . $token + ], + 'body' => is_array($contents) ? json_encode($contents) : '' + ]); + + $body = $response->body(); + if ($body instanceof JsonDecoder) { + return $body->parseJson(); + } + return null; + }; + return new self($client); + } + + /** + * FetchApiClient constructor. + * @param callable $client + */ + public function __construct(callable $client) + { + $this->client = $client; + } + + public function getOrder(string $id): array + { + return ($this->client)('GET', '/orders/'.$id); + } + + public function createOrder(string $id): array + { + return ($this->client)('POST', '/orders', [ + 'id' => $id + ]); + } + + public function deleteOrder(string $id): void + { + ($this->client)('DELETE', '/orders/'.$id); + } +} \ No newline at end of file diff --git a/examples/composition.php b/examples/composition.php new file mode 100644 index 0000000..1f2bf2c --- /dev/null +++ b/examples/composition.php @@ -0,0 +1,30 @@ + $method, + 'headers' => [ + 'Accept' => 'application/json', + 'Content-Type' => 'application/json', + 'Authorization' => 'Bearer ' . $token + ], + 'body' => is_array($contents) ? json_encode($contents) : '' + ]); + + $body = $response->body(); + if ($body instanceof JsonDecoder) { + return $body->parseJson(); + } + return null; + }; +}; + +$client = $authenticated('your-api-token'); + +$ordersArray = $client('GET', '/orders'); +$createdOrderArray = $client('POST', '/orders', ['id' => '1234556']); \ No newline at end of file diff --git a/examples/headers.php b/examples/headers.php new file mode 100644 index 0000000..cecbee5 --- /dev/null +++ b/examples/headers.php @@ -0,0 +1,12 @@ +getLastModified()->diff(new DateTimeImmutable(), true)->h; +echo sprintf('This html content was last modified %s hours ago...', $lastModified) . PHP_EOL; \ No newline at end of file diff --git a/examples/json.php b/examples/json.php new file mode 100644 index 0000000..8547416 --- /dev/null +++ b/examples/json.php @@ -0,0 +1,18 @@ + [ + 'User-Agent' => 'PHP Fetch 1.0' // Github api requires user agent + ] +]); + +$body = $response->body(); + +if ($body instanceof JsonDecoder) { + var_dump($body->parseJson()); // Dumps the json as an array +} \ No newline at end of file diff --git a/examples/simple.php b/examples/simple.php new file mode 100644 index 0000000..85fa9c9 --- /dev/null +++ b/examples/simple.php @@ -0,0 +1,11 @@ +body()->read()) !== null) { + echo $chunk; +} \ No newline at end of file diff --git a/functions.php b/functions.php new file mode 100644 index 0000000..440ccbc --- /dev/null +++ b/functions.php @@ -0,0 +1,63 @@ + [ + 'method' => $method, + 'header' => Headers::fromMap($headers)->toArray(), + 'contents' => $body, + 'ignore_errors' => true, + ] + ]; + + $resource = @fopen($url, 'rb', false, stream_context_create($context)); + if (!is_resource($resource)) { + throw new SocketError(error_get_last()['message']); + } + stream_set_blocking($resource, false); + + $body = new ResourceReader($resource); + $headers = stream_get_meta_data($resource)['wrapper_data']; + $firstLine = array_shift($headers); + $response = Response::fromFirstLine($firstLine, Headers::fromArray($headers), $body); + if ($response->status()->isError()) { + throw new ProtocolError($response); + } + return $response; +} + +/** + * @param Reader $reader + * @return string The buffered string + * @throws Io\ReaderError + */ +function buffer(Reader $reader) { + $buffer = ''; + while (($chunk = $reader->read()) !== null) { + $buffer .= $chunk; + } + return $buffer; +} \ No newline at end of file diff --git a/src/Encoding/Json.php b/src/Encoding/Json.php new file mode 100644 index 0000000..545c4e8 --- /dev/null +++ b/src/Encoding/Json.php @@ -0,0 +1,53 @@ +reader = $reader; + } + + /** + * @return string + * @throws ReaderError + */ + public function readAll(): string + { + return buffer($this->reader); + } + + /** + * @return array + * @throws JsonException + * @throws ReaderError + */ + public function decode(): array + { + return json_decode($this->readAll(), true, 512, JSON_THROW_ON_ERROR); + } + + public function read(int $bytes = self::DEFAULT_CHUNK_SIZE): ?string + { + return $this->reader->read($bytes); + } +} \ No newline at end of file diff --git a/src/Encoding/JsonDecoder.php b/src/Encoding/JsonDecoder.php new file mode 100644 index 0000000..d7d9645 --- /dev/null +++ b/src/Encoding/JsonDecoder.php @@ -0,0 +1,22 @@ +put($name, trim($value)); + } + return $self; + } + + /** + * @param array $headers + * @return Headers + */ + public static function fromMap(array $headers): Headers + { + $self = new self(); + foreach ($headers as $name => $value) { + $self->put($name, $value); + } + return $self; + } + + /** + * Headers constructor. + */ + public function __construct() + { + $this->headers = []; + } + + /** + * @param string $name + * @param string $value + */ + protected function put(string $name, string $value): void + { + $name = strtolower($name); + $this->headers[$name] = $value; + } + + /** + * Returns a header + * @param string $name + * @return string + */ + public function get(string $name): string + { + $name = strtolower($name); + return $this->headers[$name] ?? ''; + } + + /** + * @param string $name + * @param string $substring + * @return bool + */ + public function contains(string $name, string $substring): bool + { + $name = strtolower($name); + return strpos($this->get($name), $substring) !== false; + } + + /** + * @param string $name + * @return bool + */ + public function has(string $name): bool + { + $name = strtolower($name); + return array_key_exists($name, $this->headers); + } + + /** + * @param callable $callable + * @return array + */ + public function map(callable $callable): array + { + $arr = []; + foreach ($this->headers as $name => $value) { + $arr[] = $callable($value, $name); + } + return $arr; + } + + /** + * @param callable $callable + * @return array + */ + public function filter(callable $callable): array + { + $arr = []; + foreach ($this->headers as $name => $value) { + if ($callable($value, $name) === true) { + $arr[$name] = $value; + } + } + return $arr; + } + + public function toArray(): array + { + return $this->map(fn(string $value, string $name) => $name .': '.$value); + } + + public function toMap(): array + { + return $this->headers; + } +} \ No newline at end of file diff --git a/src/Io/Reader.php b/src/Io/Reader.php new file mode 100644 index 0000000..cd1596c --- /dev/null +++ b/src/Io/Reader.php @@ -0,0 +1,25 @@ +resource = $resource; + } + + /** + * @param int $bytes + * @return string|null + * @throws ReaderError + */ + public function read(int $bytes = self::DEFAULT_CHUNK_SIZE): ?string + { + if (feof($this->resource)) { + return null; + } + $result = fread($this->resource, $bytes); + if ($result === false) { + throw new ReaderError(error_get_last()); + } + return $result; + } +} \ No newline at end of file diff --git a/src/ProtocolError.php b/src/ProtocolError.php new file mode 100644 index 0000000..ba53544 --- /dev/null +++ b/src/ProtocolError.php @@ -0,0 +1,51 @@ +status()->code()); + $this->response = $response; + } + + /** + * @return Response + */ + public function getResponse(): Response + { + return $this->response; + } +} \ No newline at end of file diff --git a/src/Response.php b/src/Response.php new file mode 100644 index 0000000..713dedb --- /dev/null +++ b/src/Response.php @@ -0,0 +1,95 @@ +protocolVersion = $protocolVersion; + $this->status = $status; + $this->headers = $headers; + $this->body = $body; + $this->processJson(); + } + + /** + * @return string + */ + public function protocolVersion(): string + { + return $this->protocolVersion; + } + + /** + * @return Status + */ + public function status(): Status + { + return $this->status; + } + + /** + * @return Headers + */ + public function headers(): Headers + { + return $this->headers; + } + + /** + * @return Reader + */ + public function body(): Reader + { + return $this->body; + } + + private function processJson(): void + { + if ($this->headers->contains('Content-Type', 'json')) { + $this->body = new Json($this->body); + } + } +} \ No newline at end of file diff --git a/src/SocketError.php b/src/SocketError.php new file mode 100644 index 0000000..a35d579 --- /dev/null +++ b/src/SocketError.php @@ -0,0 +1,22 @@ +headers()); + } + + /** + * StandardHeaders constructor. + * @param Headers $headers + */ + public function __construct(Headers $headers) + { + $this->headers = $headers; + } + + /** + * @return DateTimeImmutable|null + */ + public function getLastModified(): ?DateTimeImmutable + { + if (!$this->headers->has('Last-Modified')) { + return null; + } + return DateTimeImmutable::createFromFormat( + self::HTTP_TIME_FORMAT, + $this->headers->get('Last-Modified') + ); + } + + /** + * @return string|null + */ + public function getContentType(): ?string + { + if (!$this->headers->has('Content-Type')) { + return null; + } + return explode(';', $this->headers->get('Content-Type'))[0]; + } + + /** + * @return DateTimeImmutable|null + */ + public function getExpires(): ?DateTimeImmutable + { + if (!$this->headers->has('Expires')) { + return null; + } + return DateTimeImmutable::createFromFormat( + self::HTTP_TIME_FORMAT, + $this->headers->get('Expires') + ); + } + + /** + * @return int|null + */ + public function getContentLength(): ?int + { + if (!$this->headers->has('Content-Length')) { + return null; + } + return (int) $this->headers->get('Content-Length'); + } +} \ No newline at end of file diff --git a/src/Status.php b/src/Status.php new file mode 100644 index 0000000..27f589f --- /dev/null +++ b/src/Status.php @@ -0,0 +1,59 @@ +code = $code; + $this->reasonPhrase = $reasonPhrase; + } + + /** + * @return bool + */ + public function isError(): bool + { + return $this->code >= 400 && $this->code < 600; + } + + public function isSuccess(): bool + { + return $this->code >= 200 && $this->code < 300; + } + + public function isRedirect(): bool + { + return $this->code >= 300 && $this->code < 400; + } + + /** + * @return int + */ + public function code(): int + { + return $this->code; + } + + /** + * @return string + */ + public function reasonPhrase(): string + { + return $this->reasonPhrase; + } +} \ No newline at end of file diff --git a/tests/ResponseTest.php b/tests/ResponseTest.php new file mode 100644 index 0000000..91544cd --- /dev/null +++ b/tests/ResponseTest.php @@ -0,0 +1,18 @@ +createStub(Headers::class); + $response = Response::fromFirstLine('HTTP/1.1 404 NOT FOUND', $headers); + } +}