From 95d567545a9ae6eec49034695cdea055d7ee1da2 Mon Sep 17 00:00:00 2001 From: Clemens Schwaighofer Date: Fri, 1 Nov 2024 14:41:46 +0900 Subject: [PATCH] URL Requests via curl, a simple library --- www/lib/CoreLibs/UrlRequests/Curl.php | 784 ++++++++++++++---- www/lib/CoreLibs/UrlRequests/CurlTrait.php | 147 ++++ .../Interface/RequestInterface.php | 95 +++ .../Interface/RequestsInterface.php | 84 -- 4 files changed, 875 insertions(+), 235 deletions(-) create mode 100644 www/lib/CoreLibs/UrlRequests/CurlTrait.php create mode 100644 www/lib/CoreLibs/UrlRequests/Interface/RequestInterface.php delete mode 100644 www/lib/CoreLibs/UrlRequests/Interface/RequestsInterface.php diff --git a/www/lib/CoreLibs/UrlRequests/Curl.php b/www/lib/CoreLibs/UrlRequests/Curl.php index 8cf0f47c..116abd8b 100644 --- a/www/lib/CoreLibs/UrlRequests/Curl.php +++ b/www/lib/CoreLibs/UrlRequests/Curl.php @@ -5,21 +5,36 @@ * CREATED: 2024/9/20 * DESCRIPTION: * Curl Client for get/post/put/delete requests through the php curl inteface + * + * For anything more complex use guzzlehttp/http + * https://docs.guzzlephp.org/en/stable/index.html + * + * Requests are guzzleHttp compatible + * Config for setup is guzzleHttp compatible (except the exception_on_not_authorized) + * Any setters and getters are only for this class */ +declare(strict_types=1); + namespace CoreLibs\UrlRequests; use RuntimeException; use CoreLibs\Convert\Json; +/** @package CoreLibs\UrlRequests */ class Curl implements Interface\RequestsInterface { + // all general calls: get/post/put/patch/delete + use CurlTrait; + /** @var array all the valid request type */ private const VALID_REQUEST_TYPES = ["get", "post", "put", "patch", "delete"]; /** @var array list of requests type that are set as custom in the curl options */ private const CUSTOM_REQUESTS = ["put", "patch", "delete"]; /** @var array list of requests types that have _POST type fields */ private const HAVE_POST_FIELDS = ["post", "put", "patch", "delete"]; + /** @var array list of requests that must have a body */ + private const MANDATORY_POST_FIELDS = ["post", "put", "patch"]; /** @var int error bad request */ public const HTTP_BAD_REQUEST = 400; /** @var int error not authorized Request */ @@ -39,92 +54,389 @@ class Curl implements Interface\RequestsInterface /** @var int http ok no content */ public const HTTP_NO_CONTENT = 204; - /** @var string auth ident as "email:api_token */ - private string $auth_ident; - /** @var bool if flagged to true, will raise an exception on failed authentication */ - private bool $exception_on_not_authorized = false; + // the config is set to be as much compatible to guzzelHttp as possible + // phpcs:disable Generic.Files.LineLength + /** @var array{auth?:array{0:string,1:string,2:string},auth_type?:int|string,auth_userpwd?:string,exception_on_not_authorized:bool,base_uri:string,headers:array>,query:array,timeout:float,connection_timeout:float} config settings as + *phpcs:enable Generic.Files.LineLength + * auth: [0: user, 1: password, 2: auth type] + * base_uri: base url to set, will prefix all urls given in calls + * headers: (array) base headers, can be overwritten by headers set in call + * timeout: default 0, in seconds (CURLOPT_TIMEOUT_MS) + * connect_timeout: default 300, in seconds (CURLOPT_CONNECTTIMEOUT_MS) + * : below is not a guzzleHttp config + * exception_on_not_authorized: bool true/false for throwing exception on auth error + */ + private array $config = [ + 'exception_on_not_authorized' => false, + 'base_uri' => '', + 'query' => [], + 'headers' => [], + 'timeout' => 0, + 'connection_timeout' => 300, + ]; + /** @var array{scheme?:string,user?:string,host?:string,port?:string,path?:string,query?:string,fragment?:string,pass?:string} parsed base_uri */ + private array $parsed_base_uri = []; + /** @var array lower key header name matches to given header name */ + private array $headers_named = []; + + /** @var array> received headers per header name, with sub array if there are redirects */ + private array $received_headers = []; + + /** @var string the current url sent */ + private string $url = ''; + /** @var array{scheme?:string,user?:string,host?:string,port?:string,path?:string,query?:string,fragment?:string,pass?:string} parsed url to sent */ + private array $parsed_url = []; + /** @var array the current headers sent */ + private array $headers = []; /** - * init class with auth ident token + * see config allowe entries above * - * @param ?string $auth_ident [defaul=null] String to send for authentication, optional - * @param bool $exception_on_not_authorized [default=false] If set to true - * will raise excepion on http auth error + * @param array $config config settings to be set */ - public function __construct(?string $auth_ident = null, bool $exception_on_not_authorized = false) + public function __construct(array $config = []) { - if (is_string($auth_ident)) { - $this->auth_ident = $auth_ident; - } - $this->exception_on_not_authorized = $exception_on_not_authorized; + $this->setConfiguration($config); } // ********************************************************************* // MARK: PRIVATE METHODS // ********************************************************************* - // MARK: query and params convert + /** + * Set the main configuration + * + * phpcs:disable Generic.Files.LineLength + * @param array{auth?:array{0:string,1:string,2:string},auth_type?:int|string,auth_userpwd?:string,exception_on_not_authorized?:bool,base_uri?:string,headers?:array>,query?:array,timeout?:float,connection_timeout?:float} $config + * @return void + * phpcs:enable Generic.Files.LineLength + */ + private function setConfiguration(array $config) + { + $default_config = [ + 'exception_on_not_authorized' => false, + 'base_uri' => '', + 'query' => [], + 'headers' => [], + 'timeout' => 0, + 'connection_timeout' => 300, + ]; + // auth string is array of 0: user, 1: password, 2: auth type + if (!empty($config['auth']) && is_array($config['auth'])) { + // base auth sets the header actually + $type = isset($config['auth'][2]) ? strtolower($config['auth'][2]) : 'basic'; + $userpwd = $config['auth'][0] . ':' . $config['auth'][1]; + switch ($type) { + case 'basic': + if (!isset($config['headers']['Authorization'])) { + $config['headers']['Authorization'] = 'Basic ' . base64_encode( + $userpwd + ); + } + break; + case 'digest': + $config['auth_type'] = CURLAUTH_DIGEST; + $config['auth_userpwd'] = $userpwd; + break; + case 'ntlm': + $config['auth_type'] = CURLAUTH_NTLM; + $config['auth_userpwd'] = $userpwd; + break; + } + } + // only set if bool + if ( + !isset($config['exception_on_not_authorized']) || + !is_bool($config['exception_on_not_authorized']) + ) { + $config['exception_on_not_authorized'] = false; + } + if (!empty($config['base_uri'])) { + // TODO: this should be run through Guzzle\Psr7\Utils in future + // $config['base_uri'] = Psr7\Utils::urlFor($config['base_uri']); + if (($parsed_base_uri = $this->parseUrl($config['base_uri'])) !== false) { + $this->parsed_base_uri = $parsed_base_uri; + $config['base_uri'] = $config['base_uri']; + } + } + // general headers + if (!empty($config['headers'])) { + // seat the key lookup with lower keys + foreach (array_keys($config['headers']) as $key) { + if (isset($this->headers_named[strtolower((string)$key)])) { + continue; + } + $this->headers_named[strtolower((string)$key)] = (string)$key; + } + } + // timeout (must be numeric) + if (!empty($config['timeout']) && !is_numeric($config['timeout'])) { + $config['timeout'] = 0; + } + if (!empty($config['connection_timeout']) && !is_numeric($config['connection_timeout'])) { + $config['connection_timeout'] = 300; + } + + $this->config = array_merge($default_config, $config); + } + + // MARK: parse and build url /** - * Convert Query params and combine with url + * From: https://github.com/guzzle/psr7/blob/a70f5c95fb43bc83f07c9c948baa0dc1829bf201/src/Uri.php#L106C5-L132C6 + * guzzle/psr7::parse * - * @param string $url - * @param null|string|array $query - * @return string + * convert the url to valid sets + * + * @param string $url + * @return array{scheme?:string,user?:string,host?:string,port?:string,path?:string,query?:string,fragment?:string,pass?:string}|false */ - private function convertQuery(string $url, null|string|array $query = null): string + private function parseUrl(string $url): array|false { - // conert to URL encoded query if array - if (is_array($query)) { - $query = http_build_query($query); + // If IPv6 + $prefix = ''; + if (preg_match('%^(.*://\[[0-9:a-f]+\])(.*?)$%', $url, $matches)) { + /** @var array{0:string, 1:string, 2:string} $matches */ + $prefix = $matches[1]; + $url = $matches[2]; } - // add the params to the url - if (!empty($query)) { - // add ? if the string doesn't strt with one - // check if URL has "?", if yes, add as "&" block - $param_prefix = '?'; - if (strstr($url, '?') !== false) { - $param_prefix = '&'; + + /** @var string $encodedUrl */ + $encodedUrl = preg_replace_callback( + '%[^:/@?&=#]+%usD', + static function ($matches) { + return urlencode($matches[0]); + }, + $url + ); + + $result = parse_url($prefix . $encodedUrl); + + if ($result === false) { + return false; + } + + /** @var callable $caller */ + $caller = 'urldecode'; + return array_map($caller, $result); + } + + /** + * build back the URL based on the parsed URL scheme + * NOTE: this is only a sub implementation + * + * phpcs:disable Generic.Files.LineLength + * @param array{scheme?:string,user?:string,host?:string,port?:string,path?:string,query?:string,fragment?:string,pass?:string} $parsed_url + * @param bool $remove_until_slash [default=false] + * @param bool $add_query [default=false] + * @param bool $add_fragment [default=false] + * @return string + * phpcs:enable Generic.Files.LineLength + */ + private function buildUrl( + array $parsed_url, + bool $remove_until_slash = false, + bool $add_query = false, + bool $add_fragment = false + ): string { + $url = ''; + // scheme has : + if (!empty($parsed_url['scheme'])) { + $url .= $parsed_url['scheme'] . ':'; + } + // host + port = authority + if (!empty($parsed_url['host'])) { + $url .= '//'; + $url .= $parsed_url['host'] ?? ''; + if (!empty($parsed_url['port'])) { + $url .= ':' . $parsed_url['port']; } - // if set, strip first character - if (str_starts_with($query, '?') || str_starts_with($query, '&')) { - $query = substr($query, 1); + } + // remove the last part "/.." because we do not end with "/" + if ($remove_until_slash) { + $url_path = $parsed_url['path'] ?? ''; + if (($lastSlashPos = strrpos($url_path, '/')) !== false) { + $url .= substr($url_path, 0, $lastSlashPos + 1); } - // build url string - $url .= $param_prefix . $query; + } else { + $url .= $parsed_url['path'] ?? ''; + } + // only on demand + if ($add_query && !empty($parsed_url['query'])) { + $url .= '?' . $parsed_url['query']; + } + if ($add_fragment && !empty($parsed_url['fragment'])) { + $url .= '#' . $parsed_url['fragment']; } return $url; } + // MARK: query, params and headers convert + /** - * Convert array payload data to json type string + * Build URL with base url and parameters * - * @param string|array $payload + * @param string $url_req to send + * @param null|array $query any optional parameters to send + * @return string the fully build URL + */ + private function buildQuery(string $url_req, null|array $query = null): string + { + if (($parsed_url = $this->parseUrl($url_req)) !== false) { + $this->parsed_url = $parsed_url; + } + $url = $url_req; + if ( + !empty($this->config['base_uri']) && + empty($this->parsed_url['scheme']) + ) { + if (str_ends_with($this->config['base_uri'], '/')) { + $url = $this->config['base_uri'] . $url_req; + } else { + // remove until last / and add url, strip leading / if set + // remove last "/" part until we are at the domain + // if we do not start with http(s):// then assume blank + // NOTE any fragments or params will get dropped, only path will remain + $url = $this->buildUrl($this->parsed_base_uri, remove_until_slash: true) . $url_req; + } + if (($parsed_url = $this->parseUrl($url)) !== false) { + $this->parsed_url = $parsed_url; + } + } + if (is_array($query)) { + $query = http_build_query($query, '', '&', PHP_QUERY_RFC3986); + } + // add the params to the url + if (!empty($query)) { + // if the url_url has a query or a a fragment, + // we need to build that url new + // $parsed_url = false; + if (!empty($this->parsed_url['query']) || !empty($this->parsed_url['framgent'])) { + $url = $this->buildUrl($this->parsed_url); + } + $url .= '?' . $query; + // fragments are ignored + // if (!empty($this->parsed_url['fragment'])) { + // $url .= '#' . $parsed_url['fragment']; + // } + } + // parse again with current url + if ($url != $url_req) { + if (($parsed_url = $this->parseUrl($url)) !== false) { + $this->parsed_url = $parsed_url; + } + } + + return $url; + } + + /** + * Convert array body data to json type string + * + * @param string|array $body * @return string */ - private function convertPayloadData(string|array $payload): string + private function convertPayloadData(string|array $body): string { // convert to string as JSON block if it is an array - if (is_array($payload)) { - $params = Json::jsonConvertArrayTo($payload); + if (is_array($body)) { + $params = Json::jsonConvertArrayTo($body); } - return $params; + return $params ?? ''; + } + + /** + * header convert from array key -> value to string list + * if the key value is numeric, it is assumed this is an array string list only + * Note: this should not be the case + * + * @param array> $headers + * @return array + */ + private function convertHeaders(array $headers): array + { + $return_headers = []; + foreach ($headers as $key => $value) { + if (!is_string($key)) { + // TODO: throw error + continue; + } + // bad if not valid header key + if (!preg_match('/^[a-zA-Z0-9\'`#$%&*+.^_|~!-]+$/D', $key)) { + // TODO throw error + continue; + } + // if value is array, join to string + if (is_array($value)) { + $value = join(', ', $value); + } + $value = trim((string)$value, " \t"); + // header values must be valid + if (!preg_match('/^[\x20\x09\x21-\x7E\x80-\xFF]*$/D', $value)) { + // TODO throw error + continue; + } + $return_headers[] = (string)$key . ':' . $value; + } + // remove empty entries + return $return_headers; + } + + /** + * Build headers, combine with global headers of they are set + * + * @param null|array> $headers + * @return array> + */ + private function buildHeaders(null|array $headers): array + { + // if headers is null, return empty headers, do not set default headers + if ($headers === null) { + return []; + } + // merge master headers with sub headers, sub headers overwrite master headers + if (!empty($this->config['headers'])) { + // we need to build the current headers as a lookup table + $headers_lookup = []; + foreach (array_keys($headers) as $key) { + $headers_lookup[strtolower((string)$key)] = (string)$key; + } + // add config headers if not set in local header + foreach ($this->headers_named as $header_key => $key) { + // is set local, use this, else use global + if (isset($headers_lookup[$header_key])) { + continue; + } + $headers[$key] = $this->config['headers'][$key]; + } + } + // always add HTTP_HOST and HTTP_USER_AGENT + return $headers; } // MARK: main curl request /** - * Overall reequest call + * Overall request call * - * @param string $type get, post, put, delete: if not set or invalid throw error - * @param string $url The URL being requested, - * including domain and protocol - * @param array $headers [default=[]] Headers to be used in the request - * @param string|null $params [default=null] Optional url parameters for post/put requests - * @return array{code:string,content:string} + * @param string $type get, post, pathc, put, delete: + * if not set or invalid throw error + * @param string $url The URL being requested, + * including domain and protocol + * @param null|array> $headers [default=[]] Headers to be used in the request + * @param null|array $query [default=null] Optinal query parameters + * @param null|string|array $body [default=null] Data body, converted to JSON + * @return array{code:string,headers:array>,content:string} */ - private function curlRequest(string $type, string $url, array $headers = [], ?string $params = null): array - { + private function curlRequest( + string $type, + string $url, + null|array $headers = [], + null|array $query = null, + null|string|array $body = null + ): array { + $this->url = $this->buildQuery($url, $query); + $this->headers = $this->convertHeaders($this->buildHeaders($headers)); if (!in_array($type, self::VALID_REQUEST_TYPES)) { throw new RuntimeException( json_encode([ @@ -133,38 +445,47 @@ class Curl implements Interface\RequestsInterface 'type' => 'InvalidRequestType', 'message' => 'Invalid request type set: ' . $type, 'context' => [ - 'url' => $url, 'type' => $type, + 'url' => $this->url, + 'headers' => $this->headers, ], ]) ?: '', 0, ); } // init curl handle - $handle = $this->handleCurleInit($url); + $handle = $this->handleCurleInit($this->url); // set the standard curl options - $this->setCurlOptions($handle, $headers); + $this->setCurlOptions($handle, $this->headers); // for post we set POST option if ($type == "post") { curl_setopt($handle, CURLOPT_POST, true); } elseif (in_array($type, self::CUSTOM_REQUESTS)) { curl_setopt($handle, CURLOPT_CUSTOMREQUEST, strtoupper($type)); } - if (in_array($type, self::HAVE_POST_FIELDS) && !empty($params)) { - curl_setopt($handle, CURLOPT_POSTFIELDS, $params); + // set body data if not null, will send empty [] for empty data + if (in_array($type, self::HAVE_POST_FIELDS) && $body !== null) { + curl_setopt($handle, CURLOPT_POSTFIELDS, $this->convertPayloadData($body)); } + // reset all headers before we start the call + $this->received_headers = []; // run curl execute $http_result = $this->handleCurlExec($handle); + // for debug + // print "CURLINFO_HEADER_OUT:
" . curl_getinfo($handle, CURLINFO_HEADER_OUT) . "
"; // get response code and bail on not authorized $http_response = $this->handleCurlResponse($http_result, $handle); + // close handler + $this->handleCurlClose($handle); // return response and result return [ 'code' => (string)$http_response, + 'headers' => $this->received_headers, 'content' => (string)$http_result ]; } - // MARK: curl request helpers + // MARK: curl init /** * Handel curl init and errors @@ -193,6 +514,8 @@ class Curl implements Interface\RequestsInterface ); } + // MARK: set curl options and header collector + /** * set the default curl options * @@ -204,8 +527,10 @@ class Curl implements Interface\RequestsInterface */ private function setCurlOptions(\CurlHandle $handle, array $headers): void { - if (!empty($this->auth_ident)) { - curl_setopt($handle, CURLOPT_USERPWD, $this->auth_ident); + // for not Basic auth, basic auth sets its own header + if (!empty($this->config['auth_type']) && !empty($this->config['auth_userpwd'])) { + curl_setopt($handle, CURLOPT_HTTPAUTH, $this->config['auth_type']); + curl_setopt($handle, CURLOPT_USERPWD, $this->config['auth_userpwd']); } if ($headers !== []) { curl_setopt($handle, CURLOPT_HTTPHEADER, $headers); @@ -214,6 +539,43 @@ class Curl implements Interface\RequestsInterface curl_setopt($handle, CURLOPT_RETURNTRANSFER, true); // for debug only curl_setopt($handle, CURLINFO_HEADER_OUT, true); + // curl_setopt($handle, CURLOPT_HEADER, true); + // collect the current request headers + curl_setopt($handle, CURLOPT_HEADERFUNCTION, [$this, 'collectCurlHttpHeaders']); + // if any timeout <1 + $timeout_requires_no_signal = false; + // if we have a timeout signal + if (!empty($this->config['timeout'])) { + $timeout_requires_no_signal |= $this->config['timeout'] < 1; + curl_setopt($handle, CURLOPT_TIMEOUT_MS, $this->config['timeout']); + } + if (!empty($this->config['connection_timeout'])) { + $timeout_requires_no_signal |= $this->config['connection_timeout'] < 1; + curl_setopt($handle, CURLOPT_CONNECTTIMEOUT_MS, $this->config['connection_timeout']); + } + if ($timeout_requires_no_signal && strtoupper(substr(PHP_OS, 0, 3)) !== 'WIN') { + curl_setopt($handle, CURLOPT_NOSIGNAL, true); + } + } + + /** + * Collect HTTP headers + * They will be reset before each call + * + * @param \CurlHandle $curl current curl handle + * @param string $header header string to parse + * @return int size of current line of header + */ + private function collectCurlHttpHeaders(\CurlHandle $curl, string $header): int + { + $len = strlen($header); + $header = explode(':', $header, 2); + if (count($header) < 2) { + // ignore invalid headers + return $len; + } + $this->received_headers[strtolower(trim($header[0]))][] = trim($header[1]); + return $len; } // MARK: Curl Exception handler @@ -271,7 +633,7 @@ class Curl implements Interface\RequestsInterface ); } - // MARK: curl response hanlder + // MARK: curl response handler /** * Handle curl response and not auth 401 errors @@ -286,7 +648,7 @@ class Curl implements Interface\RequestsInterface ): string { $http_response = curl_getinfo($handle, CURLINFO_RESPONSE_CODE); if ( - !$this->exception_on_not_authorized || + empty($this->config['exception_on_not_authorized']) || $http_response !== self::HTTP_NOT_AUTHORIZED ) { return (string)$http_response; @@ -317,127 +679,247 @@ class Curl implements Interface\RequestsInterface ); } + /** + * close the current curl handle + * + * @param \CurlHandle $handle + * @return void + */ + private function handleCurlClose(\CurlHandle $handle): void + { + curl_close($handle); + } + // ********************************************************************* // MARK: PUBLIC METHODS // ********************************************************************* - // MARK: request methods - /** - * Makes an request to the target url via curl: GET - * Returns result as string (json) + * Convert an array with header strings like "foo: bar" to the interface + * needed "foo" => "bar" type + * Skips entries that are already in key => value type, by checking if the + * key is a not a number * - * @param string $url The URL being requested, - * including domain and protocol - * @param array $headers [default=[]] Headers to be used in the request - * @param null|string|array $query [default=null] String to pass on as GET, - * if array will be converted - * @return array{code:string,content:string} Result code and content as array, content is json + * @param array $headers + * @return array + * @throws \UnexpectedValueException on duplicate header key */ - public function requestGet(string $url, array $headers = [], null|string|array $query = null): array + public function prepareHeaders(array $headers): array { - return $this->curlRequest("get", $this->convertQuery($url, $query), $headers); + $return_headers = []; + foreach ($headers as $header_key => $header) { + // skip if header key is not numeric + if (!is_numeric($header_key)) { + $return_headers[$header_key] = $header; + continue; + } + list($_key, $_value) = explode(':', $header); + if (array_key_exists($_key, $return_headers)) { + // raise exception if key already exists + throw new \UnexpectedValueException( + json_encode([ + 'status' => 'ERROR', + 'code' => 'C004', + 'type' => 'DuplicatedArrayKey', + 'message' => 'Key already exists in the headers', + 'context' => [ + 'key' => $_key, + 'headers' => $headers, + 'return_headers' => $return_headers, + ], + ]) ?: '', + 1 + ); + } + $return_headers[$_key] = $_value; + } + return $return_headers; + } + + // MARK: get class vars + + /** + * get the config array with all settings + * + * @return array all current config settings + */ + public function getConfig(): array + { + return $this->config; } /** - * Makes an request to the target url via curl: POST - * Returns result as string (json) + * Return the full url as it was sent * - * @param string $url The URL being requested, - * including domain and protocol - * @param string|array $payload Data to pass on as POST - * @param array $headers [default=[]] Headers to be used in the request - * @param null|string|array $query [default=null] Optinal query parameters, array will be converted - * @return array{code:string,content:string} Result code and content as array, content is json + * @return string url sent */ - public function requestPost( - string $url, - string|array $payload, - array $headers = [], - null|string|array $query = null - ): array { - return $this->curlRequest( - "post", - $this->convertQuery($url, $query), - $headers, - $this->convertPayloadData($payload) - ); + public function getUrlSent(): string + { + return $this->url; } /** - * Makes an request to the target url via curl: PUT - * Returns result as string (json) + * get the parsed url * - * @param string $url The URL being requested, - * including domain and protocol - * @param string|array $payload String to pass on as POST - * @param array $headers [default=[]] Headers to be used in the request - * @param null|string|array $query [default=null] Optinal query parameters, array will be converted - * @return array{code:string,content:string} Result code and content as array, content is json + * @return array{scheme?:string,user?:string,host?:string,port?:string,path?:string,query?:string,fragment?:string,pass?:string} */ - public function requestPut( - string $url, - string|array $payload, - array $headers = [], - null|string|array $query = null - ): array { - return $this->curlRequest( - "put", - $this->convertQuery($url, $query), - $headers, - $this->convertPayloadData($payload) - ); + public function getUrlParsedSent(): array + { + return $this->parsed_url; } /** - * Makes an request to the target url via curl: PATCH - * Returns result as string (json) + * Return the full headers as they where sent * - * @param string $url The URL being requested, - * including domain and protocol - * @param string|array $payload String to pass on as POST - * @param array $headers [default=[]] Headers to be used in the request - * @param null|string|array $query [default=null] Optinal query parameters, array will be converted - * @return array{code:string,content:string} Result code and content as array, content is json + * @return array */ - public function requestPatch( - string $url, - string|array $payload, - array $headers = [], - null|string|array $query = null - ): array { - return $this->curlRequest( - "patch", - $this->convertQuery($url, $query), - $headers, - $this->convertPayloadData($payload) - ); + public function getHeadersSent(): array + { + return $this->headers; + } + + // MARK: set/remove for global headers + + /** + * set, add or overwrite header + * On default this will overwrite header, and not set + * + * @param array> $header + * @param bool $add [default=false] if set will add header to existing value + * @return void + */ + public function setHeaders(array $header, bool $add = false): void + { + foreach ($header as $key => $value) { + // check header previously set + if (isset($this->headers_named[strtolower($key)])) { + $header_key = $this->headers_named[strtolower($key)]; + if ($add) { + // for this add we always add array on the right side + if (!is_array($value)) { + $value = (array)$value; + } + // if not array, rewrite entry to array + if (!is_array($this->config['headers'][$header_key])) { + $this->config['headers'][$header_key] = [ + $this->config['headers'][$header_key] + ]; + } + $this->config['headers'][$header_key] = array_merge( + $this->config['headers'][$header_key], + $value + ); + } else { + $this->config['headers'][$header_key] = $value; + } + } else { + $this->headers_named[strtolower($key)] = $key; + $this->config['headers'][$key] = $value; + } + } } /** - * Makes an request to the target url via curl: DELETE - * Returns result as string (json) - * Note that DELETE payload is optional + * remove header entry + * if key is only set then match only key, if both are set both sides must match * - * @param string $url The URL being requested, - * including domain and protocol - * @param null|string|array $payload [default=null] Data to pass on as POST - * @param array $headers [default=[]] Headers to be used in the request - * @param null|string|array $query [default=null] String to pass on as GET, - * if array will be converted - * @return array{code:string,content:string} Result code and content as array, content is json + * @param array $remove_headers + * @return void */ - public function requestDelete( - string $url, - null|string|array $payload = null, - array $headers = [], - null|string|array $query = null - ): array { + public function removeHeaders(array $remove_headers): void + { + foreach ($remove_headers as $key => $value) { + if (!isset($this->headers_named[strtolower($key)])) { + continue; + } + $header_key = $this->headers_named[strtolower($key)]; + if (!isset($this->config['headers'][$header_key])) { + continue; + } + // full remove + if ( + empty($value) || + ( + ( + // array both sides = equal + // string both sides = equal + (is_array($value) && is_array($this->config['headers'][$header_key])) || + (is_string($value) && is_string($this->config['headers'][$header_key])) + ) && + $value == $this->config['headers'][$header_key] + ) + ) { + unset($this->config['headers'][$header_key]); + unset($this->headers_named[$header_key]); + } elseif ( + // string value, array keys = in + // or both array and not a full match in the one before + (is_string($value) || is_array($value)) && + is_array($this->config['headers'][$header_key]) + ) { + // part remove of key, value must be array + if (!is_array($value)) { + $value = [$value]; + } + $this->config['headers'][$header_key] = array_diff( + $this->config['headers'][$header_key], + $value + ); + } + } + } + + // MARK: update/set base url + + /** + * Update or set the base url set + * if empty will unset the base url + * + * @param string $base_uri + * @return void + */ + public function setBaseUri(string $base_uri): void + { + $this->config['base_uri'] = $base_uri; + $this->parsed_base_uri = []; + if (!empty($base_uri)) { + if (($parsed_base_uri = $this->parseUrl($base_uri)) !== false) { + $this->parsed_base_uri = $parsed_base_uri; + } + } + } + + // MARK: main public call interface + + /** + * combined set call for any type of request with options type parameters + * + * phpcs:disable Generic.Files.LineLength + * @param string $type + * @param string $url + * @param array{headers?:null|array>,query?:null|array,body?:null|string|array} $options + * @return array{code:string,headers:array>,content:string} Result code, headers and content as array, content is json + * @throws \UnexpectedValueException on missing body data when body data is needed + * phpcs:enable Generic.Files.LineLength + */ + public function request(string $type, string $url, array $options = []): array + { + // can have + // - headers + // - query + // depending on type, must have (post/put/patch), optional for (delete) + // - body + $type = strtolower($type); + // check if we need a payload data set, set empty on not set + if (in_array($type, self::MANDATORY_POST_FIELDS) && !isset($options['body'])) { + $options['body'] = []; + } return $this->curlRequest( - "delete", - $this->convertQuery($url, $query), - $headers, - $payload !== null ? $this->convertPayloadData($payload) : null + $type, + $url, + $options['headers'] ?? [], + $options['query'] ?? null, + $options['body'] ?? null ); } } diff --git a/www/lib/CoreLibs/UrlRequests/CurlTrait.php b/www/lib/CoreLibs/UrlRequests/CurlTrait.php new file mode 100644 index 00000000..e42e2a3c --- /dev/null +++ b/www/lib/CoreLibs/UrlRequests/CurlTrait.php @@ -0,0 +1,147 @@ +>,query?:null|string|array,body?:null|string|array} $options Request options + * @return array{code:string,headers:array>,content:string} Result code, headers and content as array, content is json + * @throws \UnexpectedValueException on missing body data when body data is needed + */ + abstract public function request(string $type, string $url, array $options): array; + + /** + * Makes an request to the target url via curl: GET + * Returns result as string (json) + * + * @param string $url The URL being requested, + * including domain and protocol + * @param array{headers?:null|array>,query?:null|array,body?:null|string|array} $options Options to set + * @return array{code:string,headers:array>,content:string} Result code, headers and content as array, content is json + */ + public function get(string $url, array $options): array + { + return $this->request( + "get", + $url, + [ + "headers" => $options['headers'] ?? [], + "query" => $options['query'] ?? null, + ], + ); + } + + /** + * Makes an request to the target url via curl: POST + * Returns result as string (json) + * + * @param string $url The URL being requested, + * including domain and protocol + * @param array{headers?:null|array>,query?:null|array,body?:null|string|array} $options Options to set + * @return array{code:string,headers:array>,content:string} Result code, headers and content as array, content is json + */ + public function post(string $url, array $options): array + { + return $this->request( + "post", + $url, + [ + "headers" => $options['headers'] ?? [], + "query" => $options['query'] ?? null, + "body" => $options['body'] ?? null, + ], + ); + } + + /** + * Makes an request to the target url via curl: PUT + * Returns result as string (json) + * + * @param string $url The URL being requested, + * including domain and protocol + * @param array{headers?:null|array>,query?:null|array,body?:null|string|array} $options Options to set + * @return array{code:string,headers:array>,content:string} Result code, headers and content as array, content is json + */ + public function put(string $url, array $options): array + { + return $this->request( + "put", + $url, + [ + "headers" => $options['headers'] ?? [], + "query" => $options['query'] ?? null, + "body" => $options['body'] ?? null, + ], + ); + } + + /** + * Makes an request to the target url via curl: PATCH + * Returns result as string (json) + * + * @param string $url The URL being requested, + * including domain and protocol + * @param array{headers?:null|array>,query?:null|array,body?:null|string|array} $options Options to set + * @return array{code:string,headers:array>,content:string} Result code, headers and content as array, content is json + */ + public function patch(string $url, array $options): array + { + return $this->request( + "patch", + $url, + [ + "headers" => $options['headers'] ?? [], + "query" => $options['query'] ?? null, + "body" => $options['body'] ?? null, + ], + ); + } + + /** + * Makes an request to the target url via curl: DELETE + * Returns result as string (json) + * Note that DELETE body is optional + * + * @param string $url The URL being requested, + * including domain and protocol + * @param array{headers?:null|array>,query?:null|array,body?:null|string|array} $options Options to set + * @return array{code:string,headers:array>,content:string} Result code, headers and content as array, content is json + */ + public function delete(string $url, array $options): array + { + return $this->request( + "delete", + $url, + [ + "headers" => $options['headers'] ?? [], + "query" => $options['query'] ?? null, + "body" => $options['body'] ?? null, + ], + ); + } +} + +// __END__ diff --git a/www/lib/CoreLibs/UrlRequests/Interface/RequestInterface.php b/www/lib/CoreLibs/UrlRequests/Interface/RequestInterface.php new file mode 100644 index 00000000..e59b469c --- /dev/null +++ b/www/lib/CoreLibs/UrlRequests/Interface/RequestInterface.php @@ -0,0 +1,95 @@ + "bar" type + * Skips entries that are already in key => value type, by checking if the + * key is a not a number + * + * @param array $headers + * @return array + * @throws \UnexpectedValueException on duplicate header key + */ + public function prepareHeaders(array $headers): array; + + /** + * get the config array with all settings + * + * @return array all current config settings + */ + public function getConfig(): array; + + /** + * Return the full url as it was sent + * + * @return string url sent + */ + public function getUrlSent(): string; + + /** + * get the parsed url + * + * @return array{scheme?:string,user?:string,host?:string,port?:string,path?:string,query?:string,fragment?:string,pass?:string} + */ + public function getUrlParsedSent(): array; + + /** + * Return the full headers as they where sent + * + * @return array + */ + public function getHeadersSent(): array; + + /** + * set, add or overwrite header + * On default this will overwrite header, and not set + * + * @param array> $header + * @param bool $add [default=false] if set will add header to existing value + * @return void + */ + public function setHeaders(array $header, bool $add = false): void; + + /** + * remove header entry + * if key is only set then match only key, if both are set both sides must match + * + * @param array $remove_headers + * @return void + */ + public function removeHeaders(array $remove_headers): void; + + /** + * Update the base url set, if empty will unset the base url + * + * @param string $base_uri + * @return void + */ + public function setBaseUri(string $base_uri): void; + + /** + * combined set call for any type of request with options type parameters + * + * phpcs:disable Generic.Files.LineLength + * @param string $type + * @param string $url + * @param array{headers?:null|array>,query?:null|array,body?:null|string|array} $options + * @return array{code:string,headers:array>,content:string} Result code, headers and content as array, content is json + * @throws \UnexpectedValueException on missing body data when body data is needed + * phpcs:enable Generic.Files.LineLength + */ + public function request(string $type, string $url, array $options = []): array; +} + +// __END__ diff --git a/www/lib/CoreLibs/UrlRequests/Interface/RequestsInterface.php b/www/lib/CoreLibs/UrlRequests/Interface/RequestsInterface.php deleted file mode 100644 index ed527edc..00000000 --- a/www/lib/CoreLibs/UrlRequests/Interface/RequestsInterface.php +++ /dev/null @@ -1,84 +0,0 @@ - $headers [default=[]] Headers to be used in the request - * @param null|string|array $query [default=null] String to pass on as GET, - * if array will be converted - * @return array{code:string,content:string} Result code and content as array, content is json - */ - public function requestGet(string $url, array $headers = [], null|string|array $query = null): array; - - /** - * Makes an request to the target url via curl: POST - * Returns result as string (json) - * - * @param string $url The URL being requested, - * including domain and protocol - * @param string|array $payload Data to pass on as POST - * @param array $headers [default=[]] Headers to be used in the request - * @param null|string|array $query [default=null] Optinal query parameters, array will be converted - * @return array{code:string,content:string} Result code and content as array, content is json - */ - public function requestPost( - string $url, - string|array $payload, - array $headers = [], - null|string|array $query = null - ): array; - - /** - * Makes an request to the target url via curl: PUT - * Returns result as string (json) - * - * @param string $url The URL being requested, - * including domain and protocol - * @param string|array $payload Data to pass on as POST - * @param array $headers [default=[]] Headers to be used in the request - * @param null|string|array $query [default=null] Optinal query parameters, array will be converted - * @return array{code:string,content:string} Result code and content as array, content is json - */ - public function requestPut( - string $url, - string|array $payload, - array $headers = [], - null|string|array $query = null - ): array; - - /** - * Makes an request to the target url via curl: DELETE - * Returns result as string (json) - * Note that DELETE payload is optional - * - * @param string $url The URL being requested, - * including domain and protocol - * @param null|string|array $payload [default=null] Data to pass on as POST - * @param array $headers [default=[]] Headers to be used in the request - * @param null|string|array $query [default=null] String to pass on as GET, - * if array will be converted - * @return array{code:string,content:string} Result code and content as array, content is json - */ - public function requestDelete( - string $url, - null|string|array $payload = null, - array $headers = [], - null|string|array $query = null - ): array; -} - -// __END__