- First working version of ulmus-api package; at working state only

This commit is contained in:
Dave M. 2023-10-26 17:22:52 +00:00
parent 0f9495e07a
commit 7e69a09e7f
27 changed files with 1406 additions and 101 deletions

View File

@ -15,7 +15,7 @@
"repositories": [
{
"type": "vcs",
"url": "https://github.com/mcNdave/ulmus.git"
"url": "https://git.mcnd.ca/mcNdave/ulmus.git"
}
],
"autoload": {

81
src/Adapter/Rest.php Normal file
View File

@ -0,0 +1,81 @@
<?php
namespace Ulmus\Api\Adapter;
use Ulmus\Adapter\AdapterInterface;
use Ulmus\Api\ApiRepository;
use Ulmus\Api\Common\AuthenticationEnum;
use Ulmus\Api\Common\ContentTypeEnum;
use Ulmus\Api\RequestBuilder;
use Ulmus\Api\Transport\CurlAuthorization;
use Ulmus\Api\Transport\CurlClient;
use Ulmus\Ulmus;
class Rest implements AdapterInterface
{
protected AuthenticationEnum $auth;
protected string $username;
protected string $password;
protected string $token;
protected array $headers = [];
protected array $options = [];
public readonly string $url;
public function connect(): object
{
return new CurlClient($this->headers, $this->options, ( new CurlAuthorization())->fromAuthenticationEnum($this->auth, $this->username ?? false, $this->password ?? false, $this->token ?? false) );
}
public function buildDataSourceName(): string
{
return "https";
}
public function setup(array $configuration): void
{
$this->auth = AuthenticationEnum::from($configuration['auth'] ?? AuthenticationEnum::Basic->value);
$this->url = rtrim($configuration['url'], '/');
foreach([ 'username', 'password', 'token', 'headers', 'options' ] as $conf) {
if ($configuration[$conf] ?? false) {
$this->$conf = $configuration[$conf];
}
}
}
public function writableValue(mixed $value) : mixed
{
switch (true) {
case $value instanceof \UnitEnum:
return Ulmus::convertEnum($value);
case is_object($value):
return Ulmus::convertObject($value);
case is_array($value):
return json_encode($value);
case is_bool($value):
return (int) $value;
}
return $value;
}
public function repositoryClass(): string
{
return ApiRepository::class;
}
public function queryBuilderClass(): string
{
return RequestBuilder::class;
}
}

View File

@ -1,10 +0,0 @@
<?php
namespace Ulmus\Api;
class Api
{
public function __construct() {
}
}

124
src/ApiRepository.php Normal file
View File

@ -0,0 +1,124 @@
<?php
namespace Ulmus\Api;
use Psr\Http\Message\ResponseInterface;
use Ulmus\Api\Attribute\Obj\Api\ApiAction;
use Ulmus\Api\Transport\CurlClient;
use Ulmus\EntityCollection;
class ApiRepository extends \Ulmus\Repository
{
const DEFAULT_ALIAS = "";
public CurlClient $client;
protected array $urlParameters = [];
public ResponseInterface $lastResponse;
public function __construct(string $entity, string $alias = self::DEFAULT_ALIAS, ConnectionAdapter $adapter = null)
{
parent::__construct($entity, $alias, $adapter);
$this->client = $adapter->adapter()->connect();
}
public function loadOne(): ?object
{
# Must manager URL here !
$attribute = $this->getApiAttribute(Attribute\Obj\Api\Read::class);
$this->lastResponse = $response = $this->client->request($attribute->method, $this->buildRequestUrl($attribute->url));
$dataset = $this->callApiResponseCallback($response, $attribute);
return $this->instanciateEntity()->fromArray($dataset);
}
public function loadAll() : EntityCollection
{
$attribute = $this->getApiAttribute(Attribute\Obj\Api\Collection::class);
$this->lastResponse = $response = $this->client->request($attribute->method, $this->buildRequestUrl($attribute->url));
$collection = $this->callApiResponseCallback($response, $attribute);
return $this->instanciateEntityCollection($collection);
}
public function save(object|array $entity, ?array $fieldsAndValue = null, bool $replace = false): bool
{
$attribute = $this->getApiAttribute(Attribute\Obj\Api\Create::class);
$this->lastResponse = $response = $this->client->request($attribute->method, $this->buildRequestUrl($attribute->url), $entity->entityLoadedDataset);
$ret = $this->callApiResponseCallback($response, $attribute);
$entity->fromArray($ret);
return true;
}
public function bindUrl(string $parameter, mixed $value) : self
{
$this->urlParameters[$parameter] = $value;
return $this;
}
protected function buildRequestUrl(string $uri) :string
{
$uri = $this->prepareUri($uri, $this->urlParameters);
return $this->adapter->adapter()->url . '/' . ltrim($uri, '/');
}
protected function prepareUri(string $route, array $arguments) : string
{
if ( preg_match_all('~{(.*?)}~si', $route, $matches, PREG_SET_ORDER) ) {
$search = [];
foreach($matches as $item) {
$default = null;
$variable = $item[1];
# Handles default
if (strpos($variable, "=") !== false) {
list($variable, $default) = explode('=', $item[1]);
}
if ( array_key_exists($variable, $arguments) ) {
$value = $arguments[ $variable ];
unset($arguments[ $variable ]);
}
else {
if ($default ?? false) {
$value = $default;
}
elseif ( strpos($route, "[{$matches[0][0]}]") !== false && $this->enforceExistingArguments) {
throw new \RuntimeException(sprintf("Error while preparing route %s : could not match variable '%s' into given arguments ( %s ) from %s::%s", $route, $variable, json_encode($arguments), $routeParam['class'], $routeParam['classMethod']));
}
}
$search[$item[0]] = rawurlencode($value ?? "");
}
$route = str_replace(array_keys($search), array_values($search), $route);
}
return $route;
}
public function getApiAttribute(string $type) : object
{
return $this->entityClass::resolveEntity()->getAttributeImplementing($type);
}
protected function callApiResponseCallback(ResponseInterface $response, ApiAction $attribute) : mixed
{
return call_user_func_array($this->adapter->apiResponseCallback(), func_get_args());
}
}

17
src/Attribute/Obj/Api.php Normal file
View File

@ -0,0 +1,17 @@
<?php
namespace Ulmus\Api\Attribute\Obj;
use Ulmus\Attribute\Obj\AdapterAttributeInterface;
#[\Attribute(\Attribute::TARGET_CLASS)]
class Api implements AdapterAttributeInterface {
public function __construct(
public string $adapter,
) {}
public function adapter() : false|string
{
return $this->adapter ?: false;
}
}

View File

@ -0,0 +1,14 @@
<?php
namespace Ulmus\Api\Attribute\Obj\Api;
use Ulmus\Api\Common\MethodEnum;
#[\Attribute]
abstract class ApiAction
{
public function __construct(
public string $url,
public MethodEnum $method,
) {}
}

View File

@ -0,0 +1,13 @@
<?php
namespace Ulmus\Api\Attribute\Obj\Api;
use Ulmus\Api\Common\MethodEnum;
#[\Attribute(\Attribute::TARGET_CLASS)]
class Collection extends ApiAction {
public function __construct(
public string $url,
public MethodEnum $method = MethodEnum::Get,
) {}
}

View File

@ -0,0 +1,13 @@
<?php
namespace Ulmus\Api\Attribute\Obj\Api;
use Ulmus\Api\Common\MethodEnum;
#[\Attribute(\Attribute::TARGET_CLASS)]
class Create extends ApiAction {
public function __construct(
public string $url,
public MethodEnum $method = MethodEnum::Post,
) {}
}

View File

@ -0,0 +1,13 @@
<?php
namespace Ulmus\Api\Attribute\Obj\Api;
use Ulmus\Api\Common\MethodEnum;
#[\Attribute(\Attribute::TARGET_CLASS)]
class Delete extends ApiAction {
public function __construct(
public string $url,
public MethodEnum $method = MethodEnum::Delete,
) {}
}

View File

@ -0,0 +1,13 @@
<?php
namespace Ulmus\Api\Attribute\Obj\Api;
use Ulmus\Api\Common\MethodEnum;
#[\Attribute(\Attribute::TARGET_CLASS)]
class Read extends ApiAction {
public function __construct(
public string $url,
public MethodEnum $method = MethodEnum::Get,
) {}
}

View File

@ -0,0 +1,13 @@
<?php
namespace Ulmus\Api\Attribute\Obj\Api;
use Ulmus\Api\Common\MethodEnum;
#[\Attribute(\Attribute::TARGET_CLASS)]
class Update extends ApiAction {
public function __construct(
public string $url,
public MethodEnum $method = MethodEnum::Patch,
) {}
}

View File

@ -0,0 +1,10 @@
<?php
namespace Ulmus\Api\Attribute\Property;
#[\Attribute(\Attribute::TARGET_PROPERTY)]
class Entity {
public function __construct(
) {}
}

View File

@ -0,0 +1,18 @@
<?php
namespace Ulmus\Api\Common;
enum AuthenticationEnum : string
{
case Basic = "basic";
case Bearer = "bearer";
case Custom = "custom";
case Digest = "digest";
case Key = "key";
case Ntlm = "ntlm";
case Negotiate = "negotiate";
# case OAuth = "oauth";
# case OpenID = "openid";
# case OpenAPI = "openapi";
case Token = "token";
}

View File

@ -0,0 +1,10 @@
<?php
namespace Ulmus\Api\Common;
enum ContentTypeEnum : string
{
case Json = "application/json";
case Multipart = "multipart/form-data";
case UrlEncoded = "application/x-www-form-urlencoded";
}

View File

@ -0,0 +1,75 @@
<?php
namespace Ulmus\Api\Common;
enum HttpHeaderEnum: string
{
# Some specially handled headers
case A_IM = "A-IM";
case Accept_Ch = "Accept-CH";
case Accept_Ch_Lifetime = "Accept-CH-Lifetime";
case Critical_Ch = "Critical-CH";
case Content_Dpr = 'Content-DPR';
case Content_Md5 = 'Content-MD5';
case Correlation_Id = "Correlation_ID";
case Dpr = "DPR";
case Ect = "ECT";
case ETag = "ETag";
case Expect_CT = "Expect-CT";
case Http2_Settings = "HTTP2-Settings";
case IM = "IM";
case Last_Event_Id = "Last-Event-ID";
case Nel = "NEL";
case P3p = "P3P";
case Rtt = "RTT";
case Sec_Gpc = "Sec-GPC";
case Set_Ch_Ua = "Set-CH-UA";
case Set_Ch_Ua_Arch = "Set-CH-UA-Arch";
case Set_Ch_Ua_Bitness = "Set-CH-UA-Bitness";
case Set_Ch_Ua_Full_Version = "Set-CH-UA-Full-Version";
case Set_Ch_Ua_Full_Version_List = "Set-CH-UA-Full-Version-List";
case Set_Ch_Ua_Mobile = "Set-CH-UA-Mobile";
case Set_Ch_Ua_Model = "Set-CH-UA-Model";
case Set_Ch_Ua_Platform = "Set-CH-UA-Platform";
case Set_Ch_Ua_Platform_Version = "Set-CH-UA-Platform-Version";
case Sourcemap = "SourceMap";
case Te = "TE";
case Www_Authenticate = "WWW-Authenticate";
case X_Att_DeviceId = "X-ATT-DeviceId";
case X_Correlation_Id = "X-Correlation-ID";
case X_Dns_Prefetch_Control = "X-DNS-Prefetch-Control";
case X_UA_Compatible = "X-UA-Compatible";
case X_UIDH = "X-UIDH";
case X_Request_Id = "X_Request_ID";
case X_Webkit_Csp = "X-WebKit-CSP";
case X_Xss_Protection = "X-XSS-Protection";
public static function normalizeHeaderKey(string $from) : string
{
$key = str_replace(['-'], ['_'], strtolower($from));
foreach(HttpHeaderEnum::cases() as $case) {
if (strtolower($case->name) === $key) {
return $case->value;
}
}
return implode('-', array_map('ucfirst', explode('-', $from)));
}
public static function normalizeHeaderArray(array $headers) : array
{
return array_combine(array_map([ static::class, 'normalizeHeaderKey' ], array_keys($headers)), array_values($headers));
}
public static function compileHeaders(array $associativeArray) : array
{
$output = [];
foreach($associativeArray as $key => $value) {
$output[] = sprintf("%s: %s", static::normalizeHeaderKey($key), implode(', ', (array) $value));
}
return $output;
}
}

69
src/ConnectionAdapter.php Normal file
View File

@ -0,0 +1,69 @@
<?php
namespace Ulmus\Api;
use Ulmus\Adapter\AdapterInterface;
class ConnectionAdapter extends \Ulmus\ConnectionAdapter
{
public string $name;
public array $configuration;
protected AdapterInterface $adapter;
private array $connection;
protected mixed $apiResponseCallback;
public function resolveConfiguration(): void
{
$connection = $this->configuration['connections'][$this->name] ?? [];
if (false !== ($adapterName = $connection['adapter'] ?? false)) {
$this->adapter = $this->instanciateAdapter($adapterName);
} else {
$this->adapter = new Adapter\Rest();
}
$this->adapter->setup($connection);
}
public function getConfiguration(): array
{
return $this->configuration['connections'][$this->name];
}
/**
* Connect the adapter
* @return self
*/
public function connect(): self
{
$this->connection = $this->adapter->connect();
return $this;
}
public function connector(): object
{
return isset($this->connection) ? $this->connection : $this->connect()->connection;
}
public function adapter(): AdapterInterface
{
return $this->adapter;
}
protected function instanciateAdapter($name): AdapterInterface
{
$class = substr($name, 0, 2) === "\\" ? $name : "\\Ulmus\\Api\\Adapter\\$name";
return new $class();
}
public function apiResponseCallback(callable $callback = null) : callable|null
{
return $callback ? $this->apiResponseCallback = $callback : $this->apiResponseCallback ?? fn($resp) => $resp->render();
}
}

183
src/Request/Stream.php Normal file
View File

@ -0,0 +1,183 @@
<?php
namespace Ulmus\Api\Request;
use Psr\Http\Message\StreamInterface;
class Stream implements StreamInterface
{
protected mixed $stream;
public function __toString()
{
return $this->getContents();
}
public static function fromTemp(string $content = "", string $mode = 'wb+') : StreamInterface
{
return static::fromMemory($content, $mode, "php://temp");
}
public static function fromMemory(string $content = "", string $mode = 'wb+', string $destination = "php://memory") : StreamInterface
{
$obj = new static();
$obj->create($destination, $mode);
$obj->write($content);
return $obj;
}
public function create(mixed $stream, string $mode) : void
{
if (is_string($stream)) {
try {
$stream = fopen($stream, $mode);
} catch (\Throwable $e) {
throw new \RuntimeException(sprintf('Invalid stream reference provided: %s', $error->getMessage()), 0, $e);
}
}
if ( is_resource($stream) || $stream instanceof StreamInterface ) {
$this->attach($stream);
}
else {
throw new \RuntimeException("Given stream must be a resource, a StreamInterface or a string pointing to a stream destination for fopen.");
}
}
public function attach(mixed $stream) : void
{
$this->stream = $stream;
}
public function detach() : mixed
{
$stream = $this->stream;
unset($this->stream);
return $stream;
}
public function getSize(): null|int
{
if (false !== $result = fstat($this->stream)){
return $result['size'];
}
return null;
}
public function tell() : int
{
if ( false === $result = ftell($this->stream) ) {
throw new \RuntimeException("An error occured while trying to fetch current position pointer in a stream.");
}
return $result;
}
public function eof() : bool
{
return ! $this->stream || feof($this->stream);
}
public function isSeekable() : bool
{
return stream_get_meta_data($this->stream)['seekable'] ?? false;
}
public function seek($offset, $whence = SEEK_SET) : void
{
if (! $this->isSeekable()) {
throw new \InvalidArgumentException("An error occured while trying to seek an unseekable stream.");
}
if ( 0 !== fseek($this->stream, $offset, $whence) ) {
throw new \RuntimeException("An error occured while trying to seek a position in a stream.");
}
}
public function rewind() : void
{
$this->seek(0);
}
public function isWritable() : bool
{
return $this->hasMode('wxca+');
}
public function write($string) : false|int
{
if (! $this->isWritable()) {
throw new \UnexpectedValueException("An error occured while trying to seek an unseekable stream.");
}
$result = fwrite($this->stream, $string);
if ( $result === false ) {
throw new \UnexpectedValueException("fwrite() failed to write into stream.");
}
return $result;
}
public function close(): void
{
if (! $this->stream) {
return;
}
$stream = $this->detach();
fclose($stream);
}
public function isReadable(): bool
{
return $this->hasMode('r+');
}
public function read(int $length): string
{
if (! $this->isReadable()) {
throw new \UnexpectedValueException("An error occured while trying to read a stream which was not readable.");
}
elseif ( false === $result = fread($this->stream, $length) )
throw new \RuntimeException("Unreadable stream given; please check your stream's mode");{
}
return $result;
}
public function getContents(): string
{
if (! $this->isReadable()) {
throw new \UnexpectedValueException("Unreadable stream given; please check your stream's mode");
}
if ( $this->isSeekable() ) {
$this->rewind();
}
if (false === $result = stream_get_contents($this->stream)) {
throw new \RuntimeException("Failure happened on stream_get_contents() from stream. ");
}
return $result;
}
public function getMetadata(string $key = null) : mixed
{
$metadata = stream_get_meta_data($this->stream);
return is_null($key) ? $metadata : $metadata[$key] ?? null;
}
protected function hasMode(string $search) : bool
{
$meta = stream_get_meta_data($this->stream);
$mode = $meta['mode'];
return strpbrk($mode, 'acwx+') !== false;
}
}

View File

@ -0,0 +1,8 @@
<?php
namespace Ulmus\Api\Request;
interface StreamOutputInterface
{
public function render() : mixed;
}

164
src/RequestBuilder.php Normal file
View File

@ -0,0 +1,164 @@
<?php
namespace Ulmus\Api;
use Ulmus;
class RequestBuilder implements Ulmus\Query\QueryBuilderInterface
{
public Ulmus\Query\QueryBuilderInterface $parent;
public array $parameters = [];
public array $values = [];
public string $whereConditionOperator = Ulmus\Query\Where::CONDITION_AND;
public string $havingConditionOperator = Ulmus\Query\Where::CONDITION_AND;
protected int $parameterIndex = 0;
protected array $queryStack = [];
public function values(array $dataset) : self
{
$this->addValues(array_values($dataset));
return $this;
}
public function where(/* stringable*/ $field, $value, string $operator = Ulmus\Query\Where::OPERATOR_EQUAL, string $condition = Ulmus\Query\Where::CONDITION_AND, bool $not = false) : self
{
# Empty IN case
if ( [] === $value ) {
return $this;
}
if ( $this->where ?? false ) {
$where = $this->where;
}
elseif ( null === ( $where = $this->getFragment(RequestBuilder\Filter::class) ) ) {
$this->where = $where = new RequestBuilder\Filter($this);
$this->push($where);
}
$this->whereConditionOperator = $operator;
$where->add($field, $value, $operator, $condition, $not);
return $this;
}
public function notWhere($field, $value, string $operator = Ulmus\Query\Where::CONDITION_AND) : self
{
return $this->where($field, $value, $operator, true);
}
public function limit(int $value) : self
{
if ( null === $limit = $this->getFragment(Ulmus\Query\Limit::class) ) {
$limit = new Query\Limit();
$this->push($limit);
}
$limit->set($value);
return $this;
}
public function offset(int $value) : self
{
if ( null === $offset = $this->getFragment(Ulmus\Query\Offset::class) ) {
$offset = new Ulmus\Query\Offset();
$this->push($offset);
}
$offset->set($value);
return $this;
}
public function orderBy(string $field, ? string $direction = null) : self
{
return $this;
}
public function push(Ulmus\Query\Fragment $queryFragment) : self
{
$this->queryStack[] = $queryFragment;
return $this;
}
public function pull(Ulmus\Query\Fragment $queryFragment) : self
{
return array_shift($this->queryStack);
}
public function render(bool $skipToken = false) : array
{
$stack = [];
foreach($this->queryStack as $fragment) {
$stack = array_merge($stack, (array) $fragment->render($skipToken));
}
return $stack;
}
public function reset() : void
{
$this->parameters = $this->values = $this->queryStack = [];
$this->whereConditionOperator = Ulmus\Query\Where::CONDITION_AND;
$this->parameterIndex = 0;
unset($this->where);
}
public function getFragment(string $class, int $index = 0) : ? Ulmus\Query\Fragment
{
foreach($this->queryStack as $item) {
if ( get_class($item) === $class ) {
if ( $index-- === 0 ) {
return $item;
}
}
}
return null;
}
public function removeFragment(Ulmus\Query\Fragment $fragment) : void
{
foreach($this->queryStack as $key => $item) {
if ( $item === $fragment ) {
unset($this->queryStack[$key]);
}
}
}
public function __toString() : string
{
return $this->render();
}
public function addParameter($value, string $key = null) : string
{
if ( $this->parent ?? false ) {
return $this->parent->addParameter($value, $key);
}
if ( $key === null ) {
$key = ":p" . $this->parameterIndex++;
}
$this->parameters[$key] = $value;
return $key;
}
public function addValues(array $values) : void
{
$this->values = $values;
}
}

View File

@ -0,0 +1,52 @@
<?php
namespace Ulmus\Api\Response;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\StreamInterface;
use Ulmus\Api\Common\HttpHeaderEnum;
use Ulmus\Api\Request\Stream;
use Ulmus\Api\Request\StreamOutputInterface;
class JsonResponse extends Response implements StreamOutputInterface
{
public const DEFAULT_JSON_ENCODING_FLAGS = JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_TAG | JSON_UNESCAPED_SLASHES;
public const DEFAULT_JSON_DECODING_FLAGS = JSON_THROW_ON_ERROR;
public int $jsonEncodingOptions;
public function __construct(null|StreamInterface $stream = null, int $statusCode = 200, array $headers = [], $jsonEncodingOptions = JsonResponse::DEFAULT_JSON_ENCODING_FLAGS)
{
parent::__construct($stream, $statusCode, HttpHeaderEnum::normalizeHeaderArray($headers) + [ 'Content-Type' => 'application/json' ]);
$this->jsonEncodingOptions = $jsonEncodingOptions;
}
public static function fromContent(mixed $content, int $encodingOptions = self::DEFAULT_JSON_ENCODING_FLAGS) : StreamInterface
{
if ( false === $json = json_encode($content, $encodingOptions) ) {
throw new \InvalidArgumentException(sprintf('Json encoding failed with message `%s`.', json_last_error_msg()));
}
return static::fromJsonEncoded($json);
}
public static function fromJsonEncoded(string $json) : StreamInterface
{
$obj = static::fromTemp($json);
$obj->rewind();
return $obj;
}
public static function fromResponse(ResponseInterface $response) : static
{
return new static($response->getBody(), $response->getStatusCode(), $response->getHeaders());
}
public function render(): mixed
{
return json_decode($this->getBody()->getContents(), true, 1024, static::DEFAULT_JSON_DECODING_FLAGS);
}
}

126
src/Response/Message.php Normal file
View File

@ -0,0 +1,126 @@
<?php
namespace Ulmus\Api\Response;
use Psr\Http\Message\MessageInterface;
use Psr\Http\Message\StreamInterface;
use Ulmus\Api\Common\HttpHeaderEnum;
class Message implements MessageInterface
{
protected array $headers = [];
protected string $protocolVersion = '1.1';
protected StreamInterface $stream;
public function getProtocolVersion() : string
{
return $this->protocolVersion;
}
public function withProtocolVersion(string $version) : MessageInterface
{
return (clone $this)->setProtocolVersion($version);
}
protected function setProtocolVersion(string $version) : MessageInterface
{
$this->protocolVersion = $version;
return $this;
}
public function getHeaders() : array
{
return $this->headers;
}
public function getHeadersLines() : array
{
$output = [];
foreach($this->headers as $key => $header) {
$output[] = $header->getHeaderLine($key);
}
return $output;
}
public function hasHeader(string $name) : bool
{
return isset($this->headers[HttpHeaderEnum::normalizeHeaderKey($name)]);
}
public function getHeader(string $name) : array
{
$name = HttpHeaderEnum::normalizeHeaderKey($name);
if ( ! $this->hasHeader($name) ) {
return [];
}
return $this->headers[$name];
}
public function getHeaderLine(string $name) : string
{
$name = HttpHeaderEnum::normalizeHeaderKey($name);
if ( ! $this->hasHeader($name) ) {
return "";
}
return sprintf("%s: %s", $name, implode(', ', $this->getHeader($name)));
}
public function withHeader(string $name, mixed $value): MessageInterface
{
return (clone $this)->setHeaders(array_merge_recursive($this->headers, [ HttpHeaderEnum::normalizeHeaderKey($name) => $value ]));
}
public function withAddedHeader(string $name, mixed $value) : MessageInterface
{
return (clone $this)->setHeaders([ HttpHeaderEnum::normalizeHeaderKey($name) => $value ] + $this->headers);
}
public function withoutHeader(string $name) : MessageInterface
{
return (clone $this)->unsetHeader($name);
}
protected function setHeaders(array $headers) : MessageInterface
{
$this->headers = HttpHeaderEnum::normalizeHeaderArray($headers);
foreach($this->headers as & $item) {
$item = is_array($item) ? $item : [ $item ];
}
return $this;
}
protected function unsetHeader(string $name) : MessageInterface
{
unset($this->headers[HttpHeaderEnum::normalizeHeaderKey($name)]);
return $this;
}
public function getBody() : StreamInterface
{
return $this->stream;
}
public function withBody(StreamInterface $body): MessageInterface
{
return (clone $this)->setBody($body);
}
protected function setBody(StreamInterface $stream) : MessageInterface
{
$this->stream = $stream;
return $this;
}
}

113
src/Response/Response.php Normal file
View File

@ -0,0 +1,113 @@
<?php
namespace Ulmus\Api\Response;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\StreamInterface;
use Ulmus\Api\Request\Stream;
class Response extends Message implements ResponseInterface
{
protected int $code;
protected string $message;
public function __construct(null|StreamInterface $stream = null, int $statusCode = 200, array $headers = [])
{
$this->setStatusCode($statusCode);
$this->stream = $stream ?? new Stream("php://memory", "wb+");
$this->setHeaders($headers);
}
public function getStatusCode() /* : int */
{
return $this->code;
}
public function withStatus(int $code, null|string $reasonPhrase = null)/* : ResponseInterface*/
{
return (clone $this)->setStatusCode($code, $reasonPhrase);
}
public function getReasonPhrase() /* : string */
{
return $this->message;
}
private function setStatusCode(int $code, null|string $message = null) : static
{
$this->code = $code;
$this->message = $message ?: ( static::HTTP_CODE_MESSAGE[$code] ?? "" );
return $this;
}
const HTTP_CODE_MESSAGE = [
100 => "Continue",
101 => "Switching Protocols",
102 => "Processing",
103 => "Early Hints",
200 => "OK",
201 => "Created",
202 => "Accepted",
203 => "Non-Authoritative Information",
204 => "No Content",
205 => "Reset Content",
206 => "Partial Content",
207 => "Multi-Status",
208 => "Already Reported",
226 => "IM Used",
300 => "Multiple Choices",
301 => "Moved Permanently",
302 => "Found",
303 => "See Other",
304 => "Not Modified",
305 => "Use Proxy",
306 => "Switch Proxy",
307 => "Temporary Redirect",
308 => "Permanent Redirect",
400 => "Bad Request",
401 => "Unauthorized",
402 => "Payment Required",
403 => "Forbidden",
404 => "Not Found",
405 => "Method Not Allowed",
406 => "Not Acceptable",
407 => "Proxy Authentication Required",
408 => "Request Timeout",
409 => "Conflict",
410 => "Gone",
411 => "Length Required",
412 => "Precondition Failed",
413 => "Content Too Large",
414 => "URI Too Long",
415 => "Unsupported Media Type",
416 => "Range Not Satisfiable",
417 => "Expectation Failed",
418 => "I'm a teapot",
421 => "Misdirected Request",
422 => "Unprocessable Content",
423 => "Locked",
424 => "Failed Dependency",
425 => "Too Early",
426 => "Upgrade Required",
428 => "Precondition Required",
429 => "Too Many Requests",
431 => "Request Header Fields Too Large",
444 => "Connection Closed Without Response",
451 => "Unavailable For Legal Reasons",
499 => "Client Closed Request",
500 => "Internal Server Error",
501 => "Not Implemented",
502 => "Bad Gateway",
503 => "Service Unavailable",
504 => "Gateway Timeout",
505 => "HTTP Version Not Supported",
506 => "Variant Also Negotiates",
507 => "Insufficient Storage",
508 => "Loop Detected",
510 => "Not Extended",
511 => "Network Authentication Required",
599 => "Network Connect Timeout Error",
];
}

View File

@ -0,0 +1,95 @@
<?php
namespace Ulmus\Api\Transport;
use Ulmus\Api\Common\AuthenticationEnum;
class CurlAuthorization
{
public array $headers = [];
public array $options = [];
public function fromAuthenticationEnum(AuthenticationEnum $auth, string|false $username, #[\SensitiveParameter] string|false $password, #[\SensitiveParameter] string|false $token,) : static
{
switch($auth) {
case AuthenticationEnum::Bearer:
$this->bearer($token);
break;
case AuthenticationEnum::Key:
$this->key($token);
break;
case AuthenticationEnum::Token:
$this->token($token);
break;
case AuthenticationEnum::Basic:
$this->basic($username, $password);
break;
case AuthenticationEnum::NTLM:
$this->ntlm($username, $password);
break;
case AuthenticationEnum::Negotiate:
$this->ntlm($username, $password);
break;
}
return $this;
}
public function bearer(#[\SensitiveParameter] string $token) : void
{
$this->options[CURLOPT_XOAUTH2_BEARER] = $token;
}
public function apikey(#[\SensitiveParameter] string $token) : void
{
$this->customToken('apikey', $token);
}
public function key(#[\SensitiveParameter] string $token) : void
{
$this->customToken('key', $token);
}
public function token(#[\SensitiveParameter] string $token) : void
{
$this->customToken('token', $token);
}
public function customToken(string $key, #[\SensitiveParameter] string $token) : void
{
$this->headers['Authorization'] = "$key {$token}";
}
public function basic(string $username, #[\SensitiveParameter] string $password) : void
{
$this->userpass(CURLAUTH_BASIC, $username, $password);
}
public function digest(string $username, #[\SensitiveParameter] string $password) : void
{
$this->userpass(CURLAUTH_DIGEST, $username, $password);
}
public function ntlm(string $username, #[\SensitiveParameter] string $password) : void
{
$this->userpass(CURLAUTH_NTLM, $username, $password);
}
public function negotiate(string $username, #[\SensitiveParameter] string $password) : void
{
$this->userpass(CURLAUTH_NEGOTIATE, $username, $password);
}
protected function userpass(int $authType, string $username, #[\SensitiveParameter] $password) : void
{
$this->options[CURLOPT_HTTPAUTH] = $authType;
$this->options[CURLOPT_USERNAME] = $username;
$this->options[CURLOPT_PASSWORD] = $password;
}
}

View File

@ -0,0 +1,14 @@
<?php
namespace Ulmus\Api\Transport;
use Ulmus\Api\Common\ContentTypeEnum;
use Ulmus\Api\Common\MethodEnum;
class CurlClient extends CurlTransport
{
# Handle file uploading here
}

View File

@ -1,10 +0,0 @@
<?php
namespace Ulmus\Api\Transport;
class CurlConnexion extends CurlTransport
{
public function __construct(
public $headers = [],
) { }
}

View File

@ -9,31 +9,31 @@ class CurlErrors
CURLE_UNSUPPORTED_PROTOCOL => "The URL you passed to libcurl used a protocol that this libcurl does not support. The support might be a compile-time option that you didn't use, it can be a misspelled protocol string or just a protocol libcurl has no code for.",
CURLE_FAILED_INIT => "Very early initialization code failed. This is likely to be an internal error or problem, or a resource problem where something fundamental couldn't get done at init time.",
CURLE_URL_MALFORMAT => "The URL was not properly formatted.",
CURLE_NOT_BUILT_IN => "A requested feature, protocol or option was not found built-in in this libcurl due to a build-time decision. This means that a feature or option was not enabled or explicitly disabled when libcurl was built and in order to get it to function you have to get a rebuilt libcurl.",
#CURLE_NOT_BUILT_IN => "A requested feature, protocol or option was not found built-in in this libcurl due to a build-time decision. This means that a feature or option was not enabled or explicitly disabled when libcurl was built and in order to get it to function you have to get a rebuilt libcurl.",
CURLE_COULDNT_RESOLVE_PROXY => "Couldn't resolve proxy. The given proxy host could not be resolved.",
CURLE_COULDNT_RESOLVE_HOST => "Couldn't resolve host. The given remote host was not resolved.",
CURLE_COULDNT_CONNECT => "Failed to connect() to host or proxy.",
CURLE_FTP_WEIRD_SERVER_REPLY => "The server sent data libcurl couldn't parse. This error code was known as as CURLE_FTP_WEIRD_SERVER_REPLY before 7.51.0.",
CURLE_REMOTE_ACCESS_DENIED => "We were denied access to the resource given in the URL. For FTP, this occurs while trying to change to the remote directory.",
CURLE_FTP_ACCEPT_FAILED => "While waiting for the server to connect back when an active FTP session is used, an error code was sent over the control connection or similar.",
#CURLE_REMOTE_ACCESS_DENIED => "We were denied access to the resource given in the URL. For FTP, this occurs while trying to change to the remote directory.",
#CURLE_FTP_ACCEPT_FAILED => "While waiting for the server to connect back when an active FTP session is used, an error code was sent over the control connection or similar.",
CURLE_FTP_WEIRD_PASS_REPLY => "After having sent the FTP password to the server, libcurl expects a proper reply. This error code indicates that an unexpected code was returned.",
CURLE_FTP_ACCEPT_TIMEOUT => "During an active FTP session while waiting for the server to connect, the CURLOPT_ACCEPTTIMEOUT_MS (or the internal default) timeout expired.",
#CURLE_FTP_ACCEPT_TIMEOUT => "During an active FTP session while waiting for the server to connect, the CURLOPT_ACCEPTTIMEOUT_MS (or the internal default) timeout expired.",
CURLE_FTP_WEIRD_PASV_REPLY => "libcurl failed to get a sensible result back from the server as a response to either a PASV or a EPSV command. The server is flawed.",
CURLE_FTP_WEIRD_227_FORMAT => "FTP servers return a 227-line as a response to a PASV command. If libcurl fails to parse that line, this return code is passed back.",
CURLE_FTP_CANT_GET_HOST => "An internal failure to lookup the host used for the new connection.",
CURLE_FTP_COULDNT_SET_TYPE => "Received an error when trying to set the transfer mode to binary or ASCII.",
#CURLE_FTP_COULDNT_SET_TYPE => "Received an error when trying to set the transfer mode to binary or ASCII.",
CURLE_PARTIAL_FILE => "A file transfer was shorter or larger than expected. This happens when the server first reports an expected transfer size, and then delivers data that doesn't match the previously given size.",
CURLE_FTP_COULDNT_RETR_FILE => "This was either a weird reply to a 'RETR' command or a zero byte transfer complete.",
CURLE_QUOTE_ERROR => "When sending custom \"QUOTE\" commands to the remote server, one of the commands returned an error code that was 400 or higher (for FTP) or otherwise indicated unsuccessful completion of the command.",
#CURLE_QUOTE_ERROR => "When sending custom \"QUOTE\" commands to the remote server, one of the commands returned an error code that was 400 or higher (for FTP) or otherwise indicated unsuccessful completion of the command.",
CURLE_HTTP_RETURNED_ERROR => "This is returned if CURLOPT_FAILONERROR is set TRUE and the HTTP server returns an error code that is >= 400.",
CURLE_WRITE_ERROR => "An error occurred when writing received data to a local file, or an error was returned to libcurl from a write callback.",
CURLE_UPLOAD_FAILED => "Failed starting the upload. For FTP, the server typically denied the STOR command. The error buffer usually contains the server's explanation for this.",
#CURLE_UPLOAD_FAILED => "Failed starting the upload. For FTP, the server typically denied the STOR command. The error buffer usually contains the server's explanation for this.",
CURLE_READ_ERROR => "There was a problem reading a local file or an error returned by the read callback.",
CURLE_OUT_OF_MEMORY => "A memory allocation request failed. This is serious badness and things are severely screwed up if this ever occurs.",
CURLE_OPERATION_TIMEDOUT => "Operation timeout. The specified time-out period was reached according to the conditions.",
CURLE_FTP_PORT_FAILED => "The FTP PORT command returned error. This mostly happens when you haven't specified a good enough address for libcurl to use. See CURLOPT_FTPPORT.",
CURLE_FTP_COULDNT_USE_REST => "The FTP REST command returned error. This should never happen if the server is sane.",
CURLE_RANGE_ERROR => "The server does not support or accept range requests.",
#CURLE_RANGE_ERROR => "The server does not support or accept range requests.",
CURLE_HTTP_POST_ERROR => "This is an odd error that mainly occurs due to internal confusion.",
CURLE_SSL_CONNECT_ERROR => "LDAP cannot bind. LDAP bind operation failed.",
CURLE_BAD_DOWNLOAD_RESUME => "The download could not be resumed because the specified offset was out of the file boundary.",
@ -43,11 +43,11 @@ class CurlErrors
CURLE_FUNCTION_NOT_FOUND => "Function not found. A required zlib function was not found.",
CURLE_ABORTED_BY_CALLBACK => "Aborted by callback. A callback returned \"abort\" to libcurl.",
CURLE_BAD_FUNCTION_ARGUMENT => "A function was called with a bad parameter.",
CURLE_INTERFACE_FAILED => "Interface error. A specified outgoing interface could not be used. Set which interface to use for outgoing connections' source IP address with CURLOPT_INTERFACE.",
#CURLE_INTERFACE_FAILED => "Interface error. A specified outgoing interface could not be used. Set which interface to use for outgoing connections' source IP address with CURLOPT_INTERFACE.",
CURLE_TOO_MANY_REDIRECTS => "Too many redirects. When following redirects, libcurl hit the maximum amount. Set your limit with CURLOPT_MAXREDIRS.",
CURLE_UNKNOWN_OPTION => "An option passed to libcurl is not recognized/known. Refer to the appropriate documentation. This is most likely a problem in the program that uses libcurl. The error buffer might contain more specific information about which exact option it concerns.",
#CURLE_UNKNOWN_OPTION => "An option passed to libcurl is not recognized/known. Refer to the appropriate documentation. This is most likely a problem in the program that uses libcurl. The error buffer might contain more specific information about which exact option it concerns.",
CURLE_TELNET_OPTION_SYNTAX => "A telnet option string was Illegally formatted.",
CURLE_PEER_FAILED_VERIFICATION => "",
#CURLE_PEER_FAILED_VERIFICATION => "",
CURLE_GOT_NOTHING => "Nothing was returned from the server, and under the circumstances, getting nothing is considered an error.",
CURLE_SSL_ENGINE_NOTFOUND => "The specified crypto engine wasn't found.",
CURLE_SSL_ENGINE_SETFAILED => "Failed setting the selected SSL crypto engine as default!",
@ -59,38 +59,38 @@ class CurlErrors
CURLE_BAD_CONTENT_ENCODING => "Unrecognized transfer encoding.",
CURLE_LDAP_INVALID_URL => "Invalid LDAP URL.",
CURLE_FILESIZE_EXCEEDED => "Maximum file size exceeded.",
CURLE_USE_SSL_FAILED => "Requested FTP SSL level failed.",
CURLE_SEND_FAIL_REWIND => "When doing a send operation curl had to rewind the data to retransmit, but the rewinding operation failed.",
CURLE_SSL_ENGINE_INITFAILED => "Initiating the SSL Engine failed.",
CURLE_LOGIN_DENIED => "The remote server denied curl to login.",
CURLE_TFTP_NOTFOUND => "File not found on TFTP server.",
CURLE_TFTP_PERM => "Permission problem on TFTP server.",
CURLE_REMOTE_DISK_FULL => "Out of disk space on the server.",
CURLE_TFTP_ILLEGAL => "Illegal TFTP operation.",
CURLE_TFTP_UNKNOWNID => "Unknown TFTP transfer ID.",
CURLE_REMOTE_FILE_EXISTS => "File already exists and will not be overwritten.",
CURLE_TFTP_NOSUCHUSER => "This error should never be returned by a properly functioning TFTP server.",
CURLE_CONV_FAILED => "Character conversion failed.",
CURLE_CONV_REQD => "Caller must register conversion callbacks.",
#CURLE_USE_SSL_FAILED => "Requested FTP SSL level failed.",
#CURLE_SEND_FAIL_REWIND => "When doing a send operation curl had to rewind the data to retransmit, but the rewinding operation failed.",
#CURLE_SSL_ENGINE_INITFAILED => "Initiating the SSL Engine failed.",
#CURLE_LOGIN_DENIED => "The remote server denied curl to login.",
#CURLE_TFTP_NOTFOUND => "File not found on TFTP server.",
#CURLE_TFTP_PERM => "Permission problem on TFTP server.",
#CURLE_REMOTE_DISK_FULL => "Out of disk space on the server.",
#CURLE_TFTP_ILLEGAL => "Illegal TFTP operation.",
#CURLE_TFTP_UNKNOWNID => "Unknown TFTP transfer ID.",
#CURLE_REMOTE_FILE_EXISTS => "File already exists and will not be overwritten.",
#CURLE_TFTP_NOSUCHUSER => "This error should never be returned by a properly functioning TFTP server.",
#CURLE_CONV_FAILED => "Character conversion failed.",
#CURLE_CONV_REQD => "Caller must register conversion callbacks.",
CURLE_SSL_CACERT_BADFILE => "Problem with reading the SSL CA cert (path? access rights?)",
CURLE_REMOTE_FILE_NOT_FOUND => "The resource referenced in the URL does not exist.",
#CURLE_REMOTE_FILE_NOT_FOUND => "The resource referenced in the URL does not exist.",
CURLE_SSH => "An unspecified error occurred during the SSH session.",
CURLE_SSL_SHUTDOWN_FAILED => "Failed to shut down the SSL connection.",
CURLE_AGAIN => "Socket is not ready for send/recv wait till it's ready and try again. This return code is only returned from curl_easy_recv and curl_easy_send.",
CURLE_SSL_CRL_BADFILE => "Failed to load CRL file.",
CURLE_SSL_ISSUER_ERROR => "Issuer check failed.",
CURLE_FTP_PRET_FAILED => "The FTP server does not understand the PRET command at all or does not support the given argument. Be careful when using CURLOPT_CUSTOMREQUEST, a custom LIST command will be sent with PRET CMD before PASV as well.",
CURLE_RTSP_CSEQ_ERROR => "Mismatch of RTSP CSeq numbers. ",
CURLE_RTSP_SESSION_ERROR => "Mismatch of RTSP Session Identifiers. ",
CURLE_FTP_BAD_FILE_LIST => "Unable to parse FTP file list (during FTP wildcard downloading). ",
CURLE_CHUNK_FAILED => "Chunk callback reported error. ",
CURLE_NO_CONNECTION_AVAILABLE => "(For internal use only, will never be returned by libcurl) No connection available, the session will be queued.",
#CURLE_SSL_SHUTDOWN_FAILED => "Failed to shut down the SSL connection.",
#CURLE_AGAIN => "Socket is not ready for send/recv wait till it's ready and try again. This return code is only returned from curl_easy_recv and curl_easy_send.",
#CURLE_SSL_CRL_BADFILE => "Failed to load CRL file.",
#CURLE_SSL_ISSUER_ERROR => "Issuer check failed.",
#CURLE_FTP_PRET_FAILED => "The FTP server does not understand the PRET command at all or does not support the given argument. Be careful when using CURLOPT_CUSTOMREQUEST, a custom LIST command will be sent with PRET CMD before PASV as well.",
#CURLE_RTSP_CSEQ_ERROR => "Mismatch of RTSP CSeq numbers. ",
#CURLE_RTSP_SESSION_ERROR => "Mismatch of RTSP Session Identifiers. ",
#CURLE_FTP_BAD_FILE_LIST => "Unable to parse FTP file list (during FTP wildcard downloading). ",
#CURLE_CHUNK_FAILED => "Chunk callback reported error. ",
#CURLE_NO_CONNECTION_AVAILABLE => "(For internal use only, will never be returned by libcurl) No connection available, the session will be queued.",
CURLE_SSL_PINNEDPUBKEYNOTMATCH => "Failed to match the pinned key specified with CURLOPT_PINNEDPUBLICKEY.",
CURLE_SSL_INVALIDCERTSTATUS => "Status returned failure when asked with CURLOPT_SSL_VERIFYSTATUS.",
CURLE_HTTP2_STREAM => "Stream error in the HTTP/2 framing layer.",
CURLE_RECURSIVE_API_CALL => "An API function was called from inside a callback.",
CURLE_AUTH_ERROR => "An authentication function returned an error.",
CURLE_HTTP3 => "A problem was detected in the HTTP/3 layer. This is somewhat generic and can be one out of several problems, see the error buffer for details.",
CURLE_QUIC_CONNECT_ERROR => "QUIC connection error. This error may be caused by an SSL library error. QUIC is the protocol used for HTTP/3 transfers.",
#CURLE_SSL_INVALIDCERTSTATUS => "Status returned failure when asked with CURLOPT_SSL_VERIFYSTATUS.",
#CURLE_HTTP2_STREAM => "Stream error in the HTTP/2 framing layer.",
#CURLE_RECURSIVE_API_CALL => "An API function was called from inside a callback.",
#CURLE_AUTH_ERROR => "An authentication function returned an error.",
#CURLE_HTTP3 => "A problem was detected in the HTTP/3 layer. This is somewhat generic and can be one out of several problems, see the error buffer for details.",
#CURLE_QUIC_CONNECT_ERROR => "QUIC connection error. This error may be caused by an SSL library error. QUIC is the protocol used for HTTP/3 transfers.",
];
}

View File

@ -2,78 +2,165 @@
namespace Ulmus\Api\Transport;
use Ulmus\Api\Common\MethodEnum;
use Ulmus\Api\Common\{ ContentTypeEnum, MethodEnum, HttpHeaderEnum };
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Ulmus\Api\Response\JsonResponse;
use Ulmus\Api\Response\Response;
use Ulmus\Api\Request\Stream;
use Ulmus\Attribute\Obj\Method;
abstract class CurlTransport {
public $timeout = 1;
public int $timeout = 5;
public function post(string $url, array $data) : mixed
{
return $this->request(MethodEnum::Post, $url, $data);
public int $maximumRedirections = 10;
public function __construct(
public array $headers = [],
public array $curlOptions = [],
public null|CurlAuthorization $authorization = null,
public ContentTypeEnum $contentType = ContentTypeEnum::Json,
# Matching Guzzle's great user-agent syntax
public string $userAgent = "ulmus-api/1.0 curl/{curl} php/{php}",
) {
$this->userAgent = str_replace([ '{curl}', '{php}', ], [ curl_version()['version'] ?? "???", phpversion() ], $this->userAgent);
}
public function get(string $url, array $data) : mixed
public function post(string $url, mixed $data, array $headers = [], array $curlOptions = []) : mixed
{
return $this->request(MethodEnum::Get, $url, $data);
return $this->request(MethodEnum::Post, $url, $data, $headers, $curlOptions);
}
public function delete(string $url, array $data) : mixed
public function get(string $url, mixed $data, array $headers = [], array $curlOptions = []) : mixed
{
return $this->request(MethodEnum::Delete, $url, $data);
return $this->request(MethodEnum::Get, $url, $data, $headers, $curlOptions);
}
public function patch(string $url, array $data) : mixed
public function delete(string $url, mixed $data, array $headers = [], array $curlOptions = []) : mixed
{
return $this->request(MethodEnum::Patch, $url, $data);
return $this->request(MethodEnum::Delete, $url, $data, $headers, $curlOptions);
}
public function put(string $url, array $data) : mixed
public function patch(string $url, mixed $data, array $headers = [], array $curlOptions = []) : mixed
{
return $this->request(MethodEnum::Put, $url, $data);
return $this->request(MethodEnum::Patch, $url, $data, $headers, $curlOptions);
}
public function request(MethodEnum $method, string $url, array $data) : mixed
public function put(string $url, mixed $data, array $headers = [], array $curlOptions = []) : mixed
{
$ch = curl_init();
return $this->request(MethodEnum::Put, $url, $data, $headers, $curlOptions);
}
$options = [
public function fromRequest(ServerRequestInterface $request) : ResponseInterface
{
return $this->request($request->getMethod(), $request->getUri(), $request->getBody()->getContents(), $request->getHeaders());
}
public function request(MethodEnum $method, string $url, mixed $data = null, array $headers = [], array $options = []) : ResponseInterface
{
$response = new Response();
$headers = HttpHeaderEnum::normalizeHeaderArray($headers + $this->headers);
$options += $this->curlOptions;
$this->applyMethod($method, $data, $headers, $options);
$headers += $this->authorization->headers;
$options += $this->authorization->options;
$options += [
CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => $this->headers,
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT_MS => $this->timeout * 200,
CURLOPT_HTTPHEADER => HttpHeaderEnum::compileHeaders($headers),
CURLOPT_MAXREDIRS => $this->maximumRedirections,
CURLOPT_TIMEOUT_MS => $this->timeout * 1000,
CURLOPT_SSL_VERIFYPEER => false,
CURLOPT_SSL_VERIFYHOST => false,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_HEADERFUNCTION => function($curl, $headerLine) use (& $response) {
if ( strpos($headerLine, ':') ) {
list($key, $value) = array_map('trim', explode(':', $headerLine));
$response = $response->withHeader($key, $value);
}
elseif ( strtoupper(substr($headerLine, 0, 4)) === 'HTTP' ) {
list(,$code, $status) = explode(' ', trim($headerLine), 3);
$response = $response->withStatus($code, $status);
}
return strlen($headerLine);
}
];
switch ($method) {
case MethodEnum::Put:
case MethodEnum::Delete:
$options[CURLOPT_CUSTOMREQUEST] = $method->value;
break;
case MethodEnum::Post:
$options[CURLOPT_POST] = true;
$options[CURLOPT_POSTFIELDS] = http_build_query($data, '', '&');
break;
default:
case MethodEnum::Get:
}
$ch = curl_init();
foreach($options as $opt => $value) {
curl_setopt($ch, $opt, $value);
}
if ( ( false === curl_exec($ch) ) && $this->throwErrors ) {
if ( false === ( $result = curl_exec($ch) ) ) {
$errno = curl_errno($ch);
$errors = array_filter([ curl_error($ch) , CurlErrors::CURL_CODES[$errno] ?? null ]);
throw new CurlException(implode(PHP_EOL, $errors), $errno);
throw new \Exception(implode(PHP_EOL, $errors), $errno);
}
curl_close($ch);
$response = $response->withBody( Stream::fromMemory($result) );
if ( $response->hasHeader('Content-Type') && false !== strpos($response->getHeader('Content-Type')[0], 'json') ) {
$response = JsonResponse::fromResponse($response);
}
return $response;
}
protected function applyMethod(MethodEnum $method, mixed &$data, array &$headers, array &$options) : void
{
switch ($method) {
case MethodEnum::Patch:
case MethodEnum::Put:
case MethodEnum::Delete:
case MethodEnum::Post:
if ($method === MethodEnum::Post) {
$options[CURLOPT_POST] = true;
}
else {
$options[CURLOPT_CUSTOMREQUEST] = $method->value;
}
$this->arrayToPostFields($data, $headers, $options);
break;
case MethodEnum::Get:
default:
}
}
protected function arrayToPostFields(mixed &$data, array &$headers, array &$options) : void
{
$headers["Content-Type"] = $this->contentType->value;
switch($this->contentType) {
case ContentTypeEnum::Json:
$options[CURLOPT_POSTFIELDS] = json_encode($data, JsonResponse::DEFAULT_JSON_ENCODING_FLAGS);
$headers['Accept'] ??= $this->contentType->value;
break;
case ContentTypeEnum::UrlEncoded:
$options[CURLOPT_POSTFIELDS] = http_build_query($data, '', '&');
break;
case ContentTypeEnum::Multipart:
$options[CURLOPT_POSTFIELDS] = $data;
break;
}
}
protected function unindexQueryStringArrays(string $query) : string
{
return preg_replace("/\[[0-9]+\]/simU", "[]", $query);
}
}