diff --git a/composer.json b/composer.json index 0ba25ad..dfb371f 100644 --- a/composer.json +++ b/composer.json @@ -10,6 +10,7 @@ } ], "require": { + "ext-curl": "*", "mcnd/ulmus": "dev-master" }, "repositories": [ diff --git a/src/Adapter/Rest.php b/src/Adapter/Rest.php index b0cdadc..a334228 100644 --- a/src/Adapter/Rest.php +++ b/src/Adapter/Rest.php @@ -47,7 +47,6 @@ class Rest implements AdapterInterface $this->$conf = $configuration[$conf]; } } - } public function writableValue(mixed $value) : mixed diff --git a/src/ApiRepository.php b/src/ApiRepository.php index 6082cb6..2b2b1f6 100644 --- a/src/ApiRepository.php +++ b/src/ApiRepository.php @@ -2,10 +2,18 @@ namespace Ulmus\Api; +use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\StreamInterface; +use Psr\Http\Message\UriInterface; use Ulmus\Api\Attribute\Obj\Api\ApiAction; +use Ulmus\Api\Common\MethodEnum; +use Ulmus\Api\Stream\Stream; +use Ulmus\Api\Request\JsonRequest; +use Ulmus\Api\Request\Request; use Ulmus\Api\Transport\CurlClient; use Ulmus\EntityCollection; +use Ulmus\SearchRequest\SearchRequestInterface; class ApiRepository extends \Ulmus\Repository { @@ -29,29 +37,41 @@ class ApiRepository extends \Ulmus\Repository # Must manager URL here ! $attribute = $this->getApiAttribute(Attribute\Obj\Api\Read::class); - $this->lastResponse = $response = $this->client->request($attribute->method, $this->buildRequestUrl($attribute->url)); + $request = $this->prepareRequest($this->buildRequestUrl($attribute->url), $attribute->method); - $dataset = $this->callApiResponseCallback($response, $attribute); + $request = $this->callApiRequestCallback($request, $attribute); - return $this->instanciateEntity()->fromArray($dataset); + $this->lastResponse = $response = $this->client->fromRequest($request); + + $response = $this->callApiResponseCallback($response, $attribute); + + return $this->instanciateEntity()->fromArray($response->render()); } public function loadAll() : EntityCollection { $attribute = $this->getApiAttribute(Attribute\Obj\Api\Collection::class); - $this->lastResponse = $response = $this->client->request($attribute->method, $this->buildRequestUrl($attribute->url)); + $request = $this->prepareRequest($this->buildRequestUrl($attribute->url), $attribute->method); - $collection = $this->callApiResponseCallback($response, $attribute); + $request = $this->callApiRequestCallback($request, $attribute); - return $this->instanciateEntityCollection($collection); + $this->lastResponse = $response = $this->client->fromRequest($request); + + $response = $this->callApiResponseCallback($response, $attribute); + + return $this->instanciateEntityCollection()->fromArray($response->render()); } 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); + $request = $this->prepareRequest($this->buildRequestUrl($attribute->url), $attribute->method); + + $this->callApiRequestCallback($request, $attribute); + + $this->lastResponse = $response = $this->client->fromRequest($request); $ret = $this->callApiResponseCallback($response, $attribute); @@ -112,13 +132,40 @@ class ApiRepository extends \Ulmus\Repository return $route; } + protected function prepareRequest(string|UriInterface $uri, MethodEnum $method, null|string $body = null, array $headers = []) : RequestInterface + { + return new JsonRequest($uri, $method, $body === null ? Stream::fromTemp() : Stream::fromMemory($body), $headers); + } + public function getApiAttribute(string $type) : object { return $this->entityClass::resolveEntity()->getAttributeImplementing($type); } + protected function callApiRequestCallback(RequestInterface $request, ApiAction $attribute) : mixed + { + return call_user_func_array($this->adapter->apiRequestCallback(), func_get_args()); + } + protected function callApiResponseCallback(ResponseInterface $response, ApiAction $attribute) : mixed { return call_user_func_array($this->adapter->apiResponseCallback(), func_get_args()); } + + public function count(): int + { + return 0; + } + + + public function filterServerRequest(SearchRequestInterface $searchRequest, bool $count = true) : self + { + return $searchRequest->filter($this) + ->wheres($searchRequest->wheres(), \Ulmus\Query\Where::OPERATOR_EQUAL, \Ulmus\Query\Where::CONDITION_AND) + ->likes($searchRequest->likes(), \Ulmus\Query\Where::CONDITION_OR) + ->orders($searchRequest->orders()) + ->groups($searchRequest->groups()) + ->offset($searchRequest->offset()) + ->limit($searchRequest->limit()); + } } \ No newline at end of file diff --git a/src/Response/Message.php b/src/Common/Message.php similarity index 84% rename from src/Response/Message.php rename to src/Common/Message.php index 632e9e5..521de5a 100644 --- a/src/Response/Message.php +++ b/src/Common/Message.php @@ -1,6 +1,6 @@ protocolVersion; } - public function withProtocolVersion(string $version) : MessageInterface + public function withProtocolVersion($version) : MessageInterface { return (clone $this)->setProtocolVersion($version); } @@ -47,12 +47,12 @@ class Message implements MessageInterface return $output; } - public function hasHeader(string $name) : bool + public function hasHeader($name) : bool { return isset($this->headers[HttpHeaderEnum::normalizeHeaderKey($name)]); } - public function getHeader(string $name) : array + public function getHeader($name) : array { $name = HttpHeaderEnum::normalizeHeaderKey($name); @@ -63,7 +63,7 @@ class Message implements MessageInterface return $this->headers[$name]; } - public function getHeaderLine(string $name) : string + public function getHeaderLine($name) : string { $name = HttpHeaderEnum::normalizeHeaderKey($name); @@ -74,17 +74,17 @@ class Message implements MessageInterface return sprintf("%s: %s", $name, implode(', ', $this->getHeader($name))); } - public function withHeader(string $name, mixed $value): MessageInterface + public function withHeader($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 + public function withAddedHeader($name, mixed $value) : MessageInterface { return (clone $this)->setHeaders([ HttpHeaderEnum::normalizeHeaderKey($name) => $value ] + $this->headers); } - public function withoutHeader(string $name) : MessageInterface + public function withoutHeader($name) : MessageInterface { return (clone $this)->unsetHeader($name); } diff --git a/src/Request/StreamOutputInterface.php b/src/Common/StreamOutputInterface.php similarity index 72% rename from src/Request/StreamOutputInterface.php rename to src/Common/StreamOutputInterface.php index d850bbb..25af89f 100644 --- a/src/Request/StreamOutputInterface.php +++ b/src/Common/StreamOutputInterface.php @@ -1,6 +1,6 @@ parse($uri); + } + + public static function from(string|UriInterface $uri = "") : UriInterface + { + if ($uri instanceof UriInterface) { + return $uri; + } + + return new static($uri); + } + + protected function parse(string $uri): void + { + if (false === $parts = parse_url($uri)) { + throw new \InvalidArgumentException(sprintf("Malformed URIs was provided (%s)", $uri)); + } + + $this->setScheme($parts['scheme'] ?? ""); + $this->setUserInfo($parts['user'] ?? null, $parts['pass'] ?? null); + $this->setHost($parts['host'] ?? ""); + $this->setPort($parts['port'] ?? null); + $this->setPath($parts['path'] ?? null); + $this->setQuery($parts['query'] ?? []); + $this->setFragment($parts['fragment'] ?? null); + } + + public function getScheme() + { + return $this->scheme; + } + + public function renderScheme() : string + { + return $this->scheme ? "{$this->scheme}:" : ""; + } + + protected function setScheme(string $scheme) : self + { + $this->scheme = $scheme; + + return $this; + } + + public function getAuthority() : string + { + return implode('', array_filter([ + $this->getHost(), $this->renderUserInfo(), $this->renderPort(), $this->renderFragment() + ])); + } + + public function renderAuthority() : string + { + $authority = $this->getAuthority(); + + return $authority ? "//{$authority}" : $authority; + } + + public function getUserInfo() : string + { + return $this->user . ( $this->password ? ":{$this->password}" : "" ); + } + + public function renderUserInfo() : string + { + return ( $info = $this->getUserInfo() ) ? "{$info}@" : ""; + } + + public function getHost() + { + return $this->host; + } + + protected function setHost(string $host) : static + { + $this->host = $host; + + return $this; + } + + public function getPort() + { + return $this->port; + } + + public function renderPort(array $ignore = self::IGNORE_HTTP_PORT) : string + { + $port = $this->getPort(); + + return $port && ! in_array($port, $ignore) ? ":{$port}" : ""; + } + + public function getPath() + { + $path = implode('/', array_map('rawurlencode', explode('/', $this->path))); + + return str_starts_with($this->path, '/') ? '/' . ltrim($this->path, '/.') : $this->path; + } + + public function renderPath() : string + { + return $this->getPath(); + } + + public function getQuery() + { + return http_build_query($this->query, "", null, PHP_QUERY_RFC3986); + } + + public function renderQuery() : string + { + $query = $this->getQuery(); + + return $query ? "?{$query}" : ""; + } + + public function getFragment() + { + return rawurlencode($this->fragment ? "#{$this->fragment}" : ""); + } + + public function renderFragment() : string + { + return $this->getFragment(); + } + + public function withScheme($scheme) + { + return (clone $this)->setScheme($scheme); + } + + public function withUserInfo($user, $password = null) + { + return (clone $this)->setUserInfo($user, $password); + } + + protected function setUserInfo(null|string $user, null|string $password) : static + { + $this->user = $user; + $this->password = $password; + + return $this; + } + + public function withHost($host) + { + return (clone $this)->setHost($host); + } + + public function withPort($port) + { + return (clone $this)->setPort($port); + } + + protected function setPort(null|int $port) : static + { + $this->port = $port; + + return $this; + } + + public function withPath($path) + { + return (clone $this)->setPath($path); + } + + protected function setPath(null|string $path) : static + { + $this->path = $path; + + return $this; + } + + public function withQuery($query) + { + return $this->setQuery($query); + } + + protected function setQuery(null|array|string $query) : static + { + if (is_string($query)) { + parse_str($query, $parsed); + } + + $this->query = $parsed ?? $query; + + return $this; + } + + public function withFragment($fragment) + { + return (clone $this)->setFragment($fragment); + } + + protected function setFragment(null|string $fragment) : static + { + $this->fragment = $fragment; + + return $this; + } + + public function __toString() + { + return $this->render(); + } + + public function render() : string + { + return implode('', array_filter([ + $this->renderScheme(), $this->renderAuthority(), $this->renderPath(), $this->renderQuery(), $this->renderFragment() + ])); + } +} \ No newline at end of file diff --git a/src/ConnectionAdapter.php b/src/ConnectionAdapter.php index 71f797f..29e0138 100644 --- a/src/ConnectionAdapter.php +++ b/src/ConnectionAdapter.php @@ -16,6 +16,8 @@ class ConnectionAdapter extends \Ulmus\ConnectionAdapter protected mixed $apiResponseCallback; + protected mixed $apiRequestCallback; + public function resolveConfiguration(): void { $connection = $this->configuration['connections'][$this->name] ?? []; @@ -66,4 +68,9 @@ class ConnectionAdapter extends \Ulmus\ConnectionAdapter { return $callback ? $this->apiResponseCallback = $callback : $this->apiResponseCallback ?? fn($resp) => $resp->render(); } + + public function apiRequestCallback(callable $callback = null) : callable|null + { + return $callback ? $this->apiRequestCallback = $callback : $this->apiRequestCallback ?? fn($resp) => $resp->render(); + } } diff --git a/src/Request/JsonRequest.php b/src/Request/JsonRequest.php new file mode 100644 index 0000000..6185fc8 --- /dev/null +++ b/src/Request/JsonRequest.php @@ -0,0 +1,58 @@ + "application/json", 'Accept' => "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 = Stream::fromTemp($json); + $obj->rewind(); + + return $obj; + } + + public static function fromRequest(RequestInterface $request) : static + { + return new static($request->getUri(), MethodEnum::from($request->getMethod()), $request->getBody(), $request->getHeaders()); + } + + public function render(): mixed + { + $content = $this->getBody()->getContents() ?: "[]"; + + return json_decode($content, true, 1024, static::DEFAULT_JSON_DECODING_FLAGS); + } +} \ No newline at end of file diff --git a/src/Request/Request.php b/src/Request/Request.php new file mode 100644 index 0000000..a276aa6 --- /dev/null +++ b/src/Request/Request.php @@ -0,0 +1,104 @@ +method = $method; + $this->uri = Uri::from($uri); + $this->stream = $stream ?? Stream::fromMemory(); + $this->setHeaders($headers ); + } + + public function getRequestTarget(): string + { + if ( isset($this->target) ) { + return $this->target; + } + + $query = $this->uri->getQuery() ?: null; + + $target = $this->uri->getPath() . ( $query ? "?{$query}" : "" ); + + return $target ?: '/'; + } + + public function withRequestTarget($requestTarget) + { + return (clone $this)->setRequestTarget($requestTarget); + } + + public function setRequestTarget(string $target) : static + { + $this->target = $target; + + return $this; + } + + public function getMethod() + { + return $this->method->value; + } + + public function withMethod($method) + { + return (clone $this)->setMethod($method); + } + + protected function setMethod(string $method) : static + { + $this->method = MethodEnum::from($method); + + return $this; + } + + public function getUri() + { + return (string) $this->uri; + } + + public function withUri(UriInterface $uri, $preserveHost = false) + { + return (clone $this)->setUri($uri, $preserveHost); + } + + protected function setUri(UriInterface|string $uri, bool $preserveHost = false) : static + { + $this->uri = Uri::from($uri); + + if ( ! $preserveHost || ! $this->hasHeader('Host') ) { + $this->headers['Host'] = [ $this->renderHostHeader() ]; + } + + return $this; + } + + protected function renderHostHeader(UriInterface $fromUri = null) : string + { + $fromUri ??= $this->uri; + + $port = $fromUri->getPort(); + + return implode('', [ + $fromUri->getHost(), $port && ! in_array($port, Uri::IGNORE_HTTP_PORT) ? ":{$port}" : "" + ]); + } +} \ No newline at end of file diff --git a/src/RequestBuilder.php b/src/RequestBuilder.php index e789f7b..df9ebd7 100644 --- a/src/RequestBuilder.php +++ b/src/RequestBuilder.php @@ -56,13 +56,13 @@ class RequestBuilder implements Ulmus\Query\QueryBuilderInterface public function limit(int $value) : self { - if ( null === $limit = $this->getFragment(Ulmus\Query\Limit::class) ) { + /*if ( null === $limit = $this->getFragment(Ulmus\Query\Limit::class) ) { $limit = new Query\Limit(); $this->push($limit); } $limit->set($value); - +*/ return $this; } @@ -128,7 +128,7 @@ class RequestBuilder implements Ulmus\Query\QueryBuilderInterface return null; } - public function removeFragment(Ulmus\Query\Fragment $fragment) : void + public function removeFragment(\Ulmus\Query\Fragment|array|\Stringable|string $fragment) : void { foreach($this->queryStack as $key => $item) { if ( $item === $fragment ) { diff --git a/src/Response/JsonResponse.php b/src/Response/JsonResponse.php index cf401c3..cb9f660 100644 --- a/src/Response/JsonResponse.php +++ b/src/Response/JsonResponse.php @@ -5,8 +5,8 @@ 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; +use Ulmus\Api\Stream\Stream; +use Ulmus\Api\Common\StreamOutputInterface; class JsonResponse extends Response implements StreamOutputInterface { @@ -34,7 +34,7 @@ class JsonResponse extends Response implements StreamOutputInterface public static function fromJsonEncoded(string $json) : StreamInterface { - $obj = static::fromTemp($json); + $obj = Stream::fromTemp($json); $obj->rewind(); return $obj; diff --git a/src/Response/Response.php b/src/Response/Response.php index c867024..035e5af 100644 --- a/src/Response/Response.php +++ b/src/Response/Response.php @@ -4,7 +4,8 @@ namespace Ulmus\Api\Response; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\StreamInterface; -use Ulmus\Api\Request\Stream; +use Ulmus\Api\Common\{ Message }; +use Ulmus\Api\Stream\Stream; class Response extends Message implements ResponseInterface { @@ -15,7 +16,7 @@ class Response extends Message implements ResponseInterface public function __construct(null|StreamInterface $stream = null, int $statusCode = 200, array $headers = []) { $this->setStatusCode($statusCode); - $this->stream = $stream ?? new Stream("php://memory", "wb+"); + $this->stream = $stream ?? Stream::fromMemory(); $this->setHeaders($headers); } @@ -24,7 +25,7 @@ class Response extends Message implements ResponseInterface return $this->code; } - public function withStatus(int $code, null|string $reasonPhrase = null)/* : ResponseInterface*/ + public function withStatus($code, $reasonPhrase = null)/* : ResponseInterface*/ { return (clone $this)->setStatusCode($code, $reasonPhrase); } diff --git a/src/Stream/JsonStream.php b/src/Stream/JsonStream.php new file mode 100644 index 0000000..dfcacb2 --- /dev/null +++ b/src/Stream/JsonStream.php @@ -0,0 +1,50 @@ +encodingOptions = $encodingOptions; + $this->decodingOptions = $decodingOptions; + } + + 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 = Stream::fromTemp($json); + $obj->rewind(); + + return $obj; + } + + public function render(): mixed + { + return json_decode($this->getContents(), true, $this->depth, $this->decodingOptions); + } +} \ No newline at end of file diff --git a/src/Request/Stream.php b/src/Stream/Stream.php similarity index 96% rename from src/Request/Stream.php rename to src/Stream/Stream.php index 6348be9..d78868d 100644 --- a/src/Request/Stream.php +++ b/src/Stream/Stream.php @@ -1,6 +1,6 @@ hasMode('wxca+'); + return $this->hasMode('acwx+'); } public function write($string) : false|int @@ -137,7 +137,7 @@ class Stream implements StreamInterface return $this->hasMode('r+'); } - public function read(int $length): string + public function read($length): string { if (! $this->isReadable()) { throw new \UnexpectedValueException("An error occured while trying to read a stream which was not readable."); @@ -178,6 +178,6 @@ class Stream implements StreamInterface $meta = stream_get_meta_data($this->stream); $mode = $meta['mode']; - return strpbrk($mode, 'acwx+') !== false; + return strpbrk($mode, $search) !== false; } } \ No newline at end of file diff --git a/src/Transport/CurlAuthorization.php b/src/Transport/CurlAuthorization.php index 622866d..48167ed 100644 --- a/src/Transport/CurlAuthorization.php +++ b/src/Transport/CurlAuthorization.php @@ -29,12 +29,12 @@ class CurlAuthorization $this->basic($username, $password); break; - case AuthenticationEnum::NTLM: + case AuthenticationEnum::Ntlm: $this->ntlm($username, $password); break; case AuthenticationEnum::Negotiate: - $this->ntlm($username, $password); + $this->negotiate($username, $password); break; } diff --git a/src/Transport/CurlTransport.php b/src/Transport/CurlTransport.php index 1061a0e..e2cb9e7 100644 --- a/src/Transport/CurlTransport.php +++ b/src/Transport/CurlTransport.php @@ -3,12 +3,9 @@ namespace Ulmus\Api\Transport; 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; +use Ulmus\Api\Stream\Stream; +use Psr\Http\Message\{ RequestInterface, ResponseInterface }; +use Ulmus\Api\Response\{ Response, JsonResponse }; abstract class CurlTransport { @@ -52,16 +49,16 @@ abstract class CurlTransport { return $this->request(MethodEnum::Put, $url, $data, $headers, $curlOptions); } - public function fromRequest(ServerRequestInterface $request) : ResponseInterface + public function fromRequest(RequestInterface $request) : ResponseInterface { - return $this->request($request->getMethod(), $request->getUri(), $request->getBody()->getContents(), $request->getHeaders()); + return $this->request(MethodEnum::from($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); + $headers = array_merge_recursive(HttpHeaderEnum::normalizeHeaderArray($headers), HttpHeaderEnum::normalizeHeaderArray($this->headers)); $options += $this->curlOptions; @@ -83,6 +80,7 @@ abstract class CurlTransport { 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' ) { @@ -145,7 +143,7 @@ abstract class CurlTransport { switch($this->contentType) { case ContentTypeEnum::Json: - $options[CURLOPT_POSTFIELDS] = json_encode($data, JsonResponse::DEFAULT_JSON_ENCODING_FLAGS); + $options[CURLOPT_POSTFIELDS] = json_encode(is_string($data) && empty($data) ? new \stdClass() : $data, JsonResponse::DEFAULT_JSON_ENCODING_FLAGS); $headers['Accept'] ??= $this->contentType->value; break;