Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 45 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -463,6 +463,14 @@ This ensures consistent Velox versions across different environments and team me
<repository type="github" uri="vimeo/psalm" />
<binary name="psalm.phar" pattern="/^psalm\.phar$/" />
</software>

<!-- GitLab repository -->
<software name="My cool project" alias="cool-project"
homepage="https://gitlab.com/path/to/my/repository"
description="">
<repository type="gitlab" uri="path/to/my/repository" asset-pattern="/^cool-.*/" />
<binary name="cool" pattern="/^cool-.*/" />
</software>
</registry>
</dload>
```
Expand Down Expand Up @@ -553,16 +561,52 @@ Each developer gets the correct binaries for their system:
</actions>
```

## GitHub API Rate Limits
## API Rate Limits

Use a personal access token to avoid rate limits:

```bash
GITHUB_TOKEN=your_token_here ./vendor/bin/dload get
GITLAB_TOKEN=your_token_here ./vendor/bin/dload get
```

Add to CI/CD environment variables for automated downloads.

## Gitlab CI configuration

When you make a release in Gitlab, make sure to upload your assets to the release page via
package manager. This can easily be done via Gitlab CLI and the `glab release upload --use-package-registry`
command.

```
# .gitlab-ci.yml

Build artifacts:
stage: push
script:
- mkdir bin
- echo "Mock binary for darwin arm" > bin/cool-darwin-arm64
- echo "Mock binary for darwin amd" > bin/cool-darwin-amd64
- echo "Mock binary for linux arm" > bin/cool-linux-arm64
- echo "Mock binary for linux amd" > bin/cool-linux-amd64
artifacts:
expire_in: 2 hours
paths:
- $CI_PROJECT_DIR/bin/cool-*
rules:
- if: $CI_COMMIT_TAG

Release artifacts:
stage: deploy
image: gitlab/glab:latest
needs: [ "Build artifacts" ]
script:
- glab auth login --job-token $CI_JOB_TOKEN --hostname $CI_SERVER_HOST
- glab release upload --use-package-registry "$CI_COMMIT_TAG" ./bin/*
rules:
- if: $CI_COMMIT_TAG
```

## Contributing

Contributions welcome! Submit Pull Requests to:
Expand Down
4 changes: 3 additions & 1 deletion src/Bootstrap.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
use Internal\DLoad\Module\HttpClient\Factory;
use Internal\DLoad\Module\HttpClient\Internal\NyholmFactoryImpl;
use Internal\DLoad\Module\Repository\Internal\GitHub\Factory as GithubRepositoryFactory;
use Internal\DLoad\Module\Repository\Internal\GitLab\Factory as GitLabRepositoryFactory;
use Internal\DLoad\Module\Repository\RepositoryProvider;
use Internal\DLoad\Module\Velox\ApiClient;
use Internal\DLoad\Module\Velox\Builder;
Expand Down Expand Up @@ -106,7 +107,8 @@ public function withConfig(
$this->container->bind(
RepositoryProvider::class,
static fn(Container $container): RepositoryProvider => (new RepositoryProvider())
->addRepositoryFactory($container->get(GithubRepositoryFactory::class)),
->addRepositoryFactory($container->get(GithubRepositoryFactory::class))
->addRepositoryFactory($container->get(GitLabRepositoryFactory::class)),
);
$this->container->bind(BinaryProvider::class, BinaryProviderImpl::class);
$this->container->bind(Factory::class, NyholmFactoryImpl::class);
Expand Down
21 changes: 21 additions & 0 deletions src/Module/Config/Schema/GitLab.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

declare(strict_types=1);

namespace Internal\DLoad\Module\Config\Schema;

use Internal\DLoad\Module\Common\Internal\Attribute\Env;

/**
* GitLab API configuration.
*
* Contains authentication settings for GitLab API access.
*
* @internal
*/
final class GitLab
{
/** @var string|null $token API token for GitLab authentication */
#[Env('GITLAB_TOKEN')]
public ?string $token = null;
}
89 changes: 89 additions & 0 deletions src/Module/Repository/Internal/GitLab/Api/Client.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
<?php

declare(strict_types=1);

namespace Internal\DLoad\Module\Repository\Internal\GitLab\Api;

use Internal\DLoad\Module\Config\Schema\GitLab;
use Internal\DLoad\Module\HttpClient\Factory as HttpFactory;
use Internal\DLoad\Module\HttpClient\Method;
use Internal\DLoad\Module\Repository\Internal\GitLab\Exception\GitLabRateLimitException;
use Psr\Http\Client\ClientExceptionInterface;
use Psr\Http\Client\ClientInterface;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\UriInterface;

/**
* HTTP client wrapper with GitLab-specific error handling and authentication.
*
* Detects and handles GitLab Rate Limit responses automatically.
* Adds GitLab API token authentication when available.
*
* @internal
* @psalm-internal Internal\DLoad\Module\Repository\Internal\GitLab
*/
final class Client
{
/**
* @var array<non-empty-string, non-empty-string>
*/
private array $defaultHeaders = [
'accept' => 'application/json',
];

public function __construct(
private readonly HttpFactory $httpFactory,
private readonly ClientInterface $client,
private readonly GitLab $gitLabConfig,
) {
// Add authorization header if token is available
$this->gitLabConfig->token !== null and $this->defaultHeaders['authorization'] = 'Bearer ' . $this->gitLabConfig->token;
}

/**
* @throws GitLabRateLimitException
* @throws ClientExceptionInterface
*/
public function downloadArtifact(string|UriInterface $uri): ResponseInterface
{
$headers = [];
if ($this->gitLabConfig->token !== null) {
$headers = [
'PRIVATE-TOKEN' => $this->gitLabConfig->token,
];
}

$request = $this->httpFactory->request(Method::Get, $uri, $headers);

return $this->sendRequest($request);
}

/**
* @param Method|non-empty-string $method
* @param array<string, string> $headers
* @throws GitLabRateLimitException
* @throws ClientExceptionInterface
*/
public function request(Method|string $method, string|UriInterface $uri, array $headers = []): ResponseInterface
{
$request = $this->httpFactory->request($method, $uri, $headers + $this->defaultHeaders);

return $this->sendRequest($request);
}

/**
* @throws GitLabRateLimitException
* @throws ClientExceptionInterface
*/
public function sendRequest(RequestInterface $request): ResponseInterface
{
$response = $this->client->sendRequest($request);

if ($response->getStatusCode() === 429) {
throw new GitLabRateLimitException();
}

return $response;
}
}
179 changes: 179 additions & 0 deletions src/Module/Repository/Internal/GitLab/Api/RepositoryApi.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
<?php

declare(strict_types=1);

namespace Internal\DLoad\Module\Repository\Internal\GitLab\Api;

use Internal\DLoad\Module\HttpClient\Factory as HttpFactory;
use Internal\DLoad\Module\HttpClient\Method;
use Internal\DLoad\Module\Repository\Internal\GitLab\Api\Response\ReleaseInfo;
use Internal\DLoad\Module\Repository\Internal\GitLab\Api\Response\RepositoryInfo;
use Internal\DLoad\Module\Repository\Internal\GitLab\Exception\GitLabRateLimitException;
use Internal\DLoad\Module\Repository\Internal\Paginator;
use Psr\Http\Client\ClientExceptionInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\UriInterface;

/**
* API client for specific GitLab repository operations.
*
* Bound to specific owner/repo pair and provides typed methods for GitLab API operations.
*
* @internal
* @psalm-internal Internal\DLoad\Module\Repository\Internal\GitLab
*/
final class RepositoryApi
{
private const URL_REPOSITORY = 'https://gitlab.com/api/v4/projects/%s';
private const URL_RELEASES = 'https://gitlab.com/api/v4/projects/%s/releases';
private const URL_RELEASE_ASSET = 'https://gitlab.com/api/v4/projects/%s/releases/%s/downloads/%s';

/**
* @var non-empty-string
*/
public readonly string $repositoryPath;

public function __construct(
private readonly Client $client,
private readonly HttpFactory $httpFactory,
string $projectPath,
) {
$this->repositoryPath = $projectPath;
}

/**
* @param non-empty-string $repositoryPath
* @param non-empty-string $releaseName
* @param non-empty-string $fileName
* @throws GitLabRateLimitException
* @throws ClientExceptionInterface
*/
public function downloadArtifact(string $repositoryPath, string $releaseName, string $fileName): ResponseInterface
{
$url = \sprintf(self::URL_RELEASE_ASSET, \urlencode($repositoryPath), $releaseName, $fileName);
return $this->client->downloadArtifact($url);
}

/**
* @param Method|non-empty-string $method
* @param array<string, string> $headers
* @throws GitLabRateLimitException
* @throws ClientExceptionInterface
*/
public function request(Method|string $method, string|UriInterface $uri, array $headers = []): ResponseInterface
{
return $this->client->request($method, $uri, $headers);
}

/**
* @throws GitLabRateLimitException
* @throws ClientExceptionInterface
*/
public function getRepository(): RepositoryInfo
{
$response = $this->request(Method::Get, \sprintf(self::URL_REPOSITORY, \urlencode($this->repositoryPath)));

/** @var array{
* name: string,
* name_with_namespace: string,
* description: string|null,
* web_url: string,
* visibility: bool,
* created_at: string,
* updated_at: string
* } $data */
$data = \json_decode($response->getBody()->__toString(), true, 512, JSON_THROW_ON_ERROR);

return RepositoryInfo::fromApiResponse($data);
}

/**
* @param int<1, max> $page
* @return Paginator<ReleaseInfo>
* @throws GitLabRateLimitException
* @throws ClientExceptionInterface
*/
public function getReleases(int $page = 1): Paginator
{
$pageLoader = function () use ($page): \Generator {
$currentPage = $page;

do {
try {
$response = $this->releasesRequest($currentPage);

/** @var array<array-key, array{
* name: non-empty-string|null,
* tag_name: non-empty-string,
* description: null|non-empty-string,
* created_at: non-empty-string,
* released_at: non-empty-string,
* assets: array{
* links: list<array{
* name: non-empty-string,
* url: non-empty-string,
* direct_asset_url?: non-empty-string,
* link_type: non-empty-string,
* }>
* },
* upcoming_release: bool
* }> $data */
$data = \json_decode($response->getBody()->__toString(), true, 512, JSON_THROW_ON_ERROR);

// If empty response, no more pages
if ($data === []) {
return;
}

$releases = [];
foreach ($data as $releaseData) {
try {
$releases[] = ReleaseInfo::fromApiResponse($releaseData);
} catch (\Throwable) {
// Skip invalid releases
continue;
}
}

yield $releases;

// Check if there are more pages
$hasMorePages = $this->hasNextPage($response);
$currentPage++;
} catch (ClientExceptionInterface) {
return;
}
} while ($hasMorePages);
};

return Paginator::createFromGenerator($pageLoader(), null);
}

/**
* @param positive-int $page
* @throws GitLabRateLimitException
* @throws ClientExceptionInterface
*/
private function releasesRequest(int $page): ResponseInterface
{
return $this->request(
Method::Get,
$this->httpFactory->uri(
\sprintf(self::URL_RELEASES, \urlencode($this->repositoryPath)),
['page' => $page],
),
);
}

private function hasNextPage(ResponseInterface $response): bool
{
$headers = $response->getHeaders();
$link = $headers['link'] ?? [];

if (!isset($link[0])) {
return false;
}

return \str_contains($link[0], 'rel="next"');
}
}
Loading
Loading