diff --git a/README.md b/README.md new file mode 100644 index 0000000..1cb5839 --- /dev/null +++ b/README.md @@ -0,0 +1,69 @@ +GitLab API +========== + +Balík slouží jako transportní vrstva mezi konkrétní aplikací a GitLabem. + +Pomocí tohoto balíku můžete jednoduchým způsobem pokládat dotazy do GitLabu, detekovat chybové hlášení v Tracy baru a sledovat vytížení požadavků. + +Požadavky typu `GET` se automaticky cachují na `12 hodin`, pokud není řečeno jinak. + +Požadavky typu `POST`, `PUT`, `DELETE` a další změnové akce se necachují vůbec a vždy přenášíme veškerá data znovu. + +Instalace +--------- + +Použijte příkaz Composeru: + +```shell +composer require baraja-core/gitlab-api +``` + +Dále je potřeba nastavit konfiguraci služby pro Nette v NEON souboru. + +Výchozí minimální konfigurace: + +```yaml +services: + gitLabAPI: + factory: Baraja\GitLabApi\GitLabApi(%gitLab.token%) + +parameters: + gitLab: + token: 123-abcDEFghiJKL-789 + +tracy: + bar: + - Baraja\GitLabApi\GitLabApiPanel +``` + +API token musíte vždy změnit pro Váš uživatelský účet! + +Konfigurace +----------- + +Do sekce `parameters` je potřeba vložit defaultní API token pro spojení s GitLabem: + +Příklad: + +```neon +parameters: + gitLab: + token: 123-abcDEFghiJKL-789 +``` + +Volitelně lze nastavit použití Nette Cache: + +```yaml +services: + gitLabAPI: + factory: Baraja\GitLabApi\GitLabApi(%gitLab.token%) + setup: + - setCache(@cache.storage) +``` + +Propojení s vlastní GitLab instalací +------------------------------------ + +V některých případech je potřeba propojit API na vnitřní firemní síť, kde je GitLab hostován. K tomu slouží metoda `setBaseUrl()` s cestou k doméně. + +Předaným parametrem může být například řetězec `'https://gitlab.com/api/v4/'`. \ No newline at end of file diff --git a/common.neon b/common.neon new file mode 100644 index 0000000..b456972 --- /dev/null +++ b/common.neon @@ -0,0 +1,9 @@ +services: + gitLabAPI: + factory: Baraja\GitLabApi\GitLabApi(%gitLab.token%) + setup: + - setCache(@cache.storage) + +tracy: + bar: + - Baraja\GitLabApi\GitLabApiPanel \ No newline at end of file diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..23b188d --- /dev/null +++ b/composer.json @@ -0,0 +1,22 @@ +{ + "name": "baraja-core/gitlab-api", + "description": "Simple and robust GitLab API wrapper with Tracy debug mode.", + "homepage": "https://github.com/baraja-core/gitlab-api", + "authors": [ + { + "name": "Jan Barášek", + "homepage": "https://baraja.cz" + } + ], + "require": { + "php": ">=7.1.0", + "ext-curl": "*", + "nette/caching": "^3.0" + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "minimum-stability": "stable" +} \ No newline at end of file diff --git a/src/Entity/ApiData.php b/src/Entity/ApiData.php new file mode 100644 index 0000000..1adc3cb --- /dev/null +++ b/src/Entity/ApiData.php @@ -0,0 +1,90 @@ + $value) { + if ($recursive && is_array($value)) { + $obj->$key = static::from($value, true); + } else { + $obj->$key = $value; + } + } + + return $obj; + } + + /** + * Returns an iterator over all items. + */ + public function getIterator(): \RecursiveArrayIterator + { + return new \RecursiveArrayIterator((array) $this); + } + + /** + * Returns items count. + */ + public function count(): int + { + return count((array) $this); + } + + /** + * Replaces or appends a item. + * + * @param mixed $key + * @param mixed $value + * @throws GitLabApiException + */ + public function offsetSet($key, $value): void + { + if (!is_scalar($key)) { // prevents null + throw new GitLabApiException('Key must be either a string or an integer, "' . gettype($key) . '" given.'); + } + $this->$key = $value; + } + + /** + * Returns a item. + * + * @return mixed + */ + public function offsetGet($key) + { + return $this->$key; + } + + /** + * Determines whether a item exists. + */ + public function offsetExists($key): bool + { + return isset($this->$key); + } + + /** + * Removes the element from this list. + */ + public function offsetUnset($key): void + { + unset($this->$key); + } + +} \ No newline at end of file diff --git a/src/GitLabApi.php b/src/GitLabApi.php new file mode 100644 index 0000000..b39f1de --- /dev/null +++ b/src/GitLabApi.php @@ -0,0 +1,311 @@ +isLoggedIn() && \method_exists($identity = $user->getIdentity(), 'getGitLabToken')) { + $token = $identity->getGitLabToken() ?? $token; + } + + $this->token = $token; + } + + /** + * @param string $baseUrl + */ + public function setBaseUrl(string $baseUrl): void + { + $this->baseUrl = rtrim($baseUrl, '/') . '/'; + } + + /** + * Use Nette Cache for storage API responses. + * + * @param IStorage $IStorage + */ + public function setCache(IStorage $IStorage): void + { + $this->cache = new Cache($IStorage, 'gitlab-api'); + } + + /** + * @param string $url + * @param string[]|null $data + * @param string $cache + * @param string|null $token + * @return ApiData|ApiData[] + * @throws GitLabApiException + */ + public function request(string $url, array $data = null, string $cache = '12 hours', string $token = null) + { + $token = $token ? : $this->token; + if ($this->validateToken === false) { + if ($url !== 'projects' && $this->validateToken($token) === false) { + GitLabApiException::tokenIsInvalid($token); + } + $this->validateToken = true; + } + + $requestHash = 'url' . md5($url); + Helper::timer($requestHash); + + $hash = md5(json_encode([$url, $data, $token])); + $body = $this->cache === null ? null : $this->cache->load($hash); + + if (!isset($_SERVER['REMOTE_ADDR'])) { + echo "\e[0;32m" . '[GitLab | URL: ' . "\e[0m\e[0;33m" . $url . "\e[0m\e[0;32m" + . ($data !== null ? ', DATA: ' . json_encode($data) : '') + . ' | TOKEN: ' . json_encode($token) + . ']' . "\e[0m\n"; + } + + if ($body !== null) { + GitLabApiPanel::$baseUrl = $this->baseUrl; + GitLabApiPanel::$calls[] = [ + 'duration' => Helper::timer($requestHash) * 1000, + 'url' => $url, + 'isCache' => true, + 'data' => $data, + 'body' => $body, + ]; + + return $body; + } + + $fullUrl = rtrim($this->baseUrl, '/') . '/' . ltrim($url, '/'); + + $curl = curl_init(); + curl_setopt_array($curl, [ + CURLOPT_CUSTOMREQUEST => 'GET', + CURLOPT_RETURNTRANSFER => 1, + CURLOPT_URL => $fullUrl, + CURLOPT_HEADER => 1, + CURLINFO_HEADER_OUT => true, + ]); + + curl_setopt($curl, CURLOPT_HTTPHEADER, [ + 'PRIVATE-TOKEN: ' . $token, + ]); + + $resp = curl_exec($curl); + $headerSize = curl_getinfo($curl, CURLINFO_HEADER_SIZE); + $rawData = substr($resp, $headerSize); + + $body = $this->mapToApiData(Helper::decode($rawData)); + + if (isset($body['error'])) { + $errorMessages = []; + + foreach ($body as $key => $value) { + $errorMessages[] = trim($key) . ': ' . json_encode($value); + } + + throw new GitLabApiException(implode("\n", $errorMessages), $errorMessages); + } + + GitLabApiPanel::$baseUrl = $this->baseUrl; + GitLabApiPanel::$calls[] = [ + 'duration' => Helper::timer($requestHash) * 1000, + 'url' => $url, + 'isCache' => false, + 'data' => $data, + 'body' => $body, + ]; + + curl_close($curl); + + if ($this->cache !== null) { + $this->cache->save($hash, $body, [ + Cache::EXPIRE => $cache, + ]); + } + + return $body; + } + + /** + * @param string $url + * @param string[]|null $data + * @param string $method + * @param string|null $token + * @return ApiData|ApiData[] + * @throws GitLabApiException + */ + public function changeRequest(string $url, array $data = null, string $method = 'PUT', string $token = null) + { + $token = $token ? : $this->token; + if ($this->validateToken === false) { + if ($url !== 'projects' && $this->validateToken($token) === false) { + GitLabApiException::tokenIsInvalid($token); + } + $this->validateToken = true; + } + + $requestHash = 'url' . md5($url); + Helper::timer($requestHash); + + if (!isset($_SERVER['REMOTE_ADDR'])) { + echo "\e[0;32m" . '[GitLab | URL: ' . "\e[0m\e[0;33m" . $url . "\e[0m\e[0;32m" + . ($data !== null ? ', DATA: ' . json_encode($data) : '') + . ' | TOKEN: ' . json_encode($token) + . ']' . "\e[0m\n"; + } + + $fullUrl = rtrim($this->baseUrl, '/') . '/' . ltrim($url, '/'); + + $configRequest = [ + CURLOPT_CUSTOMREQUEST => $method, + CURLOPT_RETURNTRANSFER => 1, + CURLOPT_URL => $fullUrl, + CURLOPT_HEADER => 1, + ]; + + if ($data !== null) { + $configRequest[CURLOPT_POSTFIELDS] = http_build_query($data); + } + + $curl = curl_init(); + curl_setopt_array($curl, $configRequest); + + curl_setopt($curl, CURLOPT_HTTPHEADER, [ + 'PRIVATE-TOKEN: ' . $token, + ]); + + $resp = curl_exec($curl); + + if ($resp === false) { + GitLabApiPanel::$baseUrl = $this->baseUrl; + GitLabApiPanel::$calls[] = [ + 'duration' => Helper::timer($requestHash) * 1000, + 'method' => $method, + 'url' => $url, + 'data' => $data, + 'body' => $resp, + ]; + + throw new GitLabApiException('[' . $url . ']: Curl return FALSE: ' . Helper::getLastErrorMessage()); + } + + $headerSize = curl_getinfo($curl, CURLINFO_HEADER_SIZE); + $rawData = substr((string) $resp, $headerSize); + + $body = $this->mapToApiData(Helper::decode($rawData)); + + if (isset($body['error'])) { + $errorMessages = []; + + foreach ($body as $key => $value) { + $errorMessages[] = trim($key) . ': ' . json_encode($value); + } + + GitLabApiPanel::$baseUrl = $this->baseUrl; + GitLabApiPanel::$calls[] = [ + 'duration' => Helper::timer($requestHash) * 1000, + 'method' => $method, + 'url' => $url, + 'data' => $data, + 'body' => $body, + ]; + + throw new GitLabApiException('[' . $url . ']: ' . implode("\n", $errorMessages), $errorMessages); + } + + GitLabApiPanel::$baseUrl = $this->baseUrl; + GitLabApiPanel::$calls[] = [ + 'duration' => Helper::timer($requestHash) * 1000, + 'method' => $method, + 'url' => $url, + 'data' => $data, + 'body' => $body, + ]; + + curl_close($curl); + + return $body; + } + + /** + * @param string $token + * @return bool + * @throws GitLabApiException + */ + public function validateToken(string $token): bool + { + $response = $this->request('projects', null, '1 hour', $token); + + return (\is_array($response) === false + && $response->offsetExists('message') + && $response->message === '401 Unauthorized' + ) === false; + } + + /** + * @param \stdClass|\stdClass[] $haystack + * @return ApiData|ApiData[]|string|bool|int|float + */ + private function mapToApiData($haystack) + { + if (\is_array($haystack)) { + $return = []; + + foreach ($haystack as $key => $value) { + $return[$key] = $this->mapToApiData($value); + } + + return $return; + } + + if ($haystack instanceof \stdClass) { + $return = new ApiData; + + foreach ($haystack as $key => $value) { + $return->{$key} = $value; + } + + return $return; + } + + return $haystack; + } + +} \ No newline at end of file diff --git a/src/GitLabApiException.php b/src/GitLabApiException.php new file mode 100644 index 0000000..5f19c49 --- /dev/null +++ b/src/GitLabApiException.php @@ -0,0 +1,101 @@ +\w+?)\"?\:\s*\"?(?.*?)\"?$/', $errorConfig, $errorConfigParser)) { + $this->errorConfigs[$errorConfigParser['key']] = $errorConfigParser['value']; + } else { + throw new self('Invalid error config:' . "\n\n" . \json_encode($errorConfigs)); + } + } + } + + /** + * @param string $token + * @throws GitLabApiException + */ + public static function tokenIsInvalid(string $token): void + { + throw new self( + 'GitLab token "' . $token . '" is invalid.' + ); + } + + /** + * @return string[] + */ + public function getErrorConfigs(): array + { + return $this->errorConfigs; + } + + /** + * @return string + */ + public function getErrorType(): string + { + if ($this->isKey('error')) { + return $this->getKey('error', self::ERROR_ERROR); + } + + return self::ERROR_ERROR; + } + + /** + * @return bool + */ + public function isDefaultError(): bool + { + return $this->getErrorType() === self::ERROR_ERROR; + } + + /** + * @param string $key + * @return bool + */ + public function isKey(string $key): bool + { + return isset($this->errorConfigs[$key]); + } + + /** + * @param string $key + * @param string|null $default + * @return string|null + */ + public function getKey(string $key, ?string $default = null): ?string + { + if ($this->isKey($key)) { + return $this->errorConfigs[$key]; + } + + return $default; + } + +} \ No newline at end of file diff --git a/src/GitLabApiPanel.php b/src/GitLabApiPanel.php new file mode 100644 index 0000000..aaa3135 --- /dev/null +++ b/src/GitLabApiPanel.php @@ -0,0 +1,65 @@ +' + . 'GitLab panel' + . \count(self::$calls) . ' requests' + . ($duration > 0 ? ' / ' . number_format($duration, 2, '.', ' ') . ' ms' : '') + . ''; + } + + public function getPanel(): string + { + if (\count(self::$calls) > 0) { + $table = ''; + $table .= ''; + foreach (self::$calls as $call) { + $table .= '' + . '' + . '' + . '' + . '' + . ''; + } + + $table .= '
Time msUrlDataBody
' . number_format($call['duration'], 2, '.', ' ') . ' ms' + . (($call['isCache'] ?? false) ? '
cache' : '') + . '
' . ($call['method'] ?? 'GET') . ' ' . $call['url'] . '' . Dumper::toHtml($call['data']) . '' . Dumper::toHtml($call['body']) . '
'; + } else { + $table = 'No GitLab requests.'; + } + + return '

GitLab requests' . (self::$baseUrl ? ' [' . self::$baseUrl . ']' : '') . '

' + . '
' . $table . '
'; + } + +} \ No newline at end of file diff --git a/src/Helper.php b/src/Helper.php new file mode 100644 index 0000000..8321859 --- /dev/null +++ b/src/Helper.php @@ -0,0 +1,76 @@ +]+>[a-z0-9\.\-\_\(\)]+<\/a>\]\s*/i'; + + $lastError = error_get_last(); + if ($lastError && isset($lastError['message'])) { + $return = trim((string) preg_replace($pattern, ' ', (string) $lastError['message'])); + } + + return $return; + } + +} \ No newline at end of file