From e35aa8d878e063967fe2a84f5e3332e1918e3fae Mon Sep 17 00:00:00 2001 From: logikonline Date: Fri, 9 Jan 2026 12:19:17 -0500 Subject: [PATCH] sdk: add C# and Java SDK libraries with chunked upload support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both SDKs provide: - Full API client for users, repositories, and releases - Chunked upload with parallel workers - Progress tracking with speed/ETA - SHA256 checksum verification - Comprehensive exception handling C# SDK (.NET 8.0): - Modern record types for models - Async/await pattern throughout - System.Text.Json serialization Java SDK (Java 17): - Standard Maven project - Jackson for JSON - HttpClient for HTTP - ExecutorService for parallel uploads 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- sdk/csharp/Gitea.SDK/ChunkedUpload.cs | 172 ++++++++++ sdk/csharp/Gitea.SDK/Exceptions.cs | 84 +++++ sdk/csharp/Gitea.SDK/Gitea.SDK.csproj | 26 ++ sdk/csharp/Gitea.SDK/GiteaClient.cs | 268 ++++++++++++++++ sdk/csharp/Gitea.SDK/Models.cs | 161 ++++++++++ sdk/csharp/README.md | 165 ++++++++++ sdk/java/README.md | 182 +++++++++++ sdk/java/pom.xml | 111 +++++++ .../main/java/io/gitea/sdk/ChunkedUpload.java | 195 ++++++++++++ .../main/java/io/gitea/sdk/GiteaClient.java | 297 ++++++++++++++++++ .../io/gitea/sdk/exceptions/ApiException.java | 26 ++ .../exceptions/AuthenticationException.java | 13 + .../sdk/exceptions/ChecksumException.java | 26 ++ .../gitea/sdk/exceptions/GiteaException.java | 17 + .../sdk/exceptions/NotFoundException.java | 13 + .../gitea/sdk/exceptions/UploadException.java | 36 +++ .../io/gitea/sdk/models/ApiErrorResponse.java | 37 +++ .../java/io/gitea/sdk/models/Attachment.java | 66 ++++ .../sdk/models/ChunkedUploadOptions.java | 81 +++++ .../java/io/gitea/sdk/models/Progress.java | 124 ++++++++ .../java/io/gitea/sdk/models/Release.java | 86 +++++ .../java/io/gitea/sdk/models/Repository.java | 123 ++++++++ .../io/gitea/sdk/models/UploadResult.java | 55 ++++ .../io/gitea/sdk/models/UploadSession.java | 93 ++++++ .../main/java/io/gitea/sdk/models/User.java | 64 ++++ 25 files changed, 2521 insertions(+) create mode 100644 sdk/csharp/Gitea.SDK/ChunkedUpload.cs create mode 100644 sdk/csharp/Gitea.SDK/Exceptions.cs create mode 100644 sdk/csharp/Gitea.SDK/Gitea.SDK.csproj create mode 100644 sdk/csharp/Gitea.SDK/GiteaClient.cs create mode 100644 sdk/csharp/Gitea.SDK/Models.cs create mode 100644 sdk/csharp/README.md create mode 100644 sdk/java/README.md create mode 100644 sdk/java/pom.xml create mode 100644 sdk/java/src/main/java/io/gitea/sdk/ChunkedUpload.java create mode 100644 sdk/java/src/main/java/io/gitea/sdk/GiteaClient.java create mode 100644 sdk/java/src/main/java/io/gitea/sdk/exceptions/ApiException.java create mode 100644 sdk/java/src/main/java/io/gitea/sdk/exceptions/AuthenticationException.java create mode 100644 sdk/java/src/main/java/io/gitea/sdk/exceptions/ChecksumException.java create mode 100644 sdk/java/src/main/java/io/gitea/sdk/exceptions/GiteaException.java create mode 100644 sdk/java/src/main/java/io/gitea/sdk/exceptions/NotFoundException.java create mode 100644 sdk/java/src/main/java/io/gitea/sdk/exceptions/UploadException.java create mode 100644 sdk/java/src/main/java/io/gitea/sdk/models/ApiErrorResponse.java create mode 100644 sdk/java/src/main/java/io/gitea/sdk/models/Attachment.java create mode 100644 sdk/java/src/main/java/io/gitea/sdk/models/ChunkedUploadOptions.java create mode 100644 sdk/java/src/main/java/io/gitea/sdk/models/Progress.java create mode 100644 sdk/java/src/main/java/io/gitea/sdk/models/Release.java create mode 100644 sdk/java/src/main/java/io/gitea/sdk/models/Repository.java create mode 100644 sdk/java/src/main/java/io/gitea/sdk/models/UploadResult.java create mode 100644 sdk/java/src/main/java/io/gitea/sdk/models/UploadSession.java create mode 100644 sdk/java/src/main/java/io/gitea/sdk/models/User.java diff --git a/sdk/csharp/Gitea.SDK/ChunkedUpload.cs b/sdk/csharp/Gitea.SDK/ChunkedUpload.cs new file mode 100644 index 0000000000..8e4bb788b1 --- /dev/null +++ b/sdk/csharp/Gitea.SDK/ChunkedUpload.cs @@ -0,0 +1,172 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +using System.Diagnostics; +using System.Security.Cryptography; + +namespace Gitea.SDK; + +/// +/// Handles chunked file uploads with parallel workers. +/// +public class ChunkedUpload +{ + private readonly GiteaClient _client; + private readonly string _owner; + private readonly string _repo; + private readonly long _releaseId; + private readonly ChunkedUploadOptions _options; + private UploadSession? _session; + + internal ChunkedUpload( + GiteaClient client, + string owner, + string repo, + long releaseId, + ChunkedUploadOptions options) + { + _client = client; + _owner = owner; + _repo = repo; + _releaseId = releaseId; + _options = options; + } + + /// + /// Gets the current upload session. + /// + public UploadSession? Session => _session; + + /// + /// Uploads a file using chunked upload. + /// + public async Task UploadAsync( + Stream fileStream, + string filename, + CancellationToken cancellationToken = default) + { + var fileSize = fileStream.Length; + var chunkSize = _options.ChunkSize; + var totalChunks = (int)Math.Ceiling((double)fileSize / chunkSize); + + // Calculate checksum if requested + string? checksum = null; + if (_options.VerifyChecksum) + { + checksum = await ComputeChecksumAsync(fileStream, cancellationToken); + fileStream.Position = 0; + } + + // Create upload session + _session = await CreateSessionAsync(filename, fileSize, chunkSize, totalChunks, checksum, cancellationToken); + + // Track progress + var stopwatch = Stopwatch.StartNew(); + var chunksCompleted = 0; + var bytesCompleted = 0L; + var progressLock = new object(); + + // Upload chunks in parallel + var semaphore = new SemaphoreSlim(_options.Parallel); + var tasks = new List(); + var chunks = new List<(int Index, byte[] Data)>(); + + // Read all chunks + for (var i = 0; i < totalChunks; i++) + { + var offset = i * chunkSize; + var size = (int)Math.Min(chunkSize, fileSize - offset); + var buffer = new byte[size]; + fileStream.Position = offset; + await fileStream.ReadAsync(buffer.AsMemory(0, size), cancellationToken); + chunks.Add((i, buffer)); + } + + // Upload chunks + foreach (var (index, data) in chunks) + { + await semaphore.WaitAsync(cancellationToken); + + var task = Task.Run(async () => + { + try + { + await UploadChunkAsync(_session.Id, index, data, cancellationToken); + + lock (progressLock) + { + chunksCompleted++; + bytesCompleted += data.Length; + + var elapsed = stopwatch.Elapsed.TotalSeconds; + var speed = elapsed > 0 ? bytesCompleted / elapsed : 0; + var remaining = fileSize - bytesCompleted; + var eta = speed > 0 ? TimeSpan.FromSeconds(remaining / speed) : TimeSpan.Zero; + + _options.OnProgress?.Invoke(new Progress + { + BytesDone = bytesCompleted, + BytesTotal = fileSize, + ChunksDone = chunksCompleted, + ChunksTotal = totalChunks, + Percent = (double)bytesCompleted / fileSize * 100, + Speed = speed, + Eta = eta + }); + } + } + finally + { + semaphore.Release(); + } + }, cancellationToken); + + tasks.Add(task); + } + + await Task.WhenAll(tasks); + + // Complete the upload + return await CompleteUploadAsync(_session.Id, cancellationToken); + } + + private async Task ComputeChecksumAsync(Stream stream, CancellationToken cancellationToken) + { + using var sha256 = SHA256.Create(); + var hash = await sha256.ComputeHashAsync(stream, cancellationToken); + return Convert.ToHexString(hash).ToLowerInvariant(); + } + + private async Task CreateSessionAsync( + string filename, + long fileSize, + long chunkSize, + int totalChunks, + string? checksum, + CancellationToken cancellationToken) + { + var path = $"/api/v1/repos/{Uri.EscapeDataString(_owner)}/{Uri.EscapeDataString(_repo)}/releases/{_releaseId}/assets/upload"; + var body = new + { + filename, + file_size = fileSize, + chunk_size = chunkSize, + total_chunks = totalChunks, + checksum + }; + + return await _client.PostAsync(path, body, cancellationToken); + } + + private async Task UploadChunkAsync(string sessionId, int chunkIndex, byte[] data, CancellationToken cancellationToken) + { + var path = $"/api/v1/repos/uploads/{Uri.EscapeDataString(sessionId)}/chunks/{chunkIndex}"; + await _client.PutBinaryAsync(path, data, cancellationToken); + } + + private async Task CompleteUploadAsync(string sessionId, CancellationToken cancellationToken) + { + var path = $"/api/v1/repos/uploads/{Uri.EscapeDataString(sessionId)}/complete"; + return await _client.PostAsync(path, null, cancellationToken); + } +} diff --git a/sdk/csharp/Gitea.SDK/Exceptions.cs b/sdk/csharp/Gitea.SDK/Exceptions.cs new file mode 100644 index 0000000000..94cec4223d --- /dev/null +++ b/sdk/csharp/Gitea.SDK/Exceptions.cs @@ -0,0 +1,84 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +namespace Gitea.SDK; + +/// +/// Base exception for all Gitea SDK errors. +/// +public class GiteaException : Exception +{ + public GiteaException(string message) : base(message) { } + public GiteaException(string message, Exception inner) : base(message, inner) { } +} + +/// +/// Thrown when authentication fails (401). +/// +public class AuthenticationException : GiteaException +{ + public AuthenticationException(string message) : base(message) { } +} + +/// +/// Thrown when a resource is not found (404). +/// +public class NotFoundException : GiteaException +{ + public NotFoundException(string message) : base(message) { } +} + +/// +/// Thrown when the API returns an error response. +/// +public class ApiException : GiteaException +{ + public string? Code { get; } + public int StatusCode { get; } + + public ApiException(string message, string? code, int statusCode) + : base(message) + { + Code = code; + StatusCode = statusCode; + } +} + +/// +/// Thrown when an upload operation fails. +/// +public class UploadException : GiteaException +{ + public string? SessionId { get; } + public int? ChunkNumber { get; } + + public UploadException(string message, string? sessionId = null, int? chunkNumber = null) + : base(message) + { + SessionId = sessionId; + ChunkNumber = chunkNumber; + } + + public UploadException(string message, Exception inner, string? sessionId = null, int? chunkNumber = null) + : base(message, inner) + { + SessionId = sessionId; + ChunkNumber = chunkNumber; + } +} + +/// +/// Thrown when checksum verification fails. +/// +public class ChecksumException : UploadException +{ + public string? Expected { get; } + public string? Actual { get; } + + public ChecksumException(string message, string? expected = null, string? actual = null, string? sessionId = null) + : base(message, sessionId) + { + Expected = expected; + Actual = actual; + } +} diff --git a/sdk/csharp/Gitea.SDK/Gitea.SDK.csproj b/sdk/csharp/Gitea.SDK/Gitea.SDK.csproj new file mode 100644 index 0000000000..de3d98adae --- /dev/null +++ b/sdk/csharp/Gitea.SDK/Gitea.SDK.csproj @@ -0,0 +1,26 @@ + + + + net8.0 + enable + enable + latest + + + Gitea.SDK + 1.0.0 + The Gitea Authors + Gitea + Official .NET SDK for the Gitea API with chunked upload support for large files + gitea;git;api;sdk;client;upload;chunked + MIT + https://docs.gitea.io/sdk/csharp + https://github.com/go-gitea/gitea.git + git + + + true + $(NoWarn);CS1591 + + + diff --git a/sdk/csharp/Gitea.SDK/GiteaClient.cs b/sdk/csharp/Gitea.SDK/GiteaClient.cs new file mode 100644 index 0000000000..98d0bc2de4 --- /dev/null +++ b/sdk/csharp/Gitea.SDK/GiteaClient.cs @@ -0,0 +1,268 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Gitea.SDK; + +/// +/// Client for the Gitea API. +/// +public class GiteaClient : IDisposable +{ + private readonly HttpClient _httpClient; + private readonly string _baseUrl; + private readonly JsonSerializerOptions _jsonOptions; + private bool _disposed; + + /// + /// Creates a new Gitea API client. + /// + /// Base URL of the Gitea instance + /// API token for authentication + /// Optional HttpClient instance + public GiteaClient(string baseUrl, string? token = null, HttpClient? httpClient = null) + { + _baseUrl = baseUrl.TrimEnd('/'); + _httpClient = httpClient ?? new HttpClient(); + _httpClient.DefaultRequestHeaders.UserAgent.Add( + new ProductInfoHeaderValue("Gitea.SDK", "1.0.0")); + + if (!string.IsNullOrEmpty(token)) + { + _httpClient.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue("token", token); + } + + _jsonOptions = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + } + + #region User Methods + + /// + /// Gets the currently authenticated user. + /// + public async Task GetCurrentUserAsync(CancellationToken cancellationToken = default) + { + return await GetAsync("/api/v1/user", cancellationToken); + } + + /// + /// Gets a user by username. + /// + public async Task GetUserAsync(string username, CancellationToken cancellationToken = default) + { + return await GetAsync($"/api/v1/users/{Uri.EscapeDataString(username)}", cancellationToken); + } + + #endregion + + #region Repository Methods + + /// + /// Gets a repository by owner and name. + /// + public async Task GetRepositoryAsync(string owner, string repo, CancellationToken cancellationToken = default) + { + return await GetAsync( + $"/api/v1/repos/{Uri.EscapeDataString(owner)}/{Uri.EscapeDataString(repo)}", + cancellationToken); + } + + /// + /// Lists repositories for a user. + /// + public async Task> ListUserRepositoriesAsync(string username, CancellationToken cancellationToken = default) + { + return await GetAsync>( + $"/api/v1/users/{Uri.EscapeDataString(username)}/repos", + cancellationToken); + } + + #endregion + + #region Release Methods + + /// + /// Gets a release by ID. + /// + public async Task GetReleaseAsync(string owner, string repo, long releaseId, CancellationToken cancellationToken = default) + { + return await GetAsync( + $"/api/v1/repos/{Uri.EscapeDataString(owner)}/{Uri.EscapeDataString(repo)}/releases/{releaseId}", + cancellationToken); + } + + /// + /// Gets a release by tag name. + /// + public async Task GetReleaseByTagAsync(string owner, string repo, string tag, CancellationToken cancellationToken = default) + { + return await GetAsync( + $"/api/v1/repos/{Uri.EscapeDataString(owner)}/{Uri.EscapeDataString(repo)}/releases/tags/{Uri.EscapeDataString(tag)}", + cancellationToken); + } + + /// + /// Lists all releases for a repository. + /// + public async Task> ListReleasesAsync(string owner, string repo, CancellationToken cancellationToken = default) + { + return await GetAsync>( + $"/api/v1/repos/{Uri.EscapeDataString(owner)}/{Uri.EscapeDataString(repo)}/releases", + cancellationToken); + } + + #endregion + + #region Upload Methods + + /// + /// Uploads a release asset using chunked upload. + /// + public async Task UploadReleaseAssetAsync( + string owner, + string repo, + long releaseId, + Stream fileStream, + string filename, + ChunkedUploadOptions? options = null, + CancellationToken cancellationToken = default) + { + var upload = new ChunkedUpload(this, owner, repo, releaseId, options ?? new ChunkedUploadOptions()); + return await upload.UploadAsync(fileStream, filename, cancellationToken); + } + + /// + /// Creates a chunked upload handler. + /// + public ChunkedUpload CreateChunkedUpload( + string owner, + string repo, + long releaseId, + ChunkedUploadOptions? options = null) + { + return new ChunkedUpload(this, owner, repo, releaseId, options ?? new ChunkedUploadOptions()); + } + + /// + /// Gets the status of an upload session. + /// + public async Task GetUploadSessionAsync(string sessionId, CancellationToken cancellationToken = default) + { + return await GetAsync($"/api/v1/repos/uploads/{sessionId}", cancellationToken); + } + + /// + /// Cancels an upload session. + /// + public async Task CancelUploadAsync(string sessionId, CancellationToken cancellationToken = default) + { + await DeleteAsync($"/api/v1/repos/uploads/{sessionId}", cancellationToken); + } + + #endregion + + #region Internal HTTP Methods + + internal async Task GetAsync(string path, CancellationToken cancellationToken) + { + var response = await _httpClient.GetAsync(_baseUrl + path, cancellationToken); + await EnsureSuccessAsync(response, cancellationToken); + return await response.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken) + ?? throw new GiteaException("Empty response"); + } + + internal async Task PostAsync(string path, object? body, CancellationToken cancellationToken) + { + var response = await _httpClient.PostAsJsonAsync(_baseUrl + path, body, _jsonOptions, cancellationToken); + await EnsureSuccessAsync(response, cancellationToken); + return await response.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken) + ?? throw new GiteaException("Empty response"); + } + + internal async Task PostAsync(string path, object? body, CancellationToken cancellationToken) + { + var response = await _httpClient.PostAsJsonAsync(_baseUrl + path, body, _jsonOptions, cancellationToken); + await EnsureSuccessAsync(response, cancellationToken); + } + + internal async Task PutBinaryAsync(string path, byte[] data, CancellationToken cancellationToken) + { + var content = new ByteArrayContent(data); + content.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream"); + var response = await _httpClient.PutAsync(_baseUrl + path, content, cancellationToken); + await EnsureSuccessAsync(response, cancellationToken); + return await response.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken) + ?? throw new GiteaException("Empty response"); + } + + internal async Task PutBinaryAsync(string path, byte[] data, CancellationToken cancellationToken) + { + var content = new ByteArrayContent(data); + content.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream"); + var response = await _httpClient.PutAsync(_baseUrl + path, content, cancellationToken); + await EnsureSuccessAsync(response, cancellationToken); + } + + internal async Task DeleteAsync(string path, CancellationToken cancellationToken) + { + var response = await _httpClient.DeleteAsync(_baseUrl + path, cancellationToken); + await EnsureSuccessAsync(response, cancellationToken); + } + + private async Task EnsureSuccessAsync(HttpResponseMessage response, CancellationToken cancellationToken) + { + if (response.IsSuccessStatusCode) + return; + + var content = await response.Content.ReadAsStringAsync(cancellationToken); + + switch ((int)response.StatusCode) + { + case 401: + throw new AuthenticationException("Authentication failed"); + case 404: + throw new NotFoundException("Resource not found"); + default: + try + { + var error = JsonSerializer.Deserialize(content, _jsonOptions); + throw new ApiException( + error?.Message ?? content, + error?.Code, + (int)response.StatusCode); + } + catch (JsonException) + { + throw new ApiException(content, null, (int)response.StatusCode); + } + } + } + + #endregion + + public void Dispose() + { + if (!_disposed) + { + _httpClient.Dispose(); + _disposed = true; + } + GC.SuppressFinalize(this); + } +} + +internal class ApiErrorResponse +{ + public string? Code { get; set; } + public string? Message { get; set; } + public int? Status { get; set; } +} diff --git a/sdk/csharp/Gitea.SDK/Models.cs b/sdk/csharp/Gitea.SDK/Models.cs new file mode 100644 index 0000000000..7eeb820882 --- /dev/null +++ b/sdk/csharp/Gitea.SDK/Models.cs @@ -0,0 +1,161 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +using System.Text.Json.Serialization; + +namespace Gitea.SDK; + +/// +/// Represents a Gitea user. +/// +public record User +{ + public long Id { get; init; } + public string Login { get; init; } = string.Empty; + public string FullName { get; init; } = string.Empty; + public string Email { get; init; } = string.Empty; + public string AvatarUrl { get; init; } = string.Empty; + public bool IsAdmin { get; init; } +} + +/// +/// Represents a Gitea repository. +/// +public record Repository +{ + public long Id { get; init; } + public string Name { get; init; } = string.Empty; + public string FullName { get; init; } = string.Empty; + public User? Owner { get; init; } + public string Description { get; init; } = string.Empty; + public bool Private { get; init; } + public bool Fork { get; init; } + public string DefaultBranch { get; init; } = "main"; + public int StarsCount { get; init; } + public int ForksCount { get; init; } + public string CloneUrl { get; init; } = string.Empty; + public string HtmlUrl { get; init; } = string.Empty; +} + +/// +/// Represents a release attachment/asset. +/// +public record Attachment +{ + public long Id { get; init; } + public string Name { get; init; } = string.Empty; + public long Size { get; init; } + public long DownloadCount { get; init; } + public string BrowserDownloadUrl { get; init; } = string.Empty; + public DateTime CreatedAt { get; init; } +} + +/// +/// Represents a Gitea release. +/// +public record Release +{ + public long Id { get; init; } + public string TagName { get; init; } = string.Empty; + public string Name { get; init; } = string.Empty; + public string Body { get; init; } = string.Empty; + public bool Draft { get; init; } + public bool Prerelease { get; init; } + public DateTime PublishedAt { get; init; } + public List Assets { get; init; } = new(); +} + +/// +/// Represents a chunked upload session. +/// +public record UploadSession +{ + public string Id { get; init; } = string.Empty; + public string FileName { get; init; } = string.Empty; + public long FileSize { get; init; } + public long ChunkSize { get; init; } + public long TotalChunks { get; init; } + public long ChunksReceived { get; init; } + public string Status { get; init; } = "pending"; + public DateTime ExpiresAt { get; init; } + public string? Checksum { get; init; } +} + +/// +/// Represents the result of a completed upload. +/// +public record UploadResult +{ + public long Id { get; init; } + public string Name { get; init; } = string.Empty; + public long Size { get; init; } + public string BrowserDownloadUrl { get; init; } = string.Empty; + public bool ChecksumVerified { get; init; } +} + +/// +/// Represents upload progress. +/// +public record Progress +{ + /// Bytes uploaded so far. + public long BytesDone { get; init; } + + /// Total bytes to upload. + public long BytesTotal { get; init; } + + /// Number of chunks uploaded. + public long ChunksDone { get; init; } + + /// Total number of chunks. + public long ChunksTotal { get; init; } + + /// Percentage complete (0-100). + public double Percent { get; init; } + + /// Upload speed in bytes per second. + public double Speed { get; init; } + + /// Estimated time remaining. + public TimeSpan Eta { get; init; } + + /// + /// Formats the speed as a human-readable string. + /// + public string SpeedFormatted => FormatBytes((long)Speed) + "/s"; + + /// + /// Formats the ETA as a human-readable string. + /// + public string EtaFormatted => Eta.TotalSeconds < 60 + ? $"{(int)Eta.TotalSeconds}s" + : Eta.TotalMinutes < 60 + ? $"{(int)Eta.TotalMinutes}m{Eta.Seconds}s" + : $"{(int)Eta.TotalHours}h{Eta.Minutes}m"; + + private static string FormatBytes(long bytes) + { + if (bytes < 1024) return $"{bytes} B"; + if (bytes < 1024 * 1024) return $"{bytes / 1024.0:F1} KB"; + if (bytes < 1024 * 1024 * 1024) return $"{bytes / 1024.0 / 1024.0:F1} MB"; + return $"{bytes / 1024.0 / 1024.0 / 1024.0:F1} GB"; + } +} + +/// +/// Options for chunked uploads. +/// +public class ChunkedUploadOptions +{ + /// Size of each chunk in bytes (default: 10MB). + public long ChunkSize { get; set; } = 10 * 1024 * 1024; + + /// Number of parallel upload workers (default: 4). + public int Parallel { get; set; } = 4; + + /// Whether to verify file checksum (default: true). + public bool VerifyChecksum { get; set; } = true; + + /// Progress callback. + public Action? OnProgress { get; set; } +} diff --git a/sdk/csharp/README.md b/sdk/csharp/README.md new file mode 100644 index 0000000000..9c4bb0cded --- /dev/null +++ b/sdk/csharp/README.md @@ -0,0 +1,165 @@ +# Gitea .NET SDK + +Official .NET SDK for the Gitea API with chunked upload support for large files. + +## Installation + +```bash +dotnet add package Gitea.SDK +``` + +## Quick Start + +```csharp +using Gitea.SDK; + +// Create client +var client = new GiteaClient("https://gitea.example.com", "your_token"); + +// Get current user +var user = await client.GetCurrentUserAsync(); +Console.WriteLine($"Logged in as {user.Login}"); + +// Get a repository +var repo = await client.GetRepositoryAsync("owner", "repo"); +Console.WriteLine($"Repository: {repo.FullName}"); +``` + +## Chunked Upload + +Upload large files with progress tracking: + +```csharp +using Gitea.SDK; + +var client = new GiteaClient("https://gitea.example.com", "your_token"); + +// Upload a release asset with progress +await using var fileStream = File.OpenRead("large-file.tar.gz"); + +var result = await client.UploadReleaseAssetAsync( + owner: "myorg", + repo: "myrepo", + releaseId: 123, + fileStream: fileStream, + filename: "large-file.tar.gz", + options: new ChunkedUploadOptions + { + ChunkSize = 50 * 1024 * 1024, // 50MB chunks + Parallel = 4, + VerifyChecksum = true, + OnProgress = p => + { + Console.WriteLine($"Progress: {p.Percent:F1}%"); + Console.WriteLine($"Speed: {p.SpeedFormatted}"); + Console.WriteLine($"ETA: {p.EtaFormatted}"); + } + }); + +Console.WriteLine($"Uploaded: {result.BrowserDownloadUrl}"); +``` + +## Using ChunkedUpload Directly + +For more control over the upload process: + +```csharp +using Gitea.SDK; + +var client = new GiteaClient("https://gitea.example.com", "your_token"); + +var upload = client.CreateChunkedUpload("owner", "repo", 123, new ChunkedUploadOptions +{ + ChunkSize = 50 * 1024 * 1024, + Parallel = 4, + OnProgress = p => Console.WriteLine($"{p.Percent:F1}%") +}); + +try +{ + await using var stream = File.OpenRead("file.tar.gz"); + var result = await upload.UploadAsync(stream, "file.tar.gz"); + Console.WriteLine($"Success: {result.BrowserDownloadUrl}"); +} +catch (UploadException ex) +{ + // Upload failed, can retry later + Console.WriteLine($"Resume with session: {upload.Session?.Id}"); +} +``` + +## API Reference + +### GiteaClient + +#### Constructor + +```csharp +var client = new GiteaClient( + baseUrl: "https://gitea.example.com", + token: "your_api_token", + httpClient: null // Optional custom HttpClient +); +``` + +#### User Methods + +- `GetCurrentUserAsync()` - Get authenticated user +- `GetUserAsync(username)` - Get user by username + +#### Repository Methods + +- `GetRepositoryAsync(owner, repo)` - Get repository +- `ListUserRepositoriesAsync(username)` - List user's repositories + +#### Release Methods + +- `GetReleaseAsync(owner, repo, releaseId)` - Get release by ID +- `GetReleaseByTagAsync(owner, repo, tag)` - Get release by tag +- `ListReleasesAsync(owner, repo)` - List all releases + +#### Upload Methods + +- `UploadReleaseAssetAsync(...)` - Upload release asset with chunked upload +- `CreateChunkedUpload(owner, repo, releaseId, options)` - Create upload handler +- `GetUploadSessionAsync(sessionId)` - Get upload session status +- `CancelUploadAsync(sessionId)` - Cancel upload session + +## Error Handling + +```csharp +using Gitea.SDK; + +var client = new GiteaClient("https://gitea.example.com", "your_token"); + +try +{ + var repo = await client.GetRepositoryAsync("owner", "nonexistent"); +} +catch (NotFoundException ex) +{ + Console.WriteLine($"Repository not found: {ex.Message}"); +} +catch (AuthenticationException ex) +{ + Console.WriteLine($"Authentication failed: {ex.Message}"); +} +catch (UploadException ex) +{ + Console.WriteLine($"Upload failed: {ex.Message}"); + Console.WriteLine($"Session: {ex.SessionId}, Chunk: {ex.ChunkNumber}"); +} +catch (ApiException ex) +{ + Console.WriteLine($"API error [{ex.Code}]: {ex.Message}"); +} +``` + +## Requirements + +- .NET 8.0 or later +- System.Text.Json for JSON serialization + +## License + +MIT License - See LICENSE file for details. diff --git a/sdk/java/README.md b/sdk/java/README.md new file mode 100644 index 0000000000..b9cb7a212e --- /dev/null +++ b/sdk/java/README.md @@ -0,0 +1,182 @@ +# Gitea Java SDK + +Official Java SDK for the Gitea API with chunked upload support for large files. + +## Requirements + +- Java 17 or later +- Maven or Gradle + +## Installation + +### Maven + +```xml + + io.gitea + gitea-sdk + 1.0.0 + +``` + +### Gradle + +```groovy +implementation 'io.gitea:gitea-sdk:1.0.0' +``` + +## Quick Start + +```java +import io.gitea.sdk.GiteaClient; +import io.gitea.sdk.models.*; + +// Create client +var client = new GiteaClient("https://gitea.example.com", "your_token"); + +// Get current user +var user = client.getCurrentUser(); +System.out.println("Logged in as " + user.getLogin()); + +// Get a repository +var repo = client.getRepository("owner", "repo"); +System.out.println("Repository: " + repo.getFullName()); +``` + +## Chunked Upload + +Upload large files with progress tracking: + +```java +import io.gitea.sdk.GiteaClient; +import io.gitea.sdk.models.*; + +import java.io.FileInputStream; + +var client = new GiteaClient("https://gitea.example.com", "your_token"); + +// Upload a release asset with progress +try (var fileStream = new FileInputStream("large-file.tar.gz")) { + var fileSize = new java.io.File("large-file.tar.gz").length(); + + var result = client.uploadReleaseAsset( + "myorg", + "myrepo", + 123L, + fileStream, + "large-file.tar.gz", + fileSize, + new ChunkedUploadOptions() + .setChunkSize(50 * 1024 * 1024) // 50MB chunks + .setParallel(4) + .setVerifyChecksum(true) + .setOnProgress(p -> { + System.out.printf("Progress: %.1f%%%n", p.getPercent()); + System.out.println("Speed: " + p.getSpeedFormatted()); + System.out.println("ETA: " + p.getEtaFormatted()); + }) + ); + + System.out.println("Uploaded: " + result.getBrowserDownloadUrl()); +} +``` + +## Using ChunkedUpload Directly + +For more control over the upload process: + +```java +import io.gitea.sdk.GiteaClient; +import io.gitea.sdk.ChunkedUpload; +import io.gitea.sdk.models.*; + +var client = new GiteaClient("https://gitea.example.com", "your_token"); + +var upload = client.createChunkedUpload("owner", "repo", 123L, + new ChunkedUploadOptions() + .setChunkSize(50 * 1024 * 1024) + .setParallel(4) + .setOnProgress(p -> System.out.printf("%.1f%%%n", p.getPercent())) +); + +try (var stream = new FileInputStream("file.tar.gz")) { + var fileSize = new java.io.File("file.tar.gz").length(); + var result = upload.upload(stream, "file.tar.gz", fileSize); + System.out.println("Success: " + result.getBrowserDownloadUrl()); +} catch (UploadException e) { + // Upload failed, can retry later + System.out.println("Resume with session: " + upload.getSession().getId()); +} +``` + +## API Reference + +### GiteaClient + +#### Constructor + +```java +// With token +var client = new GiteaClient("https://gitea.example.com", "your_api_token"); + +// With custom HttpClient +var httpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(60)) + .build(); +var client = new GiteaClient("https://gitea.example.com", "your_api_token", httpClient); +``` + +#### User Methods + +- `getCurrentUser()` - Get authenticated user +- `getUser(username)` - Get user by username + +#### Repository Methods + +- `getRepository(owner, repo)` - Get repository +- `listUserRepositories(username)` - List user's repositories + +#### Release Methods + +- `getRelease(owner, repo, releaseId)` - Get release by ID +- `getReleaseByTag(owner, repo, tag)` - Get release by tag +- `listReleases(owner, repo)` - List all releases + +#### Upload Methods + +- `uploadReleaseAsset(...)` - Upload release asset with chunked upload +- `createChunkedUpload(owner, repo, releaseId, options)` - Create upload handler +- `getUploadSession(sessionId)` - Get upload session status +- `cancelUpload(sessionId)` - Cancel upload session + +## Error Handling + +```java +import io.gitea.sdk.GiteaClient; +import io.gitea.sdk.exceptions.*; + +var client = new GiteaClient("https://gitea.example.com", "your_token"); + +try { + var repo = client.getRepository("owner", "nonexistent"); +} catch (NotFoundException e) { + System.out.println("Repository not found: " + e.getMessage()); +} catch (AuthenticationException e) { + System.out.println("Authentication failed: " + e.getMessage()); +} catch (UploadException e) { + System.out.println("Upload failed: " + e.getMessage()); + System.out.println("Session: " + e.getSessionId() + ", Chunk: " + e.getChunkNumber()); +} catch (ApiException e) { + System.out.println("API error [" + e.getCode() + "]: " + e.getMessage()); +} catch (GiteaException e) { + System.out.println("Error: " + e.getMessage()); +} +``` + +## Thread Safety + +The `GiteaClient` is thread-safe and can be shared across multiple threads. Each `ChunkedUpload` instance should be used by a single thread at a time. + +## License + +MIT License - See LICENSE file for details. diff --git a/sdk/java/pom.xml b/sdk/java/pom.xml new file mode 100644 index 0000000000..d6e637675b --- /dev/null +++ b/sdk/java/pom.xml @@ -0,0 +1,111 @@ + + + 4.0.0 + + io.gitea + gitea-sdk + 1.0.0 + jar + + Gitea SDK + Official Java SDK for the Gitea API with chunked upload support for large files + https://docs.gitea.io/sdk/java + + + + MIT License + https://opensource.org/licenses/MIT + + + + + + The Gitea Authors + Gitea + https://gitea.io + + + + + scm:git:git://github.com/go-gitea/gitea.git + scm:git:ssh://github.com:go-gitea/gitea.git + https://github.com/go-gitea/gitea/tree/main/sdk/java + + + + 17 + 17 + UTF-8 + 2.16.0 + + + + + + com.fasterxml.jackson.core + jackson-databind + ${jackson.version} + + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + ${jackson.version} + + + + + org.junit.jupiter + junit-jupiter + 5.10.1 + test + + + org.mockito + mockito-core + 5.8.0 + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.11.0 + + 17 + 17 + + + + org.apache.maven.plugins + maven-source-plugin + 3.3.0 + + + attach-sources + + jar-no-fork + + + + + + org.apache.maven.plugins + maven-javadoc-plugin + 3.6.2 + + + attach-javadocs + + jar + + + + + + + diff --git a/sdk/java/src/main/java/io/gitea/sdk/ChunkedUpload.java b/sdk/java/src/main/java/io/gitea/sdk/ChunkedUpload.java new file mode 100644 index 0000000000..0a5d8263e1 --- /dev/null +++ b/sdk/java/src/main/java/io/gitea/sdk/ChunkedUpload.java @@ -0,0 +1,195 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package io.gitea.sdk; + +import io.gitea.sdk.exceptions.GiteaException; +import io.gitea.sdk.exceptions.UploadException; +import io.gitea.sdk.models.*; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.security.DigestInputStream; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Duration; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; + +/** + * Handles chunked file uploads with parallel workers. + */ +public class ChunkedUpload { + private final GiteaClient client; + private final String owner; + private final String repo; + private final long releaseId; + private final ChunkedUploadOptions options; + private UploadSession session; + + ChunkedUpload( + GiteaClient client, + String owner, + String repo, + long releaseId, + ChunkedUploadOptions options) { + this.client = client; + this.owner = owner; + this.repo = repo; + this.releaseId = releaseId; + this.options = options != null ? options : ChunkedUploadOptions.defaults(); + } + + /** + * Gets the current upload session. + */ + public UploadSession getSession() { + return session; + } + + /** + * Uploads a file using chunked upload. + */ + public UploadResult upload(InputStream fileStream, String filename, long fileSize) throws GiteaException { + long chunkSize = options.getChunkSize(); + int totalChunks = (int) Math.ceil((double) fileSize / chunkSize); + + // Read all data and optionally compute checksum + byte[] fileData; + String checksum = null; + + try { + if (options.isVerifyChecksum()) { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + DigestInputStream dis = new DigestInputStream(fileStream, digest); + fileData = dis.readAllBytes(); + checksum = bytesToHex(digest.digest()); + } else { + fileData = fileStream.readAllBytes(); + } + } catch (IOException | NoSuchAlgorithmException e) { + throw new UploadException("Failed to read file", e, null, null); + } + + // Create upload session + session = createSession(filename, fileSize, chunkSize, totalChunks, checksum); + + // Prepare chunks + List chunks = new ArrayList<>(); + for (int i = 0; i < totalChunks; i++) { + long offset = i * chunkSize; + int size = (int) Math.min(chunkSize, fileSize - offset); + byte[] data = new byte[size]; + System.arraycopy(fileData, (int) offset, data, 0, size); + chunks.add(new ChunkData(i, data)); + } + + // Track progress + long startTime = System.nanoTime(); + AtomicInteger chunksCompleted = new AtomicInteger(0); + AtomicLong bytesCompleted = new AtomicLong(0); + + // Upload chunks in parallel + ExecutorService executor = Executors.newFixedThreadPool(options.getParallel()); + List> futures = new ArrayList<>(); + + for (ChunkData chunk : chunks) { + futures.add(executor.submit(() -> { + uploadChunk(session.getId(), chunk.index, chunk.data); + + int done = chunksCompleted.incrementAndGet(); + long bytes = bytesCompleted.addAndGet(chunk.data.length); + + if (options.getOnProgress() != null) { + double elapsed = (System.nanoTime() - startTime) / 1_000_000_000.0; + double speed = elapsed > 0 ? bytes / elapsed : 0; + long remaining = fileSize - bytes; + Duration eta = speed > 0 ? Duration.ofSeconds((long) (remaining / speed)) : Duration.ZERO; + + options.getOnProgress().accept(new Progress( + bytes, + fileSize, + done, + totalChunks, + (double) bytes / fileSize * 100, + speed, + eta + )); + } + + return null; + })); + } + + // Wait for all uploads + try { + for (Future future : futures) { + future.get(); + } + } catch (InterruptedException | ExecutionException e) { + throw new UploadException("Upload failed", e, session.getId(), null); + } finally { + executor.shutdown(); + } + + // Complete the upload + return completeUpload(session.getId()); + } + + private UploadSession createSession( + String filename, + long fileSize, + long chunkSize, + int totalChunks, + String checksum) throws GiteaException { + String path = "/api/v1/repos/" + encode(owner) + "/" + encode(repo) + "/releases/" + releaseId + "/assets/upload"; + Map body = new HashMap<>(); + body.put("filename", filename); + body.put("file_size", fileSize); + body.put("chunk_size", chunkSize); + body.put("total_chunks", totalChunks); + if (checksum != null) { + body.put("checksum", checksum); + } + return client.post(path, body, UploadSession.class); + } + + private void uploadChunk(String sessionId, int chunkIndex, byte[] data) throws GiteaException { + String path = "/api/v1/repos/uploads/" + encode(sessionId) + "/chunks/" + chunkIndex; + client.putBinary(path, data); + } + + private UploadResult completeUpload(String sessionId) throws GiteaException { + String path = "/api/v1/repos/uploads/" + encode(sessionId) + "/complete"; + return client.post(path, null, UploadResult.class); + } + + private static String encode(String value) { + return URLEncoder.encode(value, StandardCharsets.UTF_8); + } + + private static String bytesToHex(byte[] bytes) { + StringBuilder sb = new StringBuilder(); + for (byte b : bytes) { + sb.append(String.format("%02x", b)); + } + return sb.toString(); + } + + private static class ChunkData { + final int index; + final byte[] data; + + ChunkData(int index, byte[] data) { + this.index = index; + this.data = data; + } + } +} diff --git a/sdk/java/src/main/java/io/gitea/sdk/GiteaClient.java b/sdk/java/src/main/java/io/gitea/sdk/GiteaClient.java new file mode 100644 index 0000000000..ce956cdf4d --- /dev/null +++ b/sdk/java/src/main/java/io/gitea/sdk/GiteaClient.java @@ -0,0 +1,297 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package io.gitea.sdk; + +import io.gitea.sdk.exceptions.*; +import io.gitea.sdk.models.*; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.List; + +/** + * Client for the Gitea API. + */ +public class GiteaClient implements AutoCloseable { + private final HttpClient httpClient; + private final String baseUrl; + private final String token; + private final ObjectMapper objectMapper; + + /** + * Creates a new Gitea API client. + * + * @param baseUrl Base URL of the Gitea instance + * @param token API token for authentication + */ + public GiteaClient(String baseUrl, String token) { + this(baseUrl, token, null); + } + + /** + * Creates a new Gitea API client with custom HttpClient. + * + * @param baseUrl Base URL of the Gitea instance + * @param token API token for authentication + * @param httpClient Custom HttpClient (optional) + */ + public GiteaClient(String baseUrl, String token, HttpClient httpClient) { + this.baseUrl = baseUrl.replaceAll("/+$", ""); + this.token = token; + this.httpClient = httpClient != null ? httpClient : HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(30)) + .build(); + + this.objectMapper = new ObjectMapper() + .setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE) + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + } + + // User Methods + + /** + * Gets the currently authenticated user. + */ + public User getCurrentUser() throws GiteaException { + return get("/api/v1/user", User.class); + } + + /** + * Gets a user by username. + */ + public User getUser(String username) throws GiteaException { + return get("/api/v1/users/" + encode(username), User.class); + } + + // Repository Methods + + /** + * Gets a repository by owner and name. + */ + public Repository getRepository(String owner, String repo) throws GiteaException { + return get("/api/v1/repos/" + encode(owner) + "/" + encode(repo), Repository.class); + } + + /** + * Lists repositories for a user. + */ + public List listUserRepositories(String username) throws GiteaException { + return getList("/api/v1/users/" + encode(username) + "/repos", Repository.class); + } + + // Release Methods + + /** + * Gets a release by ID. + */ + public Release getRelease(String owner, String repo, long releaseId) throws GiteaException { + return get("/api/v1/repos/" + encode(owner) + "/" + encode(repo) + "/releases/" + releaseId, Release.class); + } + + /** + * Gets a release by tag name. + */ + public Release getReleaseByTag(String owner, String repo, String tag) throws GiteaException { + return get("/api/v1/repos/" + encode(owner) + "/" + encode(repo) + "/releases/tags/" + encode(tag), Release.class); + } + + /** + * Lists all releases for a repository. + */ + public List listReleases(String owner, String repo) throws GiteaException { + return getList("/api/v1/repos/" + encode(owner) + "/" + encode(repo) + "/releases", Release.class); + } + + // Upload Methods + + /** + * Uploads a release asset using chunked upload. + */ + public UploadResult uploadReleaseAsset( + String owner, + String repo, + long releaseId, + InputStream fileStream, + String filename, + long fileSize, + ChunkedUploadOptions options) throws GiteaException { + var upload = new ChunkedUpload(this, owner, repo, releaseId, options); + return upload.upload(fileStream, filename, fileSize); + } + + /** + * Creates a chunked upload handler. + */ + public ChunkedUpload createChunkedUpload( + String owner, + String repo, + long releaseId, + ChunkedUploadOptions options) { + return new ChunkedUpload(this, owner, repo, releaseId, options); + } + + /** + * Gets the status of an upload session. + */ + public UploadSession getUploadSession(String sessionId) throws GiteaException { + return get("/api/v1/repos/uploads/" + encode(sessionId), UploadSession.class); + } + + /** + * Cancels an upload session. + */ + public void cancelUpload(String sessionId) throws GiteaException { + delete("/api/v1/repos/uploads/" + encode(sessionId)); + } + + // Internal HTTP Methods + + T get(String path, Class type) throws GiteaException { + var request = buildRequest(path) + .GET() + .build(); + return execute(request, type); + } + + List getList(String path, Class elementType) throws GiteaException { + var request = buildRequest(path) + .GET() + .build(); + return executeList(request, elementType); + } + + T post(String path, Object body, Class type) throws GiteaException { + try { + var json = body != null ? objectMapper.writeValueAsString(body) : "{}"; + var request = buildRequest(path) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(json)) + .build(); + return execute(request, type); + } catch (IOException e) { + throw new GiteaException("Failed to serialize request body", e); + } + } + + void post(String path, Object body) throws GiteaException { + try { + var json = body != null ? objectMapper.writeValueAsString(body) : "{}"; + var request = buildRequest(path) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(json)) + .build(); + executeVoid(request); + } catch (IOException e) { + throw new GiteaException("Failed to serialize request body", e); + } + } + + T putBinary(String path, byte[] data, Class type) throws GiteaException { + var request = buildRequest(path) + .header("Content-Type", "application/octet-stream") + .PUT(HttpRequest.BodyPublishers.ofByteArray(data)) + .build(); + return execute(request, type); + } + + void putBinary(String path, byte[] data) throws GiteaException { + var request = buildRequest(path) + .header("Content-Type", "application/octet-stream") + .PUT(HttpRequest.BodyPublishers.ofByteArray(data)) + .build(); + executeVoid(request); + } + + void delete(String path) throws GiteaException { + var request = buildRequest(path) + .DELETE() + .build(); + executeVoid(request); + } + + private HttpRequest.Builder buildRequest(String path) { + var builder = HttpRequest.newBuilder() + .uri(URI.create(baseUrl + path)) + .header("User-Agent", "Gitea.SDK/1.0.0"); + + if (token != null && !token.isEmpty()) { + builder.header("Authorization", "token " + token); + } + + return builder; + } + + private T execute(HttpRequest request, Class type) throws GiteaException { + try { + var response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + ensureSuccess(response); + return objectMapper.readValue(response.body(), type); + } catch (IOException | InterruptedException e) { + throw new GiteaException("Request failed", e); + } + } + + private List executeList(HttpRequest request, Class elementType) throws GiteaException { + try { + var response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + ensureSuccess(response); + var listType = objectMapper.getTypeFactory().constructCollectionType(List.class, elementType); + return objectMapper.readValue(response.body(), listType); + } catch (IOException | InterruptedException e) { + throw new GiteaException("Request failed", e); + } + } + + private void executeVoid(HttpRequest request) throws GiteaException { + try { + var response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + ensureSuccess(response); + } catch (IOException | InterruptedException e) { + throw new GiteaException("Request failed", e); + } + } + + private void ensureSuccess(HttpResponse response) throws GiteaException { + if (response.statusCode() >= 200 && response.statusCode() < 300) { + return; + } + + switch (response.statusCode()) { + case 401: + throw new AuthenticationException("Authentication failed"); + case 404: + throw new NotFoundException("Resource not found"); + default: + try { + var error = objectMapper.readValue(response.body(), ApiErrorResponse.class); + throw new ApiException( + error.getMessage() != null ? error.getMessage() : response.body(), + error.getCode(), + response.statusCode()); + } catch (IOException e) { + throw new ApiException(response.body(), null, response.statusCode()); + } + } + } + + private static String encode(String value) { + return URLEncoder.encode(value, StandardCharsets.UTF_8); + } + + @Override + public void close() { + // HttpClient doesn't need explicit cleanup in Java 11+ + } +} diff --git a/sdk/java/src/main/java/io/gitea/sdk/exceptions/ApiException.java b/sdk/java/src/main/java/io/gitea/sdk/exceptions/ApiException.java new file mode 100644 index 0000000000..bc41adae4c --- /dev/null +++ b/sdk/java/src/main/java/io/gitea/sdk/exceptions/ApiException.java @@ -0,0 +1,26 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package io.gitea.sdk.exceptions; + +/** + * Thrown when the API returns an error response. + */ +public class ApiException extends GiteaException { + private final String code; + private final int statusCode; + + public ApiException(String message, String code, int statusCode) { + super(message); + this.code = code; + this.statusCode = statusCode; + } + + public String getCode() { + return code; + } + + public int getStatusCode() { + return statusCode; + } +} diff --git a/sdk/java/src/main/java/io/gitea/sdk/exceptions/AuthenticationException.java b/sdk/java/src/main/java/io/gitea/sdk/exceptions/AuthenticationException.java new file mode 100644 index 0000000000..e947a0198e --- /dev/null +++ b/sdk/java/src/main/java/io/gitea/sdk/exceptions/AuthenticationException.java @@ -0,0 +1,13 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package io.gitea.sdk.exceptions; + +/** + * Thrown when authentication fails (401). + */ +public class AuthenticationException extends GiteaException { + public AuthenticationException(String message) { + super(message); + } +} diff --git a/sdk/java/src/main/java/io/gitea/sdk/exceptions/ChecksumException.java b/sdk/java/src/main/java/io/gitea/sdk/exceptions/ChecksumException.java new file mode 100644 index 0000000000..8288eacd46 --- /dev/null +++ b/sdk/java/src/main/java/io/gitea/sdk/exceptions/ChecksumException.java @@ -0,0 +1,26 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package io.gitea.sdk.exceptions; + +/** + * Thrown when checksum verification fails. + */ +public class ChecksumException extends UploadException { + private final String expected; + private final String actual; + + public ChecksumException(String message, String expected, String actual, String sessionId) { + super(message, sessionId, null); + this.expected = expected; + this.actual = actual; + } + + public String getExpected() { + return expected; + } + + public String getActual() { + return actual; + } +} diff --git a/sdk/java/src/main/java/io/gitea/sdk/exceptions/GiteaException.java b/sdk/java/src/main/java/io/gitea/sdk/exceptions/GiteaException.java new file mode 100644 index 0000000000..c04ea8c486 --- /dev/null +++ b/sdk/java/src/main/java/io/gitea/sdk/exceptions/GiteaException.java @@ -0,0 +1,17 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package io.gitea.sdk.exceptions; + +/** + * Base exception for all Gitea SDK errors. + */ +public class GiteaException extends Exception { + public GiteaException(String message) { + super(message); + } + + public GiteaException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/sdk/java/src/main/java/io/gitea/sdk/exceptions/NotFoundException.java b/sdk/java/src/main/java/io/gitea/sdk/exceptions/NotFoundException.java new file mode 100644 index 0000000000..5e62e57f65 --- /dev/null +++ b/sdk/java/src/main/java/io/gitea/sdk/exceptions/NotFoundException.java @@ -0,0 +1,13 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package io.gitea.sdk.exceptions; + +/** + * Thrown when a resource is not found (404). + */ +public class NotFoundException extends GiteaException { + public NotFoundException(String message) { + super(message); + } +} diff --git a/sdk/java/src/main/java/io/gitea/sdk/exceptions/UploadException.java b/sdk/java/src/main/java/io/gitea/sdk/exceptions/UploadException.java new file mode 100644 index 0000000000..f5a5114c48 --- /dev/null +++ b/sdk/java/src/main/java/io/gitea/sdk/exceptions/UploadException.java @@ -0,0 +1,36 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package io.gitea.sdk.exceptions; + +/** + * Thrown when an upload operation fails. + */ +public class UploadException extends GiteaException { + private final String sessionId; + private final Integer chunkNumber; + + public UploadException(String message) { + this(message, null, null); + } + + public UploadException(String message, String sessionId, Integer chunkNumber) { + super(message); + this.sessionId = sessionId; + this.chunkNumber = chunkNumber; + } + + public UploadException(String message, Throwable cause, String sessionId, Integer chunkNumber) { + super(message, cause); + this.sessionId = sessionId; + this.chunkNumber = chunkNumber; + } + + public String getSessionId() { + return sessionId; + } + + public Integer getChunkNumber() { + return chunkNumber; + } +} diff --git a/sdk/java/src/main/java/io/gitea/sdk/models/ApiErrorResponse.java b/sdk/java/src/main/java/io/gitea/sdk/models/ApiErrorResponse.java new file mode 100644 index 0000000000..53eea4c6f5 --- /dev/null +++ b/sdk/java/src/main/java/io/gitea/sdk/models/ApiErrorResponse.java @@ -0,0 +1,37 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package io.gitea.sdk.models; + +/** + * Internal class for parsing API error responses. + */ +public class ApiErrorResponse { + private String code; + private String message; + private Integer status; + + public String getCode() { + return code; + } + + public void setCode(String code) { + this.code = code; + } + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } + + public Integer getStatus() { + return status; + } + + public void setStatus(Integer status) { + this.status = status; + } +} diff --git a/sdk/java/src/main/java/io/gitea/sdk/models/Attachment.java b/sdk/java/src/main/java/io/gitea/sdk/models/Attachment.java new file mode 100644 index 0000000000..7260eb8be6 --- /dev/null +++ b/sdk/java/src/main/java/io/gitea/sdk/models/Attachment.java @@ -0,0 +1,66 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package io.gitea.sdk.models; + +import java.time.Instant; + +/** + * Represents a release attachment/asset. + */ +public class Attachment { + private long id; + private String name; + private long size; + private long downloadCount; + private String browserDownloadUrl; + private Instant createdAt; + + public long getId() { + return id; + } + + public void setId(long id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public long getSize() { + return size; + } + + public void setSize(long size) { + this.size = size; + } + + public long getDownloadCount() { + return downloadCount; + } + + public void setDownloadCount(long downloadCount) { + this.downloadCount = downloadCount; + } + + public String getBrowserDownloadUrl() { + return browserDownloadUrl; + } + + public void setBrowserDownloadUrl(String browserDownloadUrl) { + this.browserDownloadUrl = browserDownloadUrl; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(Instant createdAt) { + this.createdAt = createdAt; + } +} diff --git a/sdk/java/src/main/java/io/gitea/sdk/models/ChunkedUploadOptions.java b/sdk/java/src/main/java/io/gitea/sdk/models/ChunkedUploadOptions.java new file mode 100644 index 0000000000..cccc3f43de --- /dev/null +++ b/sdk/java/src/main/java/io/gitea/sdk/models/ChunkedUploadOptions.java @@ -0,0 +1,81 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package io.gitea.sdk.models; + +import java.util.function.Consumer; + +/** + * Options for chunked uploads. + */ +public class ChunkedUploadOptions { + private long chunkSize = 10 * 1024 * 1024; // 10MB default + private int parallel = 4; + private boolean verifyChecksum = true; + private Consumer onProgress; + + public ChunkedUploadOptions() { + } + + /** + * Size of each chunk in bytes (default: 10MB). + */ + public long getChunkSize() { + return chunkSize; + } + + public ChunkedUploadOptions setChunkSize(long chunkSize) { + this.chunkSize = chunkSize; + return this; + } + + /** + * Number of parallel upload workers (default: 4). + */ + public int getParallel() { + return parallel; + } + + public ChunkedUploadOptions setParallel(int parallel) { + this.parallel = parallel; + return this; + } + + /** + * Whether to verify file checksum (default: true). + */ + public boolean isVerifyChecksum() { + return verifyChecksum; + } + + public ChunkedUploadOptions setVerifyChecksum(boolean verifyChecksum) { + this.verifyChecksum = verifyChecksum; + return this; + } + + /** + * Progress callback. + */ + public Consumer getOnProgress() { + return onProgress; + } + + public ChunkedUploadOptions setOnProgress(Consumer onProgress) { + this.onProgress = onProgress; + return this; + } + + /** + * Creates options with default settings. + */ + public static ChunkedUploadOptions defaults() { + return new ChunkedUploadOptions(); + } + + /** + * Creates options with specified chunk size. + */ + public static ChunkedUploadOptions withChunkSize(long chunkSize) { + return new ChunkedUploadOptions().setChunkSize(chunkSize); + } +} diff --git a/sdk/java/src/main/java/io/gitea/sdk/models/Progress.java b/sdk/java/src/main/java/io/gitea/sdk/models/Progress.java new file mode 100644 index 0000000000..1b5bf42be1 --- /dev/null +++ b/sdk/java/src/main/java/io/gitea/sdk/models/Progress.java @@ -0,0 +1,124 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package io.gitea.sdk.models; + +import java.time.Duration; + +/** + * Represents upload progress. + */ +public class Progress { + private long bytesDone; + private long bytesTotal; + private long chunksDone; + private long chunksTotal; + private double percent; + private double speed; + private Duration eta; + + public Progress() { + } + + public Progress(long bytesDone, long bytesTotal, long chunksDone, long chunksTotal, double percent, double speed, Duration eta) { + this.bytesDone = bytesDone; + this.bytesTotal = bytesTotal; + this.chunksDone = chunksDone; + this.chunksTotal = chunksTotal; + this.percent = percent; + this.speed = speed; + this.eta = eta; + } + + public long getBytesDone() { + return bytesDone; + } + + public void setBytesDone(long bytesDone) { + this.bytesDone = bytesDone; + } + + public long getBytesTotal() { + return bytesTotal; + } + + public void setBytesTotal(long bytesTotal) { + this.bytesTotal = bytesTotal; + } + + public long getChunksDone() { + return chunksDone; + } + + public void setChunksDone(long chunksDone) { + this.chunksDone = chunksDone; + } + + public long getChunksTotal() { + return chunksTotal; + } + + public void setChunksTotal(long chunksTotal) { + this.chunksTotal = chunksTotal; + } + + public double getPercent() { + return percent; + } + + public void setPercent(double percent) { + this.percent = percent; + } + + public double getSpeed() { + return speed; + } + + public void setSpeed(double speed) { + this.speed = speed; + } + + public Duration getEta() { + return eta; + } + + public void setEta(Duration eta) { + this.eta = eta; + } + + /** + * Formats the speed as a human-readable string. + */ + public String getSpeedFormatted() { + return formatBytes((long) speed) + "/s"; + } + + /** + * Formats the ETA as a human-readable string. + */ + public String getEtaFormatted() { + if (eta == null) { + return "unknown"; + } + long seconds = eta.getSeconds(); + if (seconds < 60) { + return seconds + "s"; + } else if (seconds < 3600) { + return (seconds / 60) + "m" + (seconds % 60) + "s"; + } else { + return (seconds / 3600) + "h" + ((seconds % 3600) / 60) + "m"; + } + } + + private static String formatBytes(long bytes) { + if (bytes < 1024) { + return bytes + " B"; + } else if (bytes < 1024 * 1024) { + return String.format("%.1f KB", bytes / 1024.0); + } else if (bytes < 1024 * 1024 * 1024) { + return String.format("%.1f MB", bytes / 1024.0 / 1024.0); + } else { + return String.format("%.1f GB", bytes / 1024.0 / 1024.0 / 1024.0); + } + } +} diff --git a/sdk/java/src/main/java/io/gitea/sdk/models/Release.java b/sdk/java/src/main/java/io/gitea/sdk/models/Release.java new file mode 100644 index 0000000000..543a6658f2 --- /dev/null +++ b/sdk/java/src/main/java/io/gitea/sdk/models/Release.java @@ -0,0 +1,86 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package io.gitea.sdk.models; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; + +/** + * Represents a Gitea release. + */ +public class Release { + private long id; + private String tagName; + private String name; + private String body; + private boolean draft; + private boolean prerelease; + private Instant publishedAt; + private List assets = new ArrayList<>(); + + public long getId() { + return id; + } + + public void setId(long id) { + this.id = id; + } + + public String getTagName() { + return tagName; + } + + public void setTagName(String tagName) { + this.tagName = tagName; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getBody() { + return body; + } + + public void setBody(String body) { + this.body = body; + } + + public boolean isDraft() { + return draft; + } + + public void setDraft(boolean draft) { + this.draft = draft; + } + + public boolean isPrerelease() { + return prerelease; + } + + public void setPrerelease(boolean prerelease) { + this.prerelease = prerelease; + } + + public Instant getPublishedAt() { + return publishedAt; + } + + public void setPublishedAt(Instant publishedAt) { + this.publishedAt = publishedAt; + } + + public List getAssets() { + return assets; + } + + public void setAssets(List assets) { + this.assets = assets; + } +} diff --git a/sdk/java/src/main/java/io/gitea/sdk/models/Repository.java b/sdk/java/src/main/java/io/gitea/sdk/models/Repository.java new file mode 100644 index 0000000000..47c3f8e70b --- /dev/null +++ b/sdk/java/src/main/java/io/gitea/sdk/models/Repository.java @@ -0,0 +1,123 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package io.gitea.sdk.models; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Represents a Gitea repository. + */ +public class Repository { + private long id; + private String name; + private String fullName; + private User owner; + private String description; + + @JsonProperty("private") + private boolean isPrivate; + + private boolean fork; + private String defaultBranch; + private int starsCount; + private int forksCount; + private String cloneUrl; + private String htmlUrl; + + public long getId() { + return id; + } + + public void setId(long id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getFullName() { + return fullName; + } + + public void setFullName(String fullName) { + this.fullName = fullName; + } + + public User getOwner() { + return owner; + } + + public void setOwner(User owner) { + this.owner = owner; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public boolean isPrivate() { + return isPrivate; + } + + public void setPrivate(boolean isPrivate) { + this.isPrivate = isPrivate; + } + + public boolean isFork() { + return fork; + } + + public void setFork(boolean fork) { + this.fork = fork; + } + + public String getDefaultBranch() { + return defaultBranch; + } + + public void setDefaultBranch(String defaultBranch) { + this.defaultBranch = defaultBranch; + } + + public int getStarsCount() { + return starsCount; + } + + public void setStarsCount(int starsCount) { + this.starsCount = starsCount; + } + + public int getForksCount() { + return forksCount; + } + + public void setForksCount(int forksCount) { + this.forksCount = forksCount; + } + + public String getCloneUrl() { + return cloneUrl; + } + + public void setCloneUrl(String cloneUrl) { + this.cloneUrl = cloneUrl; + } + + public String getHtmlUrl() { + return htmlUrl; + } + + public void setHtmlUrl(String htmlUrl) { + this.htmlUrl = htmlUrl; + } +} diff --git a/sdk/java/src/main/java/io/gitea/sdk/models/UploadResult.java b/sdk/java/src/main/java/io/gitea/sdk/models/UploadResult.java new file mode 100644 index 0000000000..01ec1d23b7 --- /dev/null +++ b/sdk/java/src/main/java/io/gitea/sdk/models/UploadResult.java @@ -0,0 +1,55 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package io.gitea.sdk.models; + +/** + * Represents the result of a completed upload. + */ +public class UploadResult { + private long id; + private String name; + private long size; + private String browserDownloadUrl; + private boolean checksumVerified; + + public long getId() { + return id; + } + + public void setId(long id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public long getSize() { + return size; + } + + public void setSize(long size) { + this.size = size; + } + + public String getBrowserDownloadUrl() { + return browserDownloadUrl; + } + + public void setBrowserDownloadUrl(String browserDownloadUrl) { + this.browserDownloadUrl = browserDownloadUrl; + } + + public boolean isChecksumVerified() { + return checksumVerified; + } + + public void setChecksumVerified(boolean checksumVerified) { + this.checksumVerified = checksumVerified; + } +} diff --git a/sdk/java/src/main/java/io/gitea/sdk/models/UploadSession.java b/sdk/java/src/main/java/io/gitea/sdk/models/UploadSession.java new file mode 100644 index 0000000000..f2730bdad5 --- /dev/null +++ b/sdk/java/src/main/java/io/gitea/sdk/models/UploadSession.java @@ -0,0 +1,93 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package io.gitea.sdk.models; + +import java.time.Instant; + +/** + * Represents a chunked upload session. + */ +public class UploadSession { + private String id; + private String fileName; + private long fileSize; + private long chunkSize; + private long totalChunks; + private long chunksReceived; + private String status = "pending"; + private Instant expiresAt; + private String checksum; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getFileName() { + return fileName; + } + + public void setFileName(String fileName) { + this.fileName = fileName; + } + + public long getFileSize() { + return fileSize; + } + + public void setFileSize(long fileSize) { + this.fileSize = fileSize; + } + + public long getChunkSize() { + return chunkSize; + } + + public void setChunkSize(long chunkSize) { + this.chunkSize = chunkSize; + } + + public long getTotalChunks() { + return totalChunks; + } + + public void setTotalChunks(long totalChunks) { + this.totalChunks = totalChunks; + } + + public long getChunksReceived() { + return chunksReceived; + } + + public void setChunksReceived(long chunksReceived) { + this.chunksReceived = chunksReceived; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public Instant getExpiresAt() { + return expiresAt; + } + + public void setExpiresAt(Instant expiresAt) { + this.expiresAt = expiresAt; + } + + public String getChecksum() { + return checksum; + } + + public void setChecksum(String checksum) { + this.checksum = checksum; + } +} diff --git a/sdk/java/src/main/java/io/gitea/sdk/models/User.java b/sdk/java/src/main/java/io/gitea/sdk/models/User.java new file mode 100644 index 0000000000..a2ed10d166 --- /dev/null +++ b/sdk/java/src/main/java/io/gitea/sdk/models/User.java @@ -0,0 +1,64 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package io.gitea.sdk.models; + +/** + * Represents a Gitea user. + */ +public class User { + private long id; + private String login; + private String fullName; + private String email; + private String avatarUrl; + private boolean isAdmin; + + public long getId() { + return id; + } + + public void setId(long id) { + this.id = id; + } + + public String getLogin() { + return login; + } + + public void setLogin(String login) { + this.login = login; + } + + public String getFullName() { + return fullName; + } + + public void setFullName(String fullName) { + this.fullName = fullName; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public String getAvatarUrl() { + return avatarUrl; + } + + public void setAvatarUrl(String avatarUrl) { + this.avatarUrl = avatarUrl; + } + + public boolean isAdmin() { + return isAdmin; + } + + public void setAdmin(boolean admin) { + isAdmin = admin; + } +}