diff --git a/README.md b/README.md index 8568bc9f..dfca8ee4 100644 --- a/README.md +++ b/README.md @@ -21,9 +21,9 @@ Init your first application: ```php require_once __DIR__ . '/../../vendor/autoload.php'; -use Utopia\Http; -use Utopia\Request; -use Utopia\Response; +use Utopia\Http\Http; +use Utopia\Http\Adapter\FPM\Request; +use Utopia\Http\Adapter\FPM\Response; Http::get('/hello-world') // Define Route ->inject('request') @@ -54,9 +54,9 @@ There are three types of hooks, init hooks, shutdown hooks and error hooks. Init ```php require_once __DIR__ . '/../../vendor/autoload.php'; -use Utopia\Http; -use Utopia\Request; -use Utopia\Response; +use Utopia\Http\Http; +use Utopia\Http\Adapter\FPM\Request; +use Utopia\Http\Adapter\FPM\Response; Http::init() ->inject('response') diff --git a/composer.json b/composer.json index 9795aa60..355a326e 100644 --- a/composer.json +++ b/composer.json @@ -28,10 +28,11 @@ "utopia-php/validators": "0.2.*" }, "require-dev": { - "phpunit/phpunit": "9.*", "laravel/pint": "1.*", + "phpbench/phpbench": "1.*", "phpstan/phpstan": "1.*", - "phpbench/phpbench": "1.*" + "phpunit/phpunit": "9.*", + "swoole/ide-helper": "^6.0" }, "config": { "allow-plugins": { diff --git a/composer.lock b/composer.lock index b6a45b83..b9987e23 100644 --- a/composer.lock +++ b/composer.lock @@ -4,20 +4,20 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "77291398fe50e9a4e2db59f9729c0673", + "content-hash": "bdf751c50aa401f0a220fb353295974b", "packages": [ { "name": "brick/math", - "version": "0.14.5", + "version": "0.14.7", "source": { "type": "git", "url": "https://github.com/brick/math.git", - "reference": "618a8077b3c326045e10d5788ed713b341fcfe40" + "reference": "07ff363b16ef8aca9692bba3be9e73fe63f34e50" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/brick/math/zipball/618a8077b3c326045e10d5788ed713b341fcfe40", - "reference": "618a8077b3c326045e10d5788ed713b341fcfe40", + "url": "https://api.github.com/repos/brick/math/zipball/07ff363b16ef8aca9692bba3be9e73fe63f34e50", + "reference": "07ff363b16ef8aca9692bba3be9e73fe63f34e50", "shasum": "" }, "require": { @@ -56,7 +56,7 @@ ], "support": { "issues": "https://github.com/brick/math/issues", - "source": "https://github.com/brick/math/tree/0.14.5" + "source": "https://github.com/brick/math/tree/0.14.7" }, "funding": [ { @@ -64,7 +64,7 @@ "type": "github" } ], - "time": "2026-02-03T18:06:51+00:00" + "time": "2026-02-07T10:57:35+00:00" }, { "name": "composer/semver", @@ -4297,6 +4297,38 @@ ], "time": "2024-07-11T14:55:45+00:00" }, + { + "name": "swoole/ide-helper", + "version": "6.0.2", + "source": { + "type": "git", + "url": "https://github.com/swoole/ide-helper.git", + "reference": "6f12243dce071714c5febe059578d909698f9a52" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/swoole/ide-helper/zipball/6f12243dce071714c5febe059578d909698f9a52", + "reference": "6f12243dce071714c5febe059578d909698f9a52", + "shasum": "" + }, + "type": "library", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Team Swoole", + "email": "team@swoole.com" + } + ], + "description": "IDE help files for Swoole.", + "support": { + "issues": "https://github.com/swoole/ide-helper/issues", + "source": "https://github.com/swoole/ide-helper/tree/6.0.2" + }, + "time": "2025-03-23T07:31:41+00:00" + }, { "name": "symfony/console", "version": "v8.0.4", diff --git a/docs/Getting-Starting-Guide.md b/docs/Getting-Starting-Guide.md index 93a5f681..04a3d2e3 100644 --- a/docs/Getting-Starting-Guide.md +++ b/docs/Getting-Starting-Guide.md @@ -9,9 +9,9 @@ If you’re new to Utopia, let’s get started by looking at an example of a bas ## Basic GET Route ```php -use Utopia\Http; -use Utopia\Swoole\Request; -use Utopia\Swoole\Response; +use Utopia\Http\Http; +use Utopia\Http\Adapter\Swoole\Request; +use Utopia\Http\Adapter\Swoole\Response; use Swoole\Http\Server; use Swoole\Http\Request as SwooleRequest; use Swoole\Http\Response as SwooleResponse; @@ -26,6 +26,7 @@ Http::get('/') // Return raw HTML $response->send("
Hello World!
"); } + ); /* Configure your HTTP server to respond with the Utopia app. */ @@ -138,9 +139,9 @@ You can find the details of other status codes by visiting our [GitHub repositor Let's make the above example slightly advanced by adding more properties. ```php -use Utopia\Http; -use Utopia\Swoole\Request; -use Utopia\Swoole\Response; +use Utopia\Http\Http; +use Utopia\Http\Adapter\Swoole\Request; +use Utopia\Http\Adapter\Swoole\Response; use Swoole\Http\Server; use Swoole\Http\Request as SwooleRequest; use Swoole\Http\Response as SwooleResponse; diff --git a/src/Http/Adapter/Adapter.php b/src/Http/Adapter/Adapter.php new file mode 100644 index 00000000..3e3cb602 --- /dev/null +++ b/src/Http/Adapter/Adapter.php @@ -0,0 +1,10 @@ +generateInput(); + + return $this->rawPayload; + } + + /** + * Get server + * + * Method for querying server parameters. If $key is not found $default value will be returned. + * + * @param string $key + * @param string|null $default + * @return string|null + */ + public function getServer(string $key, ?string $default = null): ?string + { + return $_SERVER[$key] ?? $default; + } + + /** + * Set server + * + * Method for setting server parameters. + * + * @param string $key + * @param string $value + * @return static + */ + public function setServer(string $key, string $value): static + { + $_SERVER[$key] = $value; + + return $this; + } + + /** + * Get IP + * + * Returns users IP address. + * Support HTTP_X_FORWARDED_FOR header usually return + * from different proxy servers or PHP default REMOTE_ADDR + * + * @return string + */ + public function getIP(): string + { + $remoteAddr = $this->getServer('REMOTE_ADDR') ?? '0.0.0.0'; + + foreach ($this->trustedIpHeaders as $header) { + $headerValue = $this->getHeader($header); + + if (empty($headerValue)) { + continue; + } + + // Leftmost IP address is the address of the originating client + $ips = \explode(',', $headerValue); + $ip = \trim($ips[0]); + + // Validate IP format (supports both IPv4 and IPv6) + if (\filter_var($ip, FILTER_VALIDATE_IP)) { + return $ip; + } + } + + return $remoteAddr; + } + + /** + * Get Protocol + * + * Returns request protocol. + * Support HTTP_X_FORWARDED_PROTO header usually return + * from different proxy servers or PHP default REQUEST_SCHEME + * + * @return string + */ + public function getProtocol(): string + { + return $this->getServer('HTTP_X_FORWARDED_PROTO', $this->getServer('REQUEST_SCHEME')) ?? 'https'; + } + + /** + * Get Port + * + * Returns request port. + * + * @return string + */ + public function getPort(): string + { + return (string) \parse_url($this->getProtocol().'://'.$this->getServer('HTTP_HOST', ''), PHP_URL_PORT); + } + + /** + * Get Hostname + * + * Returns request hostname. + * + * @return string + */ + public function getHostname(): string + { + return (string) \parse_url($this->getProtocol().'://'.$this->getServer('HTTP_HOST', ''), PHP_URL_HOST); + } + + /** + * Get Method + * + * Return HTTP request method + * + * @return string + */ + public function getMethod(): string + { + return $this->getServer('REQUEST_METHOD') ?? 'UNKNOWN'; + } + + /** + * Set Method + * + * Set HTTP request method + * + * @param string $method + * @return static + */ + public function setMethod(string $method): static + { + $this->setServer('REQUEST_METHOD', $method); + + return $this; + } + + /** + * Get URI + * + * Return HTTP request URI + * + * @return string + */ + public function getURI(): string + { + return $this->getServer('REQUEST_URI') ?? ''; + } + + /** + * Get Path + * + * Return HTTP request path + * + * @param string $uri + * @return static + */ + public function setURI(string $uri): static + { + $this->setServer('REQUEST_URI', $uri); + + return $this; + } + + /** + * Get files + * + * Method for querying upload files data. If $key is not found empty array will be returned. + * + * @param string $key + * @return array + */ + public function getFiles(string $key): array + { + return (isset($_FILES[$key])) ? $_FILES[$key] : []; + } + + /** + * Get Referer + * + * Return HTTP referer header + * + * @param string $default + * @return string + */ + public function getReferer(string $default = ''): string + { + return (string) $this->getServer('HTTP_REFERER', $default); + } + + /** + * Get Origin + * + * Return HTTP origin header + * + * @param string $default + * @return string + */ + public function getOrigin(string $default = ''): string + { + return (string) $this->getServer('HTTP_ORIGIN', $default); + } + + /** + * Get User Agent + * + * Return HTTP user agent header + * + * @param string $default + * @return string + */ + public function getUserAgent(string $default = ''): string + { + return (string) $this->getServer('HTTP_USER_AGENT', $default); + } + + /** + * Get Accept + * + * Return HTTP accept header + * + * @param string $default + * @return string + */ + public function getAccept(string $default = ''): string + { + return (string) $this->getServer('HTTP_ACCEPT', $default); + } + + /** + * Get cookie + * + * Method for querying HTTP cookie parameters. If $key is not found $default value will be returned. + * + * @param string $key + * @param string $default + * @return string + */ + public function getCookie(string $key, string $default = ''): string + { + return (isset($_COOKIE[$key])) ? $_COOKIE[$key] : $default; + } + + /** + * Get header + * + * Method for querying HTTP header parameters. If $key is not found $default value will be returned. + * + * @param string $key + * @param string $default + * @return string + */ + public function getHeader(string $key, string $default = ''): string + { + $headers = $this->generateHeaders(); + + return (isset($headers[$key])) ? $headers[$key] : $default; + } + + /** + * Set header + * + * Method for adding HTTP header parameters. + * + * @param string $key + * @param string $value + * @return static + */ + public function addHeader(string $key, string $value): static + { + $this->headers[$key] = $value; + + return $this; + } + + /** + * Remvoe header + * + * Method for removing HTTP header parameters. + * + * @param string $key + * @return static + */ + public function removeHeader(string $key): static + { + if (isset($this->headers[$key])) { + unset($this->headers[$key]); + } + + return $this; + } + + /** + * Generate input + * + * Generate PHP input stream and parse it as an array in order to handle different content type of requests + * + * @return array + */ + protected function generateInput(): array + { + if (null === $this->queryString) { + $this->queryString = $_GET; + } + if (null === $this->payload) { + $contentType = $this->getHeader('content-type'); + + // Get content-type without the charset + $length = \strpos($contentType, ';'); + $length = (empty($length)) ? \strlen($contentType) : $length; + $contentType = \substr($contentType, 0, $length); + + $this->rawPayload = \file_get_contents('php://input'); + + switch ($contentType) { + case 'application/json': + $this->payload = \json_decode($this->rawPayload, true); + break; + default: + $this->payload = $_POST; + break; + } + + if (empty($this->payload)) { // Make sure we return same data type even if json payload is empty or failed + $this->payload = []; + } + } + + return match ($this->getServer('REQUEST_METHOD', '')) { + self::METHOD_POST, + self::METHOD_PUT, + self::METHOD_PATCH, + self::METHOD_DELETE => $this->payload, + default => $this->queryString + }; + } + + /** + * Generate headers + * + * Parse request headers as an array for easy querying using the getHeader method + * + * @return array + */ + protected function generateHeaders(): array + { + if (null === $this->headers) { + /** + * Fallback for older PHP versions + * that do not support generateHeaders + */ + if (!\function_exists('getallheaders')) { + $headers = []; + + foreach ($_SERVER as $name => $value) { + if (\substr($name, 0, 5) == 'HTTP_') { + $headers[\str_replace(' ', '-', \strtolower(\str_replace('_', ' ', \substr($name, 5))))] = $value; + } + } + + $this->headers = $headers; + + return $this->headers; + } + + $this->headers = array_change_key_case(getallheaders()); + } + + return $this->headers; + } +} diff --git a/src/Http/Adapter/FPM/Response.php b/src/Http/Adapter/FPM/Response.php new file mode 100644 index 00000000..7e36a55a --- /dev/null +++ b/src/Http/Adapter/FPM/Response.php @@ -0,0 +1,88 @@ + $value + * @return void + */ + public function sendHeader(string $key, mixed $value): void + { + if (\is_array($value)) { + foreach ($value as $v) { + \header($key.': '.$v, false); + } + } else { + \header($key.': '.$value); + } + } + + /** + * Send Cookie + * + * Output Cookie + * + * @param string $name + * @param string $value + * @param array $options + * @return void + */ + protected function sendCookie(string $name, string $value, array $options): void + { + // Use proper PHP keyword name + $options['expires'] = $options['expire']; + unset($options['expire']); + + // Set the cookie + \setcookie($name, $value, $options); + } +} diff --git a/src/Http/Adapter/FPM/Server.php b/src/Http/Adapter/FPM/Server.php new file mode 100644 index 00000000..62566379 --- /dev/null +++ b/src/Http/Adapter/FPM/Server.php @@ -0,0 +1,34 @@ + $request); + Http::setResource('fpmResponse', fn () => $response); + + call_user_func($callback, $request, $response); + } + + public function onStart(callable $callback) + { + call_user_func($callback, $this); + } + + public function start() + { + return; + } +} diff --git a/src/Http/Adapter/Swoole/Request.php b/src/Http/Adapter/Swoole/Request.php new file mode 100644 index 00000000..78760397 --- /dev/null +++ b/src/Http/Adapter/Swoole/Request.php @@ -0,0 +1,378 @@ +swoole = $request; + } + + /** + * Get raw payload + * + * Method for getting the HTTP request payload as a raw string. + * + * @return string + */ + public function getRawPayload(): string + { + return $this->swoole->rawContent(); + } + + /** + * Get server + * + * Method for querying server parameters. If $key is not found $default value will be returned. + * + * @param string $key + * @param string|null $default + * @return string|null + */ + public function getServer(string $key, ?string $default = null): ?string + { + return $this->swoole->server[$key] ?? $default; + } + + /** + * Set server + * + * Method for setting server parameters. + * + * @param string $key + * @param string $value + * @return static + */ + public function setServer(string $key, string $value): static + { + $this->swoole->server[$key] = $value; + + return $this; + } + + /** + * Get IP + * + * Extracts the client's IP address from trusted headers or falls back to the remote address. + * Prioritizes headers like X-Forwarded-For when behind proxies or load balancers, + * defaulting to REMOTE_ADDR when trusted headers are unavailable. + * + * @return string The validated client IP address or '0.0.0.0' if unavailable + */ + public function getIP(): string + { + $remoteAddr = $this->getServer('REMOTE_ADDR') ?? '0.0.0.0'; + + foreach ($this->trustedIpHeaders as $header) { + $headerValue = $this->getHeader($header); + + if (empty($headerValue)) { + continue; + } + + // Leftmost IP address is the address of the originating client + $ips = explode(',', $headerValue); + $ip = trim($ips[0]); + + // Validate IP format (supports both IPv4 and IPv6) + if (filter_var($ip, FILTER_VALIDATE_IP)) { + return $ip; + } + } + + return $remoteAddr; + } + + /** + * Get Protocol + * + * Returns request protocol. + * Support HTTP_X_FORWARDED_PROTO header usually return + * from different proxy servers or PHP default REQUEST_SCHEME + * + * @return string + */ + public function getProtocol(): string + { + $protocol = $this->getHeader('x-forwarded-proto', $this->getServer('server_protocol') ?? 'https'); + + if ($protocol === 'HTTP/1.1') { + return 'http'; + } + + return match ($protocol) { + 'http', 'https', 'ws', 'wss' => $protocol, + default => 'https' + }; + } + + /** + * Get Port + * + * Returns request port. + * + * @return string + */ + public function getPort(): string + { + return $this->getHeader('x-forwarded-port', (string) \parse_url($this->getProtocol().'://'.$this->getHeader('x-forwarded-host', $this->getHeader('host')), PHP_URL_PORT)); + } + + /** + * Get Hostname + * + * Returns request hostname. + * + * @return string + */ + public function getHostname(): string + { + return strval(\parse_url($this->getProtocol().'://'.$this->getHeader('x-forwarded-host', $this->getHeader('host')), PHP_URL_HOST)); + } + + /** + * Get Method + * + * Return HTTP request method + * + * @return string + */ + public function getMethod(): string + { + return $this->getServer('request_method') ?? 'UNKNOWN'; + } + + /** + * Set method + * + * Set HTTP request method + * + * @param string $method + * @return static + */ + public function setMethod(string $method): static + { + $this->setServer('request_method', $method); + + return $this; + } + + /** + * Get URI + * + * Return HTTP request URI + * + * @return string + */ + public function getURI(): string + { + return $this->getServer('request_uri') ?? ''; + } + + /** + * Set URI + * + * Set HTTP request URI + * + * @param string $uri + * @return static + */ + public function setURI(string $uri): static + { + $this->setServer('request_uri', $uri); + + return $this; + } + + /** + * Get Referer + * + * Return HTTP referer header + * + * @return string + */ + public function getReferer(string $default = ''): string + { + return $this->getHeader('referer', ''); + } + + /** + * Get Origin + * + * Return HTTP origin header + * + * @return string + */ + public function getOrigin(string $default = ''): string + { + return $this->getHeader('origin', $default); + } + + /** + * Get User Agent + * + * Return HTTP user agent header + * + * @return string + */ + public function getUserAgent(string $default = ''): string + { + return $this->getHeader('user-agent', $default); + } + + /** + * Get Accept + * + * Return HTTP accept header + * + * @return string + */ + public function getAccept(string $default = ''): string + { + return $this->getHeader('accept', $default); + } + + /** + * Get files + * + * Method for querying upload files data. If $key is not found empty array will be returned. + * + * @param string $key + * @return array + */ + public function getFiles($key): array + { + $key = strtolower($key); + + return $this->swoole->files[$key] ?? []; + } + + /** + * Get cookie + * + * Method for querying HTTP cookie parameters. If $key is not found $default value will be returned. + * + * @param string $key + * @param string $default + * @return string + */ + public function getCookie(string $key, string $default = ''): string + { + $key = strtolower($key); + + return $this->swoole->cookie[$key] ?? $default; + } + + /** + * Get header + * + * Method for querying HTTP header parameters. If $key is not found $default value will be returned. + * + * @param string $key + * @param string $default + * @return string + */ + public function getHeader(string $key, string $default = ''): string + { + return $this->swoole->header[$key] ?? $default; + } + + /** + * Method for adding HTTP header parameters. + * + * @param string $key + * @param string $value + * @return static + */ + public function addHeader(string $key, string $value): static + { + $this->swoole->header[$key] = $value; + + return $this; + } + + /** + * Method for removing HTTP header parameters. + * + * @param string $key + * @return static + */ + public function removeHeader(string $key): static + { + if (isset($this->swoole->header[$key])) { + unset($this->swoole->header[$key]); + } + + return $this; + } + + /** + * Generate input + * + * Generate PHP input stream and parse it as an array in order to handle different content type of requests + * + * @return array + */ + protected function generateInput(): array + { + if (null === $this->queryString) { + $this->queryString = $this->swoole->get ?? []; + } + if (null === $this->payload) { + $contentType = $this->getHeader('content-type'); + + // Get content-type without the charset + $length = strpos($contentType, ';'); + $length = (empty($length)) ? strlen($contentType) : $length; + $contentType = substr($contentType, 0, $length); + + switch ($contentType) { + case 'application/json': + $this->payload = json_decode(strval($this->swoole->rawContent()), true); + break; + + default: + $this->payload = $this->swoole->post; + break; + } + + if (empty($this->payload)) { // Make sure we return same data type even if json payload is empty or failed + $this->payload = []; + } + } + + return match ($this->getMethod()) { + self::METHOD_POST, + self::METHOD_PUT, + self::METHOD_PATCH, + self::METHOD_DELETE => $this->payload, + default => $this->queryString + }; + } + + /** + * Generate headers + * + * Parse request headers as an array for easy querying using the getHeader method + * + * @return array + */ + protected function generateHeaders(): array + { + return $this->swoole->header; + } +} diff --git a/src/Http/Adapter/Swoole/Response.php b/src/Http/Adapter/Swoole/Response.php new file mode 100644 index 00000000..95188e12 --- /dev/null +++ b/src/Http/Adapter/Swoole/Response.php @@ -0,0 +1,113 @@ +swoole = $response; + parent::__construct(\microtime(true)); + } + + /** + * Write + * + * @param string $content + * @return bool False if write cannot complete, such as request ended by client + */ + public function write(string $content): bool + { + return $this->swoole->write($content); + } + + /** + * End + * + * @param string|null $content + * @return void + */ + public function end(?string $content = null): void + { + $this->swoole->end($content); + } + + /** + * Get status code reason + * + * Get HTTP response status code reason from available options. If status code is unknown an exception will be thrown. + * + * @param int $code + * @return string + * + * @throws \Exception + */ + protected function getStatusCodeReason(int $code): string + { + if (!\array_key_exists($code, $this->statusCodes)) { + throw new \Exception('Unknown HTTP status code'); + } + + return $this->statusCodes[$code]; + } + + /** + * Send Status Code + * + * @param int $statusCode + * @return void + */ + protected function sendStatus(int $statusCode): void + { + $this->swoole->status((string) $statusCode, $this->getStatusCodeReason($statusCode)); + } + + /** + * Send Header + * + * @param string $key + * @param string|array $value + * @return void + */ + public function sendHeader(string $key, mixed $value): void + { + $this->swoole->header($key, $value); + } + + /** + * Send Cookie + * + * Send a cookie + * + * @param string $name + * @param string $value + * @param array $options + * @return void + */ + protected function sendCookie(string $name, string $value, array $options): void + { + $this->swoole->cookie( + $name, + value: $value, + expires: $options['expire'] ?? 0, + path: $options['path'] ?? '', + domain: $options['domain'] ?? '', + secure: $options['secure'] ?? false, + httponly: $options['httponly'] ?? false, + samesite: $options['samesite'] ?? false, + ); + } +} diff --git a/src/Http/Adapter/Swoole/Server.php b/src/Http/Adapter/Swoole/Server.php new file mode 100644 index 00000000..f39044a6 --- /dev/null +++ b/src/Http/Adapter/Swoole/Server.php @@ -0,0 +1,42 @@ +server = new SwooleServer($host, $port); + if (!empty($settings)) { + $this->server->set($settings); + } + } + + public function onRequest(callable $callback) + { + $this->server->on('request', function (SwooleRequest $request, SwooleResponse $response) use ($callback) { + Http::setResource('swooleRequest', fn () => $request); + Http::setResource('swooleResponse', fn () => $response); + + call_user_func($callback, new Request($request), new Response($response)); + }); + } + + public function onStart(callable $callback) + { + call_user_func($callback, $this); + } + + public function start() + { + $this->server->start(); + } +} diff --git a/src/Exception.php b/src/Http/Exception.php similarity index 67% rename from src/Exception.php rename to src/Http/Exception.php index 71eaf127..46e55958 100644 --- a/src/Exception.php +++ b/src/Http/Exception.php @@ -1,6 +1,6 @@ + */ + protected array $loaded = []; + + /** + * @var int + */ + protected int $count = 0; + + /** + * @var array + */ + protected array $mimeTypes = []; + + /** + * @var array + */ + public const EXTENSIONS = [ + 'css' => 'text/css', + 'js' => 'text/javascript', + 'svg' => 'image/svg+xml', + ]; + + /** + * Add MIME type. + * + * @param string $mimeType + * @return void + */ + public function addMimeType(string $mimeType): void + { + $this->mimeTypes[$mimeType] = true; + } + + /** + * Remove MIME type. + * + * @param string $mimeType + * @return void + */ + public function removeMimeType(string $mimeType): void + { + if (isset($this->mimeTypes[$mimeType])) { + unset($this->mimeTypes[$mimeType]); + } + } + + /** + * Get MimeType List + * + * @return array + */ + public function getMimeTypes(): array + { + return $this->mimeTypes; + } + + /** + * Get Files Loaded Count + * + * @return int + */ + public function getCount(): int + { + return $this->count; + } + + /** + * Load directory. + * + * @param string $directory + * @param string|null $root + * @return void + * + * @throws \Exception + */ + public function load(string $directory, ?string $root = null): void + { + if (!is_readable($directory)) { + throw new Exception("Failed to load directory: {$directory}"); + } + + $directory = realpath($directory); + + $root ??= $directory; + + $handle = opendir(strval($directory)); + + while ($path = readdir($handle)) { + $extension = pathinfo($path, PATHINFO_EXTENSION); + + if (in_array($path, ['.', '..'])) { + continue; + } + + if (in_array($extension, ['php', 'phtml'])) { + continue; + } + + if (substr($path, 0, 1) === '.') { + continue; + } + + $dirPath = $directory.'/'.$path; + + if (is_dir($dirPath)) { + $this->load($dirPath, strval($root)); + + continue; + } + + $key = substr($dirPath, strlen(strval($root))); + + if (array_key_exists($key, $this->loaded)) { + continue; + } + + $this->loaded[$key] = [ + 'contents' => file_get_contents($dirPath), + 'mimeType' => (array_key_exists($extension, self::EXTENSIONS)) + ? self::EXTENSIONS[$extension] + : mime_content_type($dirPath), + ]; + + $this->count++; + } + + closedir($handle); + } + + /** + * Is file loaded. + * + * @param string $uri + * @return bool + */ + public function isFileLoaded(string $uri): bool + { + if (!array_key_exists($uri, $this->loaded)) { + return false; + } + + return true; + } + + /** + * Get file contents. + * + * @param string $uri + * @return string + * + * @throws \Exception + */ + public function getFileContents(string $uri): mixed + { + if (!array_key_exists($uri, $this->loaded)) { + throw new Exception('File not found or not loaded: '.$uri); + } + + return $this->loaded[$uri]['contents']; + } + + /** + * Get file MIME type. + * + * @param string $uri + * @return string + * + * @throws \Exception + */ + public function getFileMimeType(string $uri): mixed + { + if (!array_key_exists($uri, $this->loaded)) { + throw new Exception('File not found or not loaded: '.$uri); + } + + return $this->loaded[$uri]['mimeType']; + } + + /** + * Reset. + * + * @return void + */ + public function reset(): void + { + $this->count = 0; + $this->loaded = []; + $this->mimeTypes = []; + } +} diff --git a/src/Hook.php b/src/Http/Hook.php similarity index 99% rename from src/Hook.php rename to src/Http/Hook.php index b679b787..76796f78 100644 --- a/src/Hook.php +++ b/src/Http/Hook.php @@ -1,6 +1,8 @@ null, ]; + /** + * @var Files + */ + protected Files $files; + /** * @var array */ @@ -142,6 +148,7 @@ class Http public function __construct(string $timezone) { \date_default_timezone_set($timezone); + $this->files = new Files(); $this->setTelemetry(new NoTelemetry()); } @@ -550,6 +557,57 @@ public static function addRoute(string $method, string $url): Route return $route; } + /** + * Load directory. + * + * @param string $directory + * @param string|null $root + * @return void + * + * @throws \Exception + */ + public function loadFiles(string $directory, ?string $root = null): void + { + $this->files->load($directory, $root); + } + + /** + * Is file loaded. + * + * @param string $uri + * @return bool + */ + protected function isFileLoaded(string $uri): bool + { + return $this->files->isFileLoaded($uri); + } + + /** + * Get file contents. + * + * @param string $uri + * @return string + * + * @throws \Exception + */ + protected function getFileContents(string $uri): mixed + { + return $this->files->getFileContents($uri); + } + + /** + * Get file MIME type. + * + * @param string $uri + * @return string + * + * @throws \Exception + */ + protected function getFileMimeType(string $uri): mixed + { + return $this->files->getFileMimeType($uri); + } + /** * Match * @@ -709,7 +767,7 @@ protected function getArguments(Hook $hook, array $values, array $requestParams) throw new Exception('Model class does not exist: ' . $model, 500); } if (!\is_a($model, Model::class, true)) { - throw new Exception('Model class is not an instance of Utopia\\Model', 500); + throw new Exception('Model class is not an instance of Utopia\\Http\\Model', 500); } if (\is_string($value) && $value !== '') { try { @@ -844,6 +902,18 @@ private function runInternal(Request $request, Response $response): static return $response; }); + if ($this->isFileLoaded($request->getURI())) { + $time = (60 * 60 * 24 * 365 * 2); // 45 days cache + + $response + ->setContentType($this->getFileMimeType($request->getURI())) + ->addHeader('Cache-Control', 'public, max-age=' . $time) + ->addHeader('Expires', \date('D, d M Y H:i:s', \time() + $time) . ' GMT') // 45 days cache + ->send($this->getFileContents($request->getURI())); + + return $this; + } + $method = $request->getMethod(); $route = $this->match($request); $this->matchedRoute = $route; diff --git a/src/Model.php b/src/Http/Model.php similarity index 79% rename from src/Model.php rename to src/Http/Model.php index 0b6da55e..14b2b23f 100644 --- a/src/Model.php +++ b/src/Http/Model.php @@ -1,6 +1,6 @@ generateInput(); - - return $this->rawPayload; - } + abstract public function getRawPayload(): string; /** * Get server @@ -143,10 +131,7 @@ public function getRawPayload(): string * @param string|null $default * @return string|null */ - public function getServer(string $key, ?string $default = null): ?string - { - return $_SERVER[$key] ?? $default; - } + abstract public function getServer(string $key, ?string $default = null): ?string; /** * Set server @@ -157,12 +142,7 @@ public function getServer(string $key, ?string $default = null): ?string * @param string $value * @return static */ - public function setServer(string $key, string $value): static - { - $_SERVER[$key] = $value; - - return $this; - } + abstract public function setServer(string $key, string $value): static; /** * Set trusted ip headers @@ -175,9 +155,9 @@ public function setServer(string $key, string $value): static */ public function setTrustedIpHeaders(array $headers): static { - $normalized = array_map('strtolower', $headers); - $trimmed = array_map('trim', $normalized); - $this->trustedIpHeaders = array_filter($trimmed); + $normalized = \array_map('strtolower', $headers); + $trimmed = \array_map('trim', $normalized); + $this->trustedIpHeaders = \array_filter($trimmed); return $this; } @@ -185,35 +165,13 @@ public function setTrustedIpHeaders(array $headers): static /** * Get IP * - * Extracts the client's IP address from trusted headers or falls back to the remote address. - * Prioritizes headers like X-Forwarded-For when behind proxies or load balancers, - * defaulting to REMOTE_ADDR when trusted headers are unavailable. + * Returns users IP address. + * Support HTTP_X_FORWARDED_FOR header usually return + * from different proxy servers or PHP default REMOTE_ADDR * - * @return string The validated client IP address or '0.0.0.0' if unavailable + * @return string */ - public function getIP(): string - { - $remoteAddr = $this->getServer('REMOTE_ADDR') ?? '0.0.0.0'; - - foreach ($this->trustedIpHeaders as $header) { - $headerValue = $this->getHeader($header); - - if (empty($headerValue)) { - continue; - } - - // Leftmost IP address is the address of the originating client - $ips = explode(',', $headerValue); - $ip = trim($ips[0]); - - // Validate IP format (supports both IPv4 and IPv6) - if (filter_var($ip, FILTER_VALIDATE_IP)) { - return $ip; - } - } - - return $remoteAddr; - } + abstract public function getIP(): string; /** * Get Protocol @@ -224,10 +182,7 @@ public function getIP(): string * * @return string */ - public function getProtocol(): string - { - return $this->getServer('HTTP_X_FORWARDED_PROTO', $this->getServer('REQUEST_SCHEME')) ?? 'https'; - } + abstract public function getProtocol(): string; /** * Get Port @@ -236,10 +191,7 @@ public function getProtocol(): string * * @return string */ - public function getPort(): string - { - return (string) \parse_url($this->getProtocol().'://'.$this->getServer('HTTP_HOST', ''), PHP_URL_PORT); - } + abstract public function getPort(): string; /** * Get Hostname @@ -248,10 +200,7 @@ public function getPort(): string * * @return string */ - public function getHostname(): string - { - return (string) \parse_url($this->getProtocol().'://'.$this->getServer('HTTP_HOST', ''), PHP_URL_HOST); - } + abstract public function getHostname(): string; /** * Get Method @@ -260,10 +209,7 @@ public function getHostname(): string * * @return string */ - public function getMethod(): string - { - return $this->getServer('REQUEST_METHOD') ?? 'UNKNOWN'; - } + abstract public function getMethod(): string; /** * Set Method @@ -273,12 +219,7 @@ public function getMethod(): string * @param string $method * @return static */ - public function setMethod(string $method): static - { - $this->setServer('REQUEST_METHOD', $method); - - return $this; - } + abstract public function setMethod(string $method): static; /** * Get URI @@ -300,12 +241,7 @@ public function getURI(): string * @param string $uri * @return static */ - public function setURI(string $uri): static - { - $this->setServer('REQUEST_URI', $uri); - - return $this; - } + abstract public function setURI(string $uri): static; /** * Get files @@ -315,10 +251,7 @@ public function setURI(string $uri): static * @param string $key * @return array */ - public function getFiles(string $key): array - { - return (isset($_FILES[$key])) ? $_FILES[$key] : []; - } + abstract public function getFiles(string $key): array; /** * Get Referer @@ -328,10 +261,7 @@ public function getFiles(string $key): array * @param string $default * @return string */ - public function getReferer(string $default = ''): string - { - return (string) $this->getServer('HTTP_REFERER', $default); - } + abstract public function getReferer(string $default = ''): string; /** * Get Origin @@ -341,10 +271,7 @@ public function getReferer(string $default = ''): string * @param string $default * @return string */ - public function getOrigin(string $default = ''): string - { - return (string) $this->getServer('HTTP_ORIGIN', $default); - } + abstract public function getOrigin(string $default = ''): string; /** * Get User Agent @@ -354,10 +281,7 @@ public function getOrigin(string $default = ''): string * @param string $default * @return string */ - public function getUserAgent(string $default = ''): string - { - return (string) $this->getServer('HTTP_USER_AGENT', $default); - } + abstract public function getUserAgent(string $default = ''): string; /** * Get Accept @@ -367,10 +291,7 @@ public function getUserAgent(string $default = ''): string * @param string $default * @return string */ - public function getAccept(string $default = ''): string - { - return (string) $this->getServer('HTTP_ACCEPT', $default); - } + abstract public function getAccept(string $default = ''): string; /** * Get cookie @@ -381,10 +302,7 @@ public function getAccept(string $default = ''): string * @param string $default * @return string */ - public function getCookie(string $key, string $default = ''): string - { - return (isset($_COOKIE[$key])) ? $_COOKIE[$key] : $default; - } + abstract public function getCookie(string $key, string $default = ''): string; /** * Get header @@ -395,12 +313,7 @@ public function getCookie(string $key, string $default = ''): string * @param string $default * @return string */ - public function getHeader(string $key, string $default = ''): string - { - $headers = $this->generateHeaders(); - - return (isset($headers[$key])) ? $headers[$key] : $default; - } + abstract public function getHeader(string $key, string $default = ''): string; /** * Get headers @@ -423,12 +336,7 @@ public function getHeaders(): array * @param string $value * @return static */ - public function addHeader(string $key, string $value): static - { - $this->headers[$key] = $value; - - return $this; - } + abstract public function addHeader(string $key, string $value): static; /** * Remvoe header @@ -438,14 +346,7 @@ public function addHeader(string $key, string $value): static * @param string $key * @return static */ - public function removeHeader(string $key): static - { - if (isset($this->headers[$key])) { - unset($this->headers[$key]); - } - - return $this; - } + abstract public function removeHeader(string $key): static; /** * Get Request Size @@ -456,7 +357,16 @@ public function removeHeader(string $key): static */ public function getSize(): int { - return \mb_strlen(\implode("\n", $this->generateHeaders()), '8bit') + \mb_strlen(\file_get_contents('php://input'), '8bit'); + $headers = $this->generateHeaders(); + $headerStrings = []; + foreach ($headers as $key => $value) { + if (\is_array($value)) { + $headerStrings[] = $key . ': ' . \implode(', ', $value); + } else { + $headerStrings[] = $key . ': ' . $value; + } + } + return \mb_strlen(\implode("\n", $headerStrings), '8bit') + \mb_strlen(\file_get_contents('php://input'), '8bit'); } /** @@ -604,51 +514,6 @@ public function setPayload(array $params): static return $this; } - /** - * Generate input - * - * Generate PHP input stream and parse it as an array in order to handle different content type of requests - * - * @return array - */ - protected function generateInput(): array - { - if (null === $this->queryString) { - $this->queryString = $_GET; - } - if (null === $this->payload) { - $contentType = $this->getHeader('content-type'); - - // Get content-type without the charset - $length = \strpos($contentType, ';'); - $length = (empty($length)) ? \strlen($contentType) : $length; - $contentType = \substr($contentType, 0, $length); - - $this->rawPayload = \file_get_contents('php://input'); - - switch ($contentType) { - case 'application/json': - $this->payload = \json_decode($this->rawPayload, true); - break; - default: - $this->payload = $_POST; - break; - } - - if (empty($this->payload)) { // Make sure we return same data type even if json payload is empty or failed - $this->payload = []; - } - } - - return match ($this->getServer('REQUEST_METHOD', '')) { - self::METHOD_POST, - self::METHOD_PUT, - self::METHOD_PATCH, - self::METHOD_DELETE => $this->payload, - default => $this->queryString - }; - } - /** * Generate headers * @@ -683,6 +548,15 @@ protected function generateHeaders(): array return $this->headers; } + /** + * Generate input + * + * Generate PHP input stream and parse it as an array in order to handle different content type of requests + * + * @return array + */ + abstract protected function generateInput(): array; + /** * Content Range Parser * diff --git a/src/Response.php b/src/Http/Response.php similarity index 96% rename from src/Response.php rename to src/Http/Response.php index d2cfd029..87ec85fe 100755 --- a/src/Response.php +++ b/src/Http/Response.php @@ -1,10 +1,10 @@ $value * @return void */ - protected function sendHeader(string $key, mixed $value): void - { - if (\is_array($value)) { - foreach ($value as $v) { - \header($key.': '.$v, false); - } - } else { - \header($key.': '.$value); - } - } + abstract public function sendHeader(string $key, mixed $value): void; /** * Send Cookie @@ -865,15 +845,7 @@ protected function sendHeader(string $key, mixed $value): void * @param array $options * @return void */ - protected function sendCookie(string $name, string $value, array $options): void - { - // Use proper PHP keyword name - $options['expires'] = $options['expire']; - unset($options['expire']); - - // Set the cookie - \setcookie($name, $value, $options); - } + abstract protected function sendCookie(string $name, string $value, array $options): void; /** * Append cookies diff --git a/src/Route.php b/src/Http/Route.php similarity index 98% rename from src/Route.php rename to src/Http/Route.php index 714b0a79..0197e5f4 100755 --- a/src/Route.php +++ b/src/Http/Route.php @@ -1,6 +1,6 @@ app->run(new Request(), new Response()); $this->assertTrue($errorCaught); - $this->assertStringContainsString('not an instance of Utopia\\Model', $errorMessage); + $this->assertStringContainsString('not an instance of Utopia\\Http\\Model', $errorMessage); } public function testModelWithEmptyString(): void diff --git a/tests/RequestTest.php b/tests/RequestTest.php index 727d534a..55d49ee9 100755 --- a/tests/RequestTest.php +++ b/tests/RequestTest.php @@ -1,9 +1,9 @@ response->setContentType(Response::CONTENT_TYPE_HTML, Response::CHARSET_UTF8); // Assertions - $this->assertInstanceOf('Utopia\Response', $contentType); + $this->assertInstanceOf('Utopia\Http\Response', $contentType); } public function testCanSetStatus() @@ -31,7 +32,7 @@ public function testCanSetStatus() $status = $this->response->setStatusCode(Response::STATUS_CODE_OK); // Assertions - $this->assertInstanceOf('Utopia\Response', $status); + $this->assertInstanceOf('Utopia\Http\Response', $status); try { $this->response->setStatusCode(0); // Unknown status code @@ -49,7 +50,7 @@ public function testCanGetStatus() $status = $this->response->setStatusCode(Response::STATUS_CODE_OK); // Assertions - $this->assertInstanceOf('Utopia\Response', $status); + $this->assertInstanceOf('Utopia\Http\Response', $status); $this->assertEquals(Response::STATUS_CODE_OK, $this->response->getStatusCode()); } diff --git a/tests/RouteTest.php b/tests/RouteTest.php index cbdea1c3..ea48e111 100755 --- a/tests/RouteTest.php +++ b/tests/RouteTest.php @@ -1,6 +1,6 @@ view->setParam('key', 'value'); - $this->assertInstanceOf('Utopia\View', $value); + $this->assertInstanceOf('Utopia\Http\View', $value); } public function testCanGetParam() @@ -37,7 +37,7 @@ public function testCanSetPath() { $value = $this->view->setPath('mocks/View/fake.phtml'); - $this->assertInstanceOf('Utopia\View', $value); + $this->assertInstanceOf('Utopia\Http\View', $value); } public function testCanSetRendered() diff --git a/tests/e2e/ResponseTest.php b/tests/e2e/ResponseTest.php index b51755b5..4c7b0c12 100644 --- a/tests/e2e/ResponseTest.php +++ b/tests/e2e/ResponseTest.php @@ -1,6 +1,6 @@