├── .gitignore
├── CHANGELOG.md
├── LICENSE
├── README.md
├── composer.json
├── phpunit.xml
├── src
├── Client.php
├── GitlabAdapter.php
└── UnableToRetrieveFileTree.php
└── tests
├── ClientTest.php
├── GitlabAdapterTest.php
├── TestCase.php
├── assets
├── testing-update.txt
└── testing.txt
└── config
└── config.testing.example.php
/.gitignore:
--------------------------------------------------------------------------------
1 | composer.lock
2 | vendor
3 | .DS_Store
4 | .idea
5 | .phpunit.result.cache
6 | tests/config/config.testing.php
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | ## Changelog
2 |
3 | All notable changes to `flysystem-gitlab-storage` will be documented in this file
4 |
5 | ### 0.0.1 - 2019-08-02
6 | - initial commit.
7 |
8 | ### 1.0.0 - 2019-08-03
9 | - Implemented createDir by creating a directory with a .gitkeep file.
10 | - Small bug fixes.
11 |
12 | ### 1.0.1 - 2019-08-03
13 | - Updated CHANGELOG.
14 |
15 | ### 1.0.2 - 2019-08-03
16 | - Updated composer.json to support laravel projects.
17 |
18 | ### 1.0.3 - 2019-08-03
19 | - Fixed packagist versioning issue.
20 |
21 | ### 1.0.4 - 2019-08-03
22 | - Added support for tree path with multiple sub folders.
23 |
24 | ### 1.0.5 - 2019-08-03
25 | - Adapters read method now returns an array instead of raw content.
26 |
27 | ### 1.0.6 - 2019-08-03
28 | - Adapters listContents method now changes type blob to type file.
29 |
30 | ### 1.0.7 - 2020-03-20
31 | - Added a debug mode.
32 |
33 | ### 1.1.0 - 2020-06-29
34 | - Moved minimum PHP version to 7.1 since PHPUnit 9 requires 7.1 or above.
35 | - Added support for paginated list of contents when requesting file trees.
36 | - [https://docs.gitlab.com/ee/api/README.html#pagination](https://docs.gitlab.com/ee/api/README.html#pagination)
37 |
38 | ### 2.0.0 - 2020-11-30
39 | - Migrated to flysystem 2.x
40 |
41 | ### 2.0.1 - 2020-12-01
42 | - Added php 8 support
43 |
44 | ### 2.0.2 - 2020-12-01
45 | - Allow to read into stream
46 |
47 | ### 2.0.3 - 2020-12-16
48 | - Reuse stream of HTTP request instead of create new stream
49 |
50 | ### 2.0.4 - 2020-12-16
51 | - Savings HTTP exchanges with HEAD request
52 |
53 | ### 3.0.0 - 2022-01-27
54 | - Added flysystem v3 support.
55 |
56 | ### 3.1.0 - 2024-04-26
57 | - Moved minimum PHP version to 8.1
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2019 Roy Voetman
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | A Gitlab Storage filesystem for [Flysystem](https://flysystem.thephpleague.com/docs/).
4 |
5 | [](https://packagist.org/packages/royvoetman/flysystem-gitlab-storage)
6 | [](LICENSE)
7 | [](https://packagist.org/packages/royvoetman/flysystem-gitlab-storage)
8 |
9 | This package contains a Flysystem adapter for Gitlab. Under the hood, Gitlab's [Repository (files) API](https://docs.gitlab.com/ee/api/repository_files.html) v4 is used.
10 |
11 | > For Flysystem 2 (PHP 7.4) [use version 2.0.4](https://github.com/RoyVoetman/flysystem-gitlab-storage/tree/v2.0.4)
12 |
13 | > For Flysystem 1 (PHP 7.1) [use version 1.1.0](https://github.com/RoyVoetman/flysystem-gitlab-storage/tree/v1.1.0)
14 |
15 | ## Installation
16 |
17 | ```bash
18 | composer require royvoetman/flysystem-gitlab-storage
19 | ```
20 |
21 | ## Integrations
22 |
23 | * Laravel - [https://github.com/royvoetman/laravel-gitlab-storage](https://github.com/royvoetman/laravel-gitlab-storage)
24 |
25 | ## Usage
26 | ```php
27 | // Create a Gitlab Client to talk with the API
28 | $client = new Client('project-id', 'branch', 'base-url', 'personal-access-token');
29 |
30 | // Create the Adapter that implements Flysystems AdapterInterface
31 | $adapter = new GitlabAdapter(
32 | // Gitlab API Client
33 | $client,
34 | // Optional path prefix
35 | 'path/prefix',
36 | );
37 |
38 | // The FilesystemOperator
39 | $filesystem = new League\Flysystem\Filesystem($adapter);
40 |
41 | // see http://flysystem.thephpleague.com/api/ for full list of available functionality
42 | ```
43 |
44 | ### Project ID
45 | Every project in Gitlab has its own Project ID. It can be found at to top of the frontpage of your repository. [See](https://stackoverflow.com/questions/39559689/where-do-i-find-the-project-id-for-the-gitlab-api#answer-53126068)
46 |
47 | ### Base URL
48 | This will be the URL where you host your gitlab server (e.g. https://gitlab.com)
49 |
50 | ### Access token (required for private projects)
51 | Gitlab supports server side API authentication with Personal Access tokens
52 |
53 | For more information on how to create your own Personal Access token: [Gitlab Docs](https://docs.gitlab.com/ee/user/profile/personal_access_tokens.html)
54 |
55 | ## Changelog
56 |
57 | Please see [CHANGELOG](CHANGELOG.md) for more information what has changed recently.
58 |
59 | ## Contributing
60 |
61 | Contributions are **welcome** and will be fully **credited**. We accept contributions via Pull Requests on [Github](https://github.com/RoyVoetman/flysystem-gitlab-storage).
62 |
63 | ### Pull Requests
64 |
65 | - **[PSR-2 Coding Standard](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-2-coding-style-guide.md)** - The easiest way to apply the conventions is to install [PHP Code Sniffer](http://pear.php.net/package/PHP_CodeSniffer).
66 | - **Document any change in behaviour** - Make sure the `README.md` and any other relevant documentation are kept up-to-date.
67 | - **Create feature branches** - Don't ask us to pull from your master branch.
68 | - **One pull request per feature** - If you want to do more than one thing, send multiple pull requests.
69 |
70 | ## License
71 |
72 | The MIT License (MIT). Please see [License File](LICENSE) for more information.
73 |
--------------------------------------------------------------------------------
/composer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "royvoetman/flysystem-gitlab-storage",
3 | "description": "Flysystem Adapter for the Gitlab Repository files API v4",
4 | "keywords": [
5 | "royvoetman",
6 | "flysystem-gitlab",
7 | "flysystem",
8 | "gitlab",
9 | "v4",
10 | "api",
11 | "laravel-storage"
12 | ],
13 | "type": "library",
14 | "license": "MIT",
15 | "authors": [
16 | {
17 | "name": "Roy Voetman",
18 | "email": "royvoetman@outlook.com",
19 | "role": "Developer"
20 | }
21 | ],
22 | "require": {
23 | "php": "^8.1",
24 | "ext-json": "*",
25 | "guzzlehttp/guzzle": "^7.0",
26 | "league/flysystem": "^2.0 || ^3.0"
27 | },
28 | "require-dev": {
29 | "phpunit/phpunit": "^10.5.11 || ^11.0.4"
30 | },
31 | "autoload": {
32 | "psr-4": {
33 | "RoyVoetman\\FlysystemGitlab\\": "src/"
34 | }
35 | },
36 | "autoload-dev": {
37 | "psr-4": {
38 | "RoyVoetman\\FlysystemGitlab\\Tests\\": "tests"
39 | }
40 | },
41 | "config": {
42 | "sort-packages": true
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/phpunit.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
8 |
9 | ./tests
10 |
11 |
12 |
13 |
14 |
15 | ./src
16 |
17 |
18 |
19 |
20 |
--------------------------------------------------------------------------------
/src/Client.php:
--------------------------------------------------------------------------------
1 | projectId = $projectId;
50 | $this->branch = $branch;
51 | $this->baseUrl = $baseUrl;
52 | $this->personalAccessToken = $personalAccessToken;
53 | }
54 |
55 | /**
56 | * @param $path
57 | *
58 | * @return string
59 | * @throws \GuzzleHttp\Exception\GuzzleException
60 | */
61 | public function readRaw(string $path): string
62 | {
63 | $path = rawurlencode($path);
64 |
65 | $response = $this->request('GET', "files/$path/raw");
66 |
67 | return $this->responseContents($response, false);
68 | }
69 |
70 | /**
71 | * @param $path
72 | *
73 | * @return array
74 | * @throws \GuzzleHttp\Exception\GuzzleException
75 | */
76 | public function read($path)
77 | {
78 | $path = rawurlencode($path);
79 |
80 | $response = $this->request('HEAD', "files/$path");
81 |
82 | $headers = $response->getHeaders();
83 | $headers = array_filter(
84 | $headers,
85 | fn($key) => substr($key, 0, 9) == 'x-gitlab-',
86 | ARRAY_FILTER_USE_KEY
87 | );
88 |
89 | $keys = array_keys($headers);
90 | $values = array_values($headers);
91 |
92 | array_walk(
93 | $keys,
94 | function(&$key) {
95 | $key = substr($key, 9);
96 | $key = strtolower($key);
97 | $key = preg_replace_callback(
98 | '/[-_]+(.)?/i',
99 | fn($matches) => strtoupper($matches[ 1 ]),
100 | $key
101 | );
102 | }
103 | );
104 |
105 | return array_combine($keys, $values);
106 | }
107 |
108 | /**
109 | * @param $path
110 | *
111 | * @return resource|null
112 | * @throws \GuzzleHttp\Exception\GuzzleException
113 | */
114 | public function readStream($path)
115 | {
116 | $path = rawurlencode($path);
117 |
118 | $response = $this->request('GET', "files/$path/raw");
119 |
120 | return $response->getBody()->detach();
121 | }
122 |
123 | /**
124 | * @param $path
125 | *
126 | * @return mixed|string
127 | * @throws \GuzzleHttp\Exception\GuzzleException
128 | */
129 | public function blame($path)
130 | {
131 | $path = rawurlencode($path);
132 |
133 | $response = $this->request('GET', "files/$path/blame");
134 |
135 | return $this->responseContents($response);
136 | }
137 |
138 | /**
139 | * @param string $path
140 | * @param string $contents
141 | * @param string $commitMessage
142 | * @param bool $override
143 | *
144 | * @return array
145 | * @throws \GuzzleHttp\Exception\GuzzleException
146 | */
147 | public function upload(string $path, string $contents, string $commitMessage, $override = false): array
148 | {
149 | $path = rawurlencode($path);
150 |
151 | $method = $override ? 'PUT' : 'POST';
152 |
153 | $response = $this->request($method, "files/$path", [
154 | 'content' => $contents,
155 | 'commit_message' => $commitMessage
156 | ]);
157 |
158 | return $this->responseContents($response);
159 | }
160 |
161 | /**
162 | * @param string $path
163 | * @param $resource
164 | * @param string $commitMessage
165 | * @param bool $override
166 | *
167 | * @return array
168 | * @throws \GuzzleHttp\Exception\GuzzleException
169 | */
170 | public function uploadStream(string $path, $resource, string $commitMessage, $override = false): array
171 | {
172 | if (!is_resource($resource)) {
173 | throw new InvalidArgumentException(sprintf('Argument must be a valid resource type. %s given.',
174 | gettype($resource)));
175 | }
176 |
177 | return $this->upload($path, stream_get_contents($resource), $commitMessage, $override);
178 | }
179 |
180 | /**
181 | * @param string $path
182 | * @param string $commitMessage
183 | *
184 | * @throws \GuzzleHttp\Exception\GuzzleException
185 | */
186 | public function delete(string $path, string $commitMessage)
187 | {
188 | $path = rawurlencode($path);
189 |
190 | $this->request('DELETE', "files/$path", [
191 | 'commit_message' => $commitMessage
192 | ]);
193 | }
194 |
195 | /**
196 | * @param string|null $directory
197 | * @param bool $recursive
198 | *
199 | * @return iterable
200 | * @throws \GuzzleHttp\Exception\GuzzleException
201 | */
202 | public function tree(string $directory = null, bool $recursive = false): iterable
203 | {
204 | if ($directory === '/' || $directory === '') {
205 | $directory = null;
206 | }
207 |
208 | $page = 1;
209 |
210 | do {
211 | $response = $this->request('GET', 'tree', [
212 | 'path' => $directory,
213 | 'recursive' => $recursive,
214 | 'per_page' => 100,
215 | 'page' => $page++
216 | ]);
217 |
218 | yield $this->responseContents($response);
219 | } while ($this->responseHasNextPage($response));
220 | }
221 |
222 | /**
223 | * @return string
224 | */
225 | public function getPersonalAccessToken(): string
226 | {
227 | return $this->personalAccessToken;
228 | }
229 |
230 | /**
231 | * @param string $personalAccessToken
232 | */
233 | public function setPersonalAccessToken(string $personalAccessToken)
234 | {
235 | $this->personalAccessToken = $personalAccessToken;
236 | }
237 |
238 | /**
239 | * @return string
240 | */
241 | public function getProjectId(): string
242 | {
243 | return $this->projectId;
244 | }
245 |
246 | /**
247 | * @param string $projectId
248 | */
249 | public function setProjectId(string $projectId)
250 | {
251 | $this->projectId = $projectId;
252 | }
253 |
254 | /**
255 | * @return string
256 | */
257 | public function getBranch(): string
258 | {
259 | return $this->branch;
260 | }
261 |
262 | /**
263 | * @param string $branch
264 | */
265 | public function setBranch(string $branch)
266 | {
267 | $this->branch = $branch;
268 | }
269 |
270 | /**
271 | * @param string $method
272 | * @param string $uri
273 | * @param array $params
274 | *
275 | * @return \GuzzleHttp\Psr7\Response
276 | * @throws \GuzzleHttp\Exception\GuzzleException
277 | */
278 | private function request(string $method, string $uri, array $params = []): Response
279 | {
280 | $uri = !in_array($method, ['POST', 'PUT', 'DELETE']) ? $this->buildUri($uri, $params) : $this->buildUri($uri);
281 | $params = in_array($method, ['POST', 'PUT', 'DELETE']) ? ['form_params' => array_merge(['branch' => $this->branch], $params)] : [];
282 |
283 | $client = new HttpClient(['headers' => ['PRIVATE-TOKEN' => $this->personalAccessToken]]);
284 |
285 | return $client->request($method, $uri, $params);
286 | }
287 |
288 | /**
289 | * @param string $uri
290 | * @param $params
291 | *
292 | * @return string
293 | */
294 | private function buildUri(string $uri, array $params = []): string
295 | {
296 | $params = array_merge(['ref' => $this->branch], $params);
297 |
298 | $params = array_map(function($value) {
299 | return $value !== null ? rawurlencode($value) : null;
300 | }, $params);
301 |
302 | if(isset($params['path'])) {
303 | $params['path'] = urldecode($params['path']);
304 | }
305 |
306 | $params = http_build_query($params);
307 |
308 | $params = !empty($params) ? "?$params" : null;
309 |
310 | $baseUrl = rtrim($this->baseUrl, '/').self::VERSION_URI;
311 |
312 | return "{$baseUrl}/projects/{$this->projectId}/repository/{$uri}{$params}";
313 | }
314 |
315 | /**
316 | * @param \GuzzleHttp\Psr7\Response $response
317 | * @param bool $json
318 | *
319 | * @return mixed|string
320 | */
321 | private function responseContents(Response $response, $json = true)
322 | {
323 | $contents = $response->getBody()
324 | ->getContents();
325 |
326 | return ($json) ? json_decode($contents, true) : $contents;
327 | }
328 |
329 | /**
330 | * @param \GuzzleHttp\Psr7\Response $response
331 | *
332 | * @return bool
333 | */
334 | private function responseHasNextPage(Response $response)
335 | {
336 | if ($response->hasHeader('X-Next-Page')) {
337 | return !empty($response->getHeader('X-Next-Page')[0] ?? "");
338 | }
339 |
340 | return false;
341 | }
342 | }
343 |
--------------------------------------------------------------------------------
/src/GitlabAdapter.php:
--------------------------------------------------------------------------------
1 | client = $client;
47 | $this->prefixer = new PathPrefixer($prefix, DIRECTORY_SEPARATOR);
48 | $this->mimeTypeDetector = new ExtensionMimeTypeDetector();
49 | }
50 |
51 | /**
52 | * @inheritdoc
53 | */
54 | public function fileExists(string $path): bool
55 | {
56 | try {
57 | $this->client->read($this->prefixer->prefixPath($path));
58 | } catch (Throwable $e) {
59 | if ($e instanceof ClientException && $e->getCode() == 404) {
60 | return false;
61 | }
62 |
63 | throw UnableToCheckFileExistence::forLocation($path, $e);
64 | }
65 |
66 | return true;
67 | }
68 |
69 | /**
70 | * @inheritdoc
71 | */
72 | public function write(string $path, string $contents, Config $config): void
73 | {
74 | $location = $this->prefixer->prefixPath($path);
75 |
76 | try {
77 | $override = $this->fileExists($location);
78 |
79 | $this->client->upload($location, $contents, self::UPLOADED_FILE_COMMIT_MESSAGE, $override);
80 | } catch (Throwable $e) {
81 | throw UnableToWriteFile::atLocation($path, $e->getMessage(), $e);
82 | }
83 | }
84 |
85 | /**
86 | * @inheritdoc
87 | */
88 | public function writeStream(string $path, $contents, Config $config): void
89 | {
90 | $location = $this->prefixer->prefixPath($path);
91 |
92 | try {
93 | $override = $this->fileExists($location);
94 |
95 | $this->client->uploadStream($location, $contents, self::UPLOADED_FILE_COMMIT_MESSAGE, $override);
96 | } catch (Throwable $e) {
97 | throw UnableToWriteFile::atLocation($path, $e->getMessage(), $e);
98 | }
99 | }
100 |
101 | /**
102 | * @inheritdoc
103 | */
104 | public function read(string $path): string
105 | {
106 | try {
107 | return $this->client->readRaw($this->prefixer->prefixPath($path));
108 | } catch (Throwable $e) {
109 | throw UnableToReadFile::fromLocation($path, $e->getMessage(), $e);
110 | }
111 | }
112 |
113 | /**
114 | * @inheritdoc
115 | */
116 | public function readStream(string $path)
117 | {
118 | try {
119 | if (null === ($resource = $this->client->readStream($this->prefixer->prefixPath($path)))) {
120 | throw UnableToReadFile::fromLocation($path, 'Empty content');
121 | }
122 |
123 | return $resource;
124 | } catch (Throwable $e) {
125 | throw UnableToReadFile::fromLocation($path, $e->getMessage(), $e);
126 | }
127 | }
128 |
129 | /**
130 | * @inheritdoc
131 | */
132 | public function delete(string $path): void
133 | {
134 | try {
135 | $this->client->delete($this->prefixer->prefixPath($path), self::DELETED_FILE_COMMIT_MESSAGE);
136 | } catch (Throwable $e) {
137 | throw UnableToDeleteFile::atLocation($path, $e->getMessage(), $e);
138 | }
139 | }
140 |
141 | /**
142 | * @inheritdoc
143 | */
144 | public function deleteDirectory(string $path): void
145 | {
146 | $files = $this->listContents($this->prefixer->prefixPath($path), false);
147 |
148 | /** @var StorageAttributes $file */
149 | foreach ($files as $file) {
150 | if ($file->isFile()) {
151 | try {
152 | $this->client->delete($file->path(), self::DELETED_FILE_COMMIT_MESSAGE);
153 | } catch (Throwable $e) {
154 | throw UnableToDeleteDirectory::atLocation($path, $e->getMessage(), $e);
155 | }
156 | }
157 | }
158 | }
159 |
160 | /**
161 | * @inheritdoc
162 | */
163 | public function createDirectory(string $path, Config $config): void
164 | {
165 | $path = rtrim($path, '/') . '/.gitkeep';
166 |
167 | try {
168 | $this->write($this->prefixer->prefixPath($path), '', $config);
169 | } catch (Throwable $e) {
170 | throw UnableToCreateDirectory::dueToFailure($path, $e);
171 | }
172 | }
173 |
174 | /**
175 | * @inheritdoc
176 | *
177 | * @throws \League\Flysystem\UnableToSetVisibility
178 | */
179 | public function setVisibility(string $path, $visibility): void
180 | {
181 | throw new UnableToSetVisibility(get_class($this).' Gitlab API does not support visibility.');
182 | }
183 |
184 | /**
185 | * @inheritdoc
186 | *
187 | * @throws \League\Flysystem\UnableToSetVisibility
188 | */
189 | public function visibility(string $path): FileAttributes
190 | {
191 | throw new UnableToSetVisibility(get_class($this).' Gitlab API does not support visibility.');
192 | }
193 |
194 | /**
195 | * @inheritdoc
196 | *
197 | * @throws \League\Flysystem\UnableToRetrieveMetadata
198 | */
199 | public function mimeType(string $path): FileAttributes
200 | {
201 | $mimeType = $this->mimeTypeDetector->detectMimeTypeFromPath($this->prefixer->prefixPath($path));
202 |
203 | if ($mimeType === null) {
204 | throw UnableToRetrieveMetadata::mimeType($path);
205 | }
206 |
207 | return new FileAttributes($path, null, null, null, $mimeType);
208 | }
209 |
210 | /**
211 | * @inheritdoc
212 | */
213 | public function lastModified(string $path): FileAttributes
214 | {
215 | try {
216 | $response = $this->client->blame($this->prefixer->prefixPath($path));
217 |
218 | if (empty($response)) {
219 | return new FileAttributes($path, null, null, null);
220 | }
221 |
222 | $lastModified = DateTime::createFromFormat("Y-m-d\TH:i:s.uO", $response[0]['commit']['committed_date']);
223 |
224 | return new FileAttributes($path, null, null, $lastModified->getTimestamp());
225 | } catch (Throwable $e) {
226 | throw UnableToRetrieveMetadata::lastModified($path, $e->getMessage(), $e);
227 | }
228 | }
229 |
230 | /**
231 | * @inheritdoc
232 | */
233 | public function fileSize(string $path): FileAttributes
234 | {
235 | try {
236 | $meta = $this->client->read($this->prefixer->prefixPath($path));
237 |
238 | return new FileAttributes($path, $meta['size'][0] ?? 0);
239 | } catch (Throwable $e) {
240 | throw UnableToRetrieveMetadata::fileSize($path, $e->getMessage(), $e);
241 | }
242 | }
243 |
244 | /**
245 | * @inheritdoc
246 | */
247 | public function listContents(string $path, bool $deep): iterable
248 | {
249 | try {
250 | $tree = $this->client->tree($this->prefixer->prefixPath($path), $deep);
251 |
252 | foreach ($tree as $folders) {
253 | foreach ($folders as $item) {
254 | $isDirectory = $item['type'] == 'tree';
255 |
256 | yield $isDirectory ? new DirectoryAttributes($item['path'], null, null) : new FileAttributes(
257 | $item['path'],
258 | $this->fileSize($item['path'])->fileSize(),
259 | null,
260 | $this->lastModified($item['path'])->lastModified(),
261 | $this->mimeTypeDetector->detectMimeTypeFromPath($item['path'])
262 | );
263 | }
264 | }
265 | } catch (Throwable $e) {
266 | throw new UnableToRetrieveFileTree($e->getMessage());
267 | }
268 | }
269 |
270 | /**
271 | * @inheritdoc
272 | */
273 | public function move(string $source, string $destination, Config $config): void
274 | {
275 | try {
276 | $contents = $this->client->readRaw($this->prefixer->prefixPath($source));
277 |
278 | $this->client->upload(
279 | $this->prefixer->prefixPath($destination),
280 | $contents,
281 | self::UPLOADED_FILE_COMMIT_MESSAGE
282 | );
283 |
284 | $this->client->delete($this->prefixer->prefixPath($source), self::DELETED_FILE_COMMIT_MESSAGE);
285 | } catch (Throwable $e) {
286 | throw UnableToMoveFile::fromLocationTo($source, $destination, $e);
287 | }
288 | }
289 |
290 | /**
291 | * @inheritdoc
292 | */
293 | public function copy(string $source, string $destination, Config $config): void
294 | {
295 | try {
296 | $contents = $this->client->readRaw($this->prefixer->prefixPath($source));
297 |
298 | $this->client->upload(
299 | $this->prefixer->prefixPath($destination),
300 | $contents,
301 | self::UPLOADED_FILE_COMMIT_MESSAGE
302 | );
303 | } catch (Throwable $e) {
304 | throw UnableToCopyFile::fromLocationTo($source, $destination, $e);
305 | }
306 | }
307 |
308 | public function getClient(): Client
309 | {
310 | return $this->client;
311 | }
312 |
313 | public function setClient(Client $client)
314 | {
315 | $this->client = $client;
316 | }
317 |
318 | /**
319 | * @inheritdoc
320 | */
321 | public function directoryExists(string $path): bool
322 | {
323 | try {
324 | $tree = $this->client->tree($this->prefixer->prefixPath($path));
325 |
326 | return (bool)count($tree->current());
327 | } catch (Throwable $e) {
328 | if ($e instanceof ClientException && $e->getCode() == 404) {
329 | return false;
330 | }
331 |
332 | throw UnableToCheckExistence::forLocation($path, $e);
333 | }
334 | }
335 | }
336 |
--------------------------------------------------------------------------------
/src/UnableToRetrieveFileTree.php:
--------------------------------------------------------------------------------
1 | client = $this->getClientInstance();
18 | }
19 |
20 | #[Test]
21 | public function it_can_be_instantiated()
22 | {
23 | $this->assertInstanceOf(Client::class, $this->getClientInstance());
24 | }
25 |
26 | #[Test]
27 | public function it_can_read_a_file()
28 | {
29 | $meta = $this->client->read('README.md');
30 |
31 | $this->assertArrayHasKey('ref', $meta);
32 | $this->assertArrayHasKey('size', $meta);
33 | $this->assertArrayHasKey('lastCommitId', $meta);
34 | }
35 |
36 | #[Test]
37 | public function it_can_read_a_file_raw()
38 | {
39 | $content = $this->client->readRaw('README.md');
40 |
41 | $this->assertStringStartsWith('# Testing repo for `flysystem-gitlab`', $content);
42 | }
43 |
44 | #[Test]
45 | public function it_can_create_a_file()
46 | {
47 | $contents = $this->client->upload('testing.md', '# Testing create', 'Created file');
48 |
49 | $this->assertStringStartsWith('# Testing create', $this->client->readRaw('testing.md'));
50 | $this->assertSame($contents, [
51 | 'file_path' => 'testing.md',
52 | 'branch' => $this->client->getBranch()
53 | ]);
54 | }
55 |
56 | #[Test]
57 | public function it_can_update_a_file()
58 | {
59 | $contents = $this->client->upload('testing.md', '# Testing update', 'Updated file', true);
60 |
61 | $this->assertStringStartsWith('# Testing update', $this->client->readRaw('testing.md'));
62 | $this->assertSame($contents, [
63 | 'file_path' => 'testing.md',
64 | 'branch' => $this->client->getBranch()
65 | ]);
66 | }
67 |
68 | #[Test]
69 | public function it_can_delete_a_file()
70 | {
71 | $this->client->delete('testing.md', 'Deleted file');
72 |
73 | $this->expectException(ClientException::class);
74 |
75 | $this->client->read('testing.md');
76 | }
77 |
78 | #[Test]
79 | public function it_can_create_a_file_from_stream()
80 | {
81 | $stream = fopen(__DIR__.'/assets/testing.txt', 'r+');
82 |
83 | $contents = $this->client->uploadStream('testing.txt', $stream, 'Created file');
84 |
85 | fclose($stream);
86 |
87 | $this->assertStringStartsWith('File for testing file streams', $this->client->readRaw('testing.txt'));
88 | $this->assertSame($contents, [
89 | 'file_path' => 'testing.txt',
90 | 'branch' => $this->client->getBranch()
91 | ]);
92 |
93 | // Clean up
94 | $this->client->delete('testing.txt', 'Deleted file');
95 | }
96 |
97 | #[Test]
98 | public function it_can_not_a_create_file_from_stream_without_a_valid_stream()
99 | {
100 | $this->expectException(\InvalidArgumentException::class);
101 |
102 | $this->client->uploadStream('testing.txt', 'string of data', 'Created file');
103 | }
104 |
105 | #[Test]
106 | public function it_can_retrieve_a_file_tree()
107 | {
108 | $contents = $this->client->tree();
109 |
110 | $content = $contents->current();
111 |
112 | $this->assertIsArray($content);
113 | $this->assertArrayHasKey('id', $content[0]);
114 | $this->assertArrayHasKey('name', $content[0]);
115 | $this->assertArrayHasKey('type', $content[0]);
116 | $this->assertArrayHasKey('path', $content[0]);
117 | $this->assertArrayHasKey('mode', $content[0]);
118 | }
119 |
120 | #[Test]
121 | public function it_can_retrieve_a_file_tree_recursive()
122 | {
123 | $contents = $this->client->tree('/', true);
124 |
125 | $content = $contents->current();
126 |
127 | $this->assertIsArray($content);
128 | }
129 |
130 | #[Test]
131 | public function it_can_retrieve_a_file_tree_of_a_subdirectory()
132 | {
133 | $contents = $this->client->tree('recursive', true);
134 |
135 | $content = $contents->current();
136 |
137 | $this->assertIsArray($content);
138 | $this->assertArrayHasKey('id', $content[0]);
139 | $this->assertArrayHasKey('name', $content[0]);
140 | $this->assertArrayHasKey('type', $content[0]);
141 | $this->assertArrayHasKey('path', $content[0]);
142 | $this->assertArrayHasKey('mode', $content[0]);
143 | }
144 |
145 | #[Test]
146 | public function it_can_change_the_branch()
147 | {
148 | $this->client->setBranch('dev');
149 |
150 | $this->assertEquals('dev', $this->client->getBranch());
151 | }
152 |
153 | #[Test]
154 | public function it_can_change_the_project_id()
155 | {
156 | $this->client->setProjectId('12345678');
157 |
158 | $this->assertEquals('12345678', $this->client->getProjectId());
159 | }
160 |
161 | #[Test]
162 | public function it_can_change_the_personal_access_token()
163 | {
164 | $this->client->setPersonalAccessToken('12345678');
165 |
166 | $this->assertEquals('12345678', $this->client->getPersonalAccessToken());
167 | }
168 | }
169 |
--------------------------------------------------------------------------------
/tests/GitlabAdapterTest.php:
--------------------------------------------------------------------------------
1 | gitlabAdapter = $this->getAdapterInstance();
29 | }
30 |
31 | #[Test]
32 | public function it_can_be_instantiated()
33 | {
34 | $this->assertInstanceOf(GitlabAdapter::class, $this->getAdapterInstance());
35 | }
36 |
37 | #[Test]
38 | public function it_can_retrieve_client_instance()
39 | {
40 | $this->assertInstanceOf(Client::class, $this->gitlabAdapter->getClient());
41 | }
42 |
43 | #[Test]
44 | public function it_can_set_client_instance()
45 | {
46 | $this->setInvalidProjectId();
47 |
48 | $this->assertEquals($this->gitlabAdapter->getClient()
49 | ->getProjectId(), '123');
50 | }
51 |
52 | #[Test]
53 | public function it_can_read_a_file()
54 | {
55 | $response = $this->gitlabAdapter->read('README.md');
56 |
57 | $this->assertStringStartsWith('# Testing repo for `flysystem-gitlab`', $response);
58 | }
59 |
60 | #[Test]
61 | public function it_can_read_a_file_into_a_stream()
62 | {
63 | $stream = $this->gitlabAdapter->readStream('README.md');
64 |
65 | $this->assertIsResource($stream);
66 | $this->assertEquals(stream_get_contents($stream, -1, 0), $this->gitlabAdapter->read('README.md'));
67 | }
68 |
69 | #[Test]
70 | public function it_throws_when_read_failed()
71 | {
72 | $this->setInvalidProjectId();
73 |
74 | $this->expectException(UnableToReadFile::class);
75 |
76 | $this->gitlabAdapter->read('README.md');
77 | }
78 |
79 | #[Test]
80 | public function it_can_determine_if_a_project_has_a_file()
81 | {
82 | $this->assertTrue($this->gitlabAdapter->fileExists('/README.md'));
83 |
84 | $this->assertFalse($this->gitlabAdapter->fileExists('/I_DONT_EXIST.md'));
85 | }
86 |
87 | #[Test]
88 | public function it_throws_when_file_existence_failed()
89 | {
90 | $this->setInvalidToken();
91 |
92 | $this->expectException(UnableToCheckFileExistence::class);
93 |
94 | $this->gitlabAdapter->fileExists('/README.md');
95 | }
96 |
97 | #[Test]
98 | public function it_can_delete_a_file()
99 | {
100 | $this->gitlabAdapter->write('testing.md', '# Testing create', new Config());
101 |
102 | $this->assertTrue($this->gitlabAdapter->fileExists('/testing.md'));
103 |
104 | $this->gitlabAdapter->delete('/testing.md');
105 |
106 | $this->assertFalse($this->gitlabAdapter->fileExists('/testing.md'));
107 | }
108 |
109 | #[Test]
110 | public function it_returns_false_when_delete_failed()
111 | {
112 | $this->setInvalidProjectId();
113 |
114 | $this->expectException(UnableToDeleteFile::class);
115 |
116 | $this->gitlabAdapter->delete('testing_renamed.md');
117 | }
118 |
119 | #[Test]
120 | public function it_can_write_a_new_file()
121 | {
122 | $this->gitlabAdapter->write('testing.md', '# Testing create', new Config());
123 |
124 | $this->assertTrue($this->gitlabAdapter->fileExists('testing.md'));
125 | $this->assertEquals('# Testing create', $this->gitlabAdapter->read('testing.md'));
126 |
127 | $this->gitlabAdapter->delete('testing.md');
128 | }
129 |
130 | #[Test]
131 | public function it_automatically_creates_missing_directories()
132 | {
133 | $this->gitlabAdapter->write('/folder/missing/testing.md', '# Testing create folders', new Config());
134 |
135 | $this->assertTrue($this->gitlabAdapter->fileExists('/folder/missing/testing.md'));
136 | $this->assertEquals('# Testing create folders', $this->gitlabAdapter->read('/folder/missing/testing.md'));
137 |
138 | $this->gitlabAdapter->delete('/folder/missing/testing.md');
139 | }
140 |
141 | #[Test]
142 | public function it_throws_when_write_failed()
143 | {
144 | $this->setInvalidProjectId();
145 |
146 | $this->expectException(UnableToWriteFile::class);
147 |
148 | $this->gitlabAdapter->write('testing.md', '# Testing create', new Config());
149 | }
150 |
151 | #[Test]
152 | public function it_can_write_a_file_stream()
153 | {
154 | $stream = fopen(__DIR__.'/assets/testing.txt', 'r+');
155 | $this->gitlabAdapter->writeStream('testing.txt', $stream, new Config());
156 | fclose($stream);
157 |
158 | $this->assertTrue($this->gitlabAdapter->fileExists('testing.txt'));
159 | $this->assertEquals('File for testing file streams', $this->gitlabAdapter->read('testing.txt'));
160 |
161 | $this->gitlabAdapter->delete('testing.txt');
162 | }
163 |
164 | #[Test]
165 | public function it_throws_when_writing_file_stream_failed()
166 | {
167 | $this->setInvalidProjectId();
168 |
169 | $this->expectException(UnableToWriteFile::class);
170 |
171 | $stream = fopen(__DIR__.'/assets/testing.txt', 'r+');
172 | $this->gitlabAdapter->writeStream('testing.txt', $stream, new Config());
173 | fclose($stream);
174 | }
175 |
176 | #[Test]
177 | public function it_can_override_a_file()
178 | {
179 | $this->gitlabAdapter->write('testing.md', '# Testing create', new Config());
180 | $this->gitlabAdapter->write('testing.md', '# Testing update', new Config());
181 |
182 | $this->assertStringStartsWith($this->gitlabAdapter->read('testing.md'), '# Testing update');
183 |
184 | $this->gitlabAdapter->delete('testing.md');
185 | }
186 |
187 | #[Test]
188 | public function it_can_override_with_a_file_stream()
189 | {
190 | $stream = fopen(__DIR__.'/assets/testing.txt', 'r+');
191 | $this->gitlabAdapter->writeStream('testing.txt', $stream, new Config());
192 | fclose($stream);
193 |
194 | $stream = fopen(__DIR__.'/assets/testing-update.txt', 'r+');
195 | $this->gitlabAdapter->writeStream('testing.txt', $stream, new Config());
196 | fclose($stream);
197 |
198 | $this->assertTrue($this->gitlabAdapter->fileExists('testing.txt'));
199 | $this->assertEquals('File for testing file streams!', $this->gitlabAdapter->read('testing.txt'));
200 |
201 | $this->gitlabAdapter->delete('testing.txt');
202 | }
203 |
204 | #[Test]
205 | public function it_can_move_a_file()
206 | {
207 | $this->gitlabAdapter->write('testing.md', '# Testing move', new Config());
208 |
209 | $this->gitlabAdapter->move('testing.md', 'testing_move.md', new Config());
210 |
211 | $this->assertFalse($this->gitlabAdapter->fileExists('testing.md'));
212 | $this->assertTrue($this->gitlabAdapter->fileExists('testing_move.md'));
213 |
214 | $this->assertEquals('# Testing move', $this->gitlabAdapter->read('testing_move.md'));
215 |
216 | $this->gitlabAdapter->delete('testing_move.md');
217 | }
218 |
219 | #[Test]
220 | public function it_throws_when_move_failed()
221 | {
222 | $this->setInvalidProjectId();
223 |
224 | $this->expectException(UnableToMoveFile::class);
225 |
226 | $this->gitlabAdapter->move('testing_move.md', 'testing.md', new Config());
227 | }
228 |
229 | #[Test]
230 | public function it_can_copy_a_file()
231 | {
232 | $this->gitlabAdapter->write('testing.md', '# Testing copy', new Config());
233 |
234 | $this->gitlabAdapter->copy('testing.md', 'testing_copy.md', new Config());
235 |
236 | $this->assertTrue($this->gitlabAdapter->fileExists('testing.md'));
237 | $this->assertTrue($this->gitlabAdapter->fileExists('testing_copy.md'));
238 |
239 | $this->assertEquals($this->gitlabAdapter->read('testing.md'), '# Testing copy');
240 | $this->assertEquals($this->gitlabAdapter->read('testing_copy.md'), '# Testing copy');
241 |
242 | $this->gitlabAdapter->delete('testing.md');
243 | $this->gitlabAdapter->delete('testing_copy.md');
244 | }
245 |
246 | #[Test]
247 | public function it_throws_when_copy_failed()
248 | {
249 | $this->setInvalidProjectId();
250 |
251 | $this->expectException(UnableToCopyFile::class);
252 |
253 | $this->gitlabAdapter->copy('testing_copy.md', 'testing.md', new Config());
254 | }
255 |
256 | #[Test]
257 | public function it_can_create_a_directory()
258 | {
259 | $this->gitlabAdapter->createDirectory('/testing', new Config());
260 |
261 | $this->assertTrue($this->gitlabAdapter->fileExists('/testing/.gitkeep'));
262 |
263 | $this->gitlabAdapter->delete('/testing/.gitkeep');
264 | }
265 |
266 | #[Test]
267 | public function it_can_retrieve_a_list_of_contents_of_root()
268 | {
269 | $list = $this->gitlabAdapter->listContents('/', false);
270 | $expectedPaths = [
271 | ['type' => 'dir', 'path' => 'recursive'],
272 | ['type' => 'file', 'path' => 'LICENSE'],
273 | ['type' => 'file', 'path' => 'README.md'],
274 | ['type' => 'file', 'path' => 'test'],
275 | ['type' => 'file', 'path' => 'test2'],
276 | ];
277 |
278 | foreach ($list as $item) {
279 | $this->assertInstanceOf(StorageAttributes::class, $item);
280 | $this->assertContains(
281 | ['type' => $item['type'], 'path' => $item['path']], $expectedPaths
282 | );
283 | }
284 | }
285 |
286 | #[Test]
287 | public function it_can_retrieve_a_list_of_contents_of_root_recursive()
288 | {
289 | $list = $this->gitlabAdapter->listContents('/', true);
290 | $expectedPaths = [
291 | ['type' => 'dir', 'path' => 'recursive'],
292 | ['type' => 'dir', 'path' => 'recursive/level-1'],
293 | ['type' => 'dir', 'path' => 'recursive/level-1/level-2'],
294 | ['type' => 'file', 'path' => 'LICENSE'],
295 | ['type' => 'file', 'path' => 'README.md'],
296 | ['type' => 'file', 'path' => 'recursive/recursive.testing.md'],
297 | ['type' => 'file', 'path' => 'recursive/level-1/level-2/.gitkeep'],
298 | ['type' => 'file', 'path' => 'test'],
299 | ['type' => 'file', 'path' => 'test2'],
300 | ];
301 |
302 | foreach ($list as $item) {
303 | $this->assertInstanceOf(StorageAttributes::class, $item);
304 | $this->assertContains(
305 | ['type' => $item['type'], 'path' => $item['path']], $expectedPaths
306 | );
307 | }
308 | }
309 |
310 | #[Test]
311 | public function it_can_retrieve_a_list_of_contents_of_sub_folder()
312 | {
313 | $list = $this->gitlabAdapter->listContents('/recursive', false);
314 | $expectedPaths = [
315 | ['type' => 'dir', 'path' => 'recursive/level-1'],
316 | ['type' => 'dir', 'path' => 'recursive/level-1/level-2'],
317 | ['type' => 'file', 'path' => 'recursive/recursive.testing.md'],
318 | ['type' => 'file', 'path' => 'recursive/level-1/level-2/.gitkeep'],
319 | ];
320 |
321 | foreach ($list as $item) {
322 | $this->assertInstanceOf(StorageAttributes::class, $item);
323 | $this->assertContains(
324 | ['type' => $item['type'], 'path' => $item['path']], $expectedPaths
325 | );
326 | }
327 | }
328 |
329 | #[Test]
330 | public function it_can_retrieve_a_list_of_contents_of_deep_sub_folder()
331 | {
332 | $list = $this->gitlabAdapter->listContents('/recursive/level-1/level-2', false);
333 | $expectedPaths = [
334 | ['type' => 'file', 'path' => 'recursive/level-1/level-2/.gitkeep'],
335 | ];
336 |
337 | foreach ($list as $item) {
338 | $this->assertInstanceOf(StorageAttributes::class, $item);
339 | $this->assertContains(
340 | ['type' => $item['type'], 'path' => $item['path']], $expectedPaths
341 | );
342 | }
343 | }
344 |
345 | #[Test]
346 | public function it_can_delete_a_directory()
347 | {
348 | $this->gitlabAdapter->createDirectory('/testing', new Config());
349 | $this->gitlabAdapter->write('/testing/testing.md', 'Testing delete directory', new Config());
350 |
351 | $this->gitlabAdapter->deleteDirectory('/testing');
352 |
353 | $this->assertFalse($this->gitlabAdapter->fileExists('/testing/.gitkeep'));
354 | $this->assertFalse($this->gitlabAdapter->fileExists('/testing/testing.md'));
355 | }
356 |
357 | #[Test]
358 | public function it_throws_when_delete_directory_failed()
359 | {
360 | $this->setInvalidProjectId();
361 |
362 | $this->expectException(FilesystemException::class);
363 |
364 | $this->gitlabAdapter->deleteDirectory('/testing');
365 | }
366 |
367 | #[Test]
368 | public function it_can_retrieve_size()
369 | {
370 | $size = $this->gitlabAdapter->fileSize('README.md');
371 |
372 | $this->assertInstanceOf(FileAttributes::class, $size);
373 | $this->assertEquals(37, $size->fileSize());
374 | }
375 |
376 | #[Test]
377 | public function it_can_retrieve_mimetype()
378 | {
379 | $metadata = $this->gitlabAdapter->mimeType('README.md');
380 |
381 | $this->assertInstanceOf(FileAttributes::class, $metadata);
382 | $this->assertEquals('text/markdown', $metadata->mimeType());
383 | }
384 |
385 | #[Test]
386 | public function it_can_not_retrieve_lastModified()
387 | {
388 | $lastModified = $this->gitlabAdapter->lastModified('README.md');
389 |
390 | $this->assertInstanceOf(FileAttributes::class, $lastModified);
391 | $this->assertEquals(1606750652, $lastModified->lastModified());
392 | }
393 |
394 | #[Test]
395 | public function it_throws_when_getting_visibility()
396 | {
397 | $this->expectException(UnableToSetVisibility::class);
398 |
399 | $this->gitlabAdapter->visibility('README.md');
400 | }
401 |
402 | #[Test]
403 | public function it_throws_when_setting_visibility()
404 | {
405 | $this->expectException(UnableToSetVisibility::class);
406 |
407 | $this->gitlabAdapter->setVisibility('README.md', 0777);
408 | }
409 |
410 | #[Test]
411 | public function it_can_check_directory_if_exists()
412 | {
413 | $dir = 'test-dir/test-dir2/test-dir3';
414 | $this->gitlabAdapter->createDirectory($dir, new Config());
415 | $this->assertTrue($this->gitlabAdapter->directoryExists($dir));
416 | $this->gitlabAdapter->deleteDirectory($dir);
417 | }
418 |
419 | #[Test]
420 | public function it_cannot_check_if_directory_exists()
421 | {
422 | $this->assertFalse($this->gitlabAdapter->directoryExists('test_non_existent_dir'));
423 | }
424 |
425 | private function setInvalidToken()
426 | {
427 | $client = $this->gitlabAdapter->getClient();
428 | $client->setPersonalAccessToken('123');
429 | $this->gitlabAdapter->setClient($client);
430 | }
431 |
432 | private function setInvalidProjectId()
433 | {
434 | $client = $this->gitlabAdapter->getClient();
435 | $client->setProjectId('123');
436 | $this->gitlabAdapter->setClient($client);
437 | }
438 | }
439 |
--------------------------------------------------------------------------------
/tests/TestCase.php:
--------------------------------------------------------------------------------
1 | config = require(__DIR__.'/config/config.testing.php');
16 | }
17 |
18 | protected function getClientInstance(): Client
19 | {
20 | return new Client($this->config[ 'project-id' ], $this->config[ 'branch' ], $this->config[ 'base-url' ],
21 | $this->config[ 'personal-access-token' ]);
22 | }
23 |
24 | protected function getAdapterInstance(): GitlabAdapter
25 | {
26 | return new GitlabAdapter($this->getClientInstance());
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/tests/assets/testing-update.txt:
--------------------------------------------------------------------------------
1 | File for testing file streams!
--------------------------------------------------------------------------------
/tests/assets/testing.txt:
--------------------------------------------------------------------------------
1 | File for testing file streams
--------------------------------------------------------------------------------
/tests/config/config.testing.example.php:
--------------------------------------------------------------------------------
1 | 'your-access-token',
21 |
22 | /**
23 | * Project id of your repo
24 | */
25 | 'project-id' => 'your-project-id',
26 |
27 | /**
28 | * Branch that should be used
29 | */
30 | 'branch' => 'master',
31 |
32 | /**
33 | * Base URL of Gitlab server you want to use
34 | */
35 | 'base-url' => 'https://gitlab.com',
36 | ];
37 |
--------------------------------------------------------------------------------