diff --git a/PCL.Core.Test/IO/Storage/Cache/CacheSystemIntegrationTest.cs b/PCL.Core.Test/IO/Storage/Cache/CacheSystemIntegrationTest.cs index 9bc75372d4..6301a95b37 100644 --- a/PCL.Core.Test/IO/Storage/Cache/CacheSystemIntegrationTest.cs +++ b/PCL.Core.Test/IO/Storage/Cache/CacheSystemIntegrationTest.cs @@ -43,7 +43,7 @@ public async Task Cleanup() { if (_cache != null) { - await _cache.DisposeAsync(); + await _cache.DisposeAsync().ConfigureAwait(false); } if (Directory.Exists(_testCacheDir)) diff --git a/PCL.Core.Test/Minecraft/ResourceProject/Comp/CompClientTest.cs b/PCL.Core.Test/Minecraft/ResourceProject/Comp/CompClientTest.cs new file mode 100644 index 0000000000..55e65c8365 --- /dev/null +++ b/PCL.Core.Test/Minecraft/ResourceProject/Comp/CompClientTest.cs @@ -0,0 +1,156 @@ +using System; +using System.Linq; +using System.Net.Http; +using System.Threading.Tasks; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using PCL.Core.Minecraft.ResourceProject.Comp.Clients; +using PCL.Core.Minecraft.ResourceProject.Comp.Models; +using PCL.Core.Minecraft.ResourceProject.Comp.Models.Enums; + +namespace PCL.Core.Test.Minecraft.ResourceProject.Comp; + +[TestClass] +public class CompClientTest +{ + private static CurseForgeClient? _cf; + private static ModrinthClient? _mr; + private static bool _hasCfKey; + private static HttpClient _httpClient = null!; + + [ClassInitialize] + public static void ClassInit(TestContext _) + { + _httpClient = new HttpClient(); + var key = Environment.GetEnvironmentVariable("PCL_CURSEFORGE_API_KEY"); + _hasCfKey = !string.IsNullOrEmpty(key); + if (_hasCfKey) + _cf = new CurseForgeClient(key!, _httpClient); + _mr = new ModrinthClient(httpClient: _httpClient); + } + + [ClassCleanup] + public static void ClassCleanup() + { + _httpClient.Dispose(); + } + + // ===== CurseForge ===== + + [TestMethod] + public async Task CurseForge_SearchProjects_ReturnsResults() + { + if (!_hasCfKey) Assert.Inconclusive("PCL_CURSEFORGE_API_KEY not set"); + var filter = new CompSearchFilter { Query = "sodium", Limit = 5 }; + var result = await _cf!.SearchProjects(filter); + Assert.IsTrue(result.Hits.Count > 0); + Assert.IsTrue(result.TotalCount > 0); + Assert.IsFalse(string.IsNullOrEmpty(result.Hits[0].Name)); + } + + [TestMethod] + public async Task CurseForge_GetProject_ReturnsProject() + { + if (!_hasCfKey) Assert.Inconclusive("PCL_CURSEFORGE_API_KEY not set"); + var project = await _cf!.GetProject("394468"); + Assert.AreEqual("394468", project.Id); + Assert.AreEqual("CurseForge", project.Provider); + Assert.IsFalse(string.IsNullOrEmpty(project.Name)); + Assert.IsFalse(string.IsNullOrEmpty(project.Summary)); + } + + [TestMethod] + public async Task CurseForge_GetProjectFiles_ReturnsFiles() + { + if (!_hasCfKey) Assert.Inconclusive("PCL_CURSEFORGE_API_KEY not set"); + var files = await _cf!.GetProjectFiles("394468"); + Assert.IsTrue(files.Count > 0); + Assert.IsFalse(string.IsNullOrEmpty(files[0].Id)); + Assert.IsFalse(string.IsNullOrEmpty(files[0].DisplayName)); + } + + [TestMethod] + public async Task CurseForge_GetGameVersions_ReturnsVersions() + { + if (!_hasCfKey) Assert.Inconclusive("PCL_CURSEFORGE_API_KEY not set"); + var versions = await _cf!.GetGameVersions(); + Assert.IsTrue(versions.Count > 0); + Assert.IsTrue(versions.Any(v => v.Version == "1.20.1" || v.Version.Contains("1.20"))); + } + + [TestMethod] + public async Task CurseForge_GetCategories_ReturnsCategories() + { + if (!_hasCfKey) Assert.Inconclusive("PCL_CURSEFORGE_API_KEY not set"); + var categories = await _cf!.GetCategories(); + Assert.IsTrue(categories.Count > 0); + Assert.IsFalse(string.IsNullOrEmpty(categories[0].Name)); + } + + // ===== Modrinth ===== + + [TestMethod] + public async Task Modrinth_SearchProjects_ReturnsResults() + { + var filter = new CompSearchFilter { Query = "sodium", Limit = 5 }; + var result = await _mr!.SearchProjects(filter); + Assert.IsTrue(result.Hits.Count > 0); + Assert.IsTrue(result.TotalCount > 0); + Assert.IsFalse(string.IsNullOrEmpty(result.Hits[0].Name)); + } + + [TestMethod] + public async Task Modrinth_GetProject_ReturnsProject() + { + var project = await _mr!.GetProject("sodium"); + Assert.AreEqual("sodium", project.Slug); + Assert.AreEqual("Modrinth", project.Provider); + Assert.IsFalse(string.IsNullOrEmpty(project.Name)); + } + + [TestMethod] + public async Task Modrinth_GetProjectFiles_ReturnsFiles() + { + var files = await _mr!.GetProjectFiles("sodium"); + Assert.IsTrue(files.Count > 0); + Assert.IsFalse(string.IsNullOrEmpty(files[0].Id)); + Assert.IsFalse(string.IsNullOrEmpty(files[0].DisplayName)); + } + + [TestMethod] + public async Task Modrinth_GetGameVersions_ReturnsVersions() + { + var versions = await _mr!.GetGameVersions(); + Assert.IsTrue(versions.Count > 0); + Assert.IsTrue(versions.Any(v => v.Version == "1.20.1" || v.Version.Contains("1.20"))); + } + + [TestMethod] + public async Task Modrinth_GetCategories_ReturnsCategories() + { + var categories = await _mr!.GetCategories(); + Assert.IsTrue(categories.Count > 0); + Assert.IsFalse(string.IsNullOrEmpty(categories[0].Name)); + } + + [TestMethod] + public async Task Modrinth_GetLoaders_ReturnsLoaders() + { + var loaders = await _mr!.GetLoaders(); + Assert.IsTrue(loaders.Count > 0); + Assert.IsTrue(loaders.Any(l => l.LoaderType == ModLoaderType.Forge)); + } + + // ===== Aggregate ===== + + [TestMethod] + public async Task Aggregate_SearchProjects_MergesResults() + { + if (!_hasCfKey) Assert.Inconclusive("PCL_CURSEFORGE_API_KEY not set"); + var agg = new AggregateClient(_cf!, _mr!); + var filter = new CompSearchFilter { Query = "sodium", Limit = 3 }; + var result = await agg.SearchProjects(filter); + Assert.IsTrue(result.Hits.Count > 0); + Assert.IsTrue(result.Hits.Any(p => p.Provider == "CurseForge")); + Assert.IsTrue(result.Hits.Any(p => p.Provider == "Modrinth")); + } +} diff --git a/PCL.Core.Test/Project/Modrinth.cs b/PCL.Core.Test/Project/Modrinth.cs deleted file mode 100644 index 523603e351..0000000000 --- a/PCL.Core.Test/Project/Modrinth.cs +++ /dev/null @@ -1,29 +0,0 @@ -using System; -using System.Net.Http; -using Microsoft.VisualStudio.TestTools.UnitTesting; -using System.Threading.Tasks; - -namespace PCL.Core.Test.Project; - -[TestClass] -public class Modrinth -{ - [TestMethod] - public async Task GetProjectTest() - { - using var c = new HttpClient(); - using var req = new HttpRequestMessage(); - req.RequestUri = new Uri("https://api.modrinth.com/v2/project/sodium"); - req.Method = HttpMethod.Get; - var ret = await c.SendAsync(req, HttpCompletionOption.ResponseHeadersRead); - Assert.IsNotNull(ret); - ret.EnsureSuccessStatusCode(); - var s = await ret!.Content.ReadAsStreamAsync(); - Assert.IsNotNull(s); - // var instance = (ModrinthProject)(await JsonSerializer.DeserializeAsync( - // s, - // typeof(ModrinthProject), - // JsonSerializerOptions.Web) ?? throw new NullReferenceException()); - // Assert.AreEqual("sodium", instance.slug); - } -} \ No newline at end of file diff --git a/PCL.Core/Minecraft/ResourceProject/Comp/Abstractions/ICompClient.cs b/PCL.Core/Minecraft/ResourceProject/Comp/Abstractions/ICompClient.cs new file mode 100644 index 0000000000..0e354f0a58 --- /dev/null +++ b/PCL.Core/Minecraft/ResourceProject/Comp/Abstractions/ICompClient.cs @@ -0,0 +1,30 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using PCL.Core.Minecraft.ResourceProject.Comp.Models; +using PCL.Core.Minecraft.ResourceProject.Comp.Models.Enums; + +namespace PCL.Core.Minecraft.ResourceProject.Comp.Abstractions; + +public interface ICompClient +{ + Task SearchProjects(CompSearchFilter filter, CancellationToken ct = default); + Task GetProject(string projectId, CancellationToken ct = default); + Task> GetProjects(IEnumerable projectIds, CancellationToken ct = default); + Task> GetFeaturedProjects(string? gameVersion = null, CancellationToken ct = default); + Task GetProjectDescription(string projectId, CancellationToken ct = default); + + Task> GetProjectFiles(string projectId, CompSearchFilter? filter = null, CancellationToken ct = default); + Task GetFile(string fileId, CancellationToken ct = default); + Task GetFileDownloadUrl(string fileId, CancellationToken ct = default); + Task GetFileChangelog(string fileId, CancellationToken ct = default); + + Task>> MatchFingerprints( + IEnumerable hashes, HashAlgorithm algo, CancellationToken ct = default); + Task CheckForUpdates( + string fileHash, HashAlgorithm algo, CompSearchFilter? filter = null, CancellationToken ct = default); + + Task> GetGameVersions(CancellationToken ct = default); + Task> GetLoaders(CancellationToken ct = default); + Task> GetCategories(CancellationToken ct = default); +} diff --git a/PCL.Core/Minecraft/ResourceProject/Comp/Abstractions/ICompClientFactory.cs b/PCL.Core/Minecraft/ResourceProject/Comp/Abstractions/ICompClientFactory.cs new file mode 100644 index 0000000000..e9a2513171 --- /dev/null +++ b/PCL.Core/Minecraft/ResourceProject/Comp/Abstractions/ICompClientFactory.cs @@ -0,0 +1,8 @@ +namespace PCL.Core.Minecraft.ResourceProject.Comp.Abstractions; + +public interface ICompClientFactory +{ + ICompClient CreateCurseForgeClient(string apiKey); + ICompClient CreateModrinthClient(string? accessToken = null); + ICompClient CreateAggregateClient(params ICompClient[] clients); +} diff --git a/PCL.Core/Minecraft/ResourceProject/Comp/Clients/AggregateClient.cs b/PCL.Core/Minecraft/ResourceProject/Comp/Clients/AggregateClient.cs new file mode 100644 index 0000000000..34e43c5926 --- /dev/null +++ b/PCL.Core/Minecraft/ResourceProject/Comp/Clients/AggregateClient.cs @@ -0,0 +1,281 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using PCL.Core.Minecraft.ResourceProject.Comp.Abstractions; +using PCL.Core.Minecraft.ResourceProject.Comp.Models; +using PCL.Core.Minecraft.ResourceProject.Comp.Models.Enums; + +namespace PCL.Core.Minecraft.ResourceProject.Comp.Clients; + +public sealed class AggregateClient : ICompClient +{ + private readonly IReadOnlyList _clients; + + public AggregateClient(params ICompClient[] clients) + { + _clients = clients ?? throw new ArgumentNullException(nameof(clients)); + } + + public AggregateClient(IEnumerable clients) + { + _clients = clients?.ToList() ?? throw new ArgumentNullException(nameof(clients)); + } + + public async Task SearchProjects(CompSearchFilter filter, CancellationToken ct = default) + { + var tasks = _clients.Select(c => c.SearchProjects(filter, ct)).ToArray(); + await Task.WhenAll(tasks).ConfigureAwait(false); + + var allHits = new List(); + var totalCount = 0; + + foreach (var task in tasks) + { + try + { + var result = await task.ConfigureAwait(false); + allHits.AddRange(result.Hits); + totalCount += result.TotalCount; + } + catch + { + } + } + + return new CompSearchResult(allHits, totalCount, filter.Offset, filter.Limit); + } + + public async Task GetProject(string projectId, CancellationToken ct = default) + { + var exceptions = new List(); + + foreach (var client in _clients) + { + try + { + return await client.GetProject(projectId, ct).ConfigureAwait(false); + } + catch (Exception ex) + { + exceptions.Add(ex); + } + } + + throw new AggregateException( + $"All providers failed to get project {projectId}.", exceptions); + } + + public async Task> GetProjects(IEnumerable projectIds, CancellationToken ct = default) + { + var ids = projectIds.ToList(); + if (ids.Count == 0) return []; + + var tasks = _clients.Select(c => _TryGetProjects(c, ids, ct)).ToArray(); + await Task.WhenAll(tasks).ConfigureAwait(false); + + return tasks + .Where(t => t.IsCompletedSuccessfully && t.Result is not null) + .SelectMany(t => t.Result!) + .DistinctBy(p => $"{p.Provider}:{p.Id}") + .ToList(); + } + + public async Task> GetFeaturedProjects(string? gameVersion = null, CancellationToken ct = default) + { + var tasks = _clients.Select(c => _TryGetFeatured(c, gameVersion, ct)).ToArray(); + await Task.WhenAll(tasks).ConfigureAwait(false); + + return tasks + .Where(t => t.IsCompletedSuccessfully && t.Result is not null) + .SelectMany(t => t.Result!) + .DistinctBy(p => $"{p.Provider}:{p.Id}") + .ToList(); + } + + public async Task GetProjectDescription(string projectId, CancellationToken ct = default) + { + foreach (var client in _clients) + { + try + { + return await client.GetProjectDescription(projectId, ct).ConfigureAwait(false); + } + catch + { + } + } + return ""; + } + + public async Task> GetProjectFiles(string projectId, CompSearchFilter? filter = null, CancellationToken ct = default) + { + var tasks = _clients.Select(c => _TryGetFiles(c, projectId, filter, ct)).ToArray(); + await Task.WhenAll(tasks).ConfigureAwait(false); + + return tasks + .Where(t => t.IsCompletedSuccessfully && t.Result is not null) + .SelectMany(t => t.Result!) + .DistinctBy(f => $"{f.Id}") + .ToList(); + } + + public async Task GetFile(string fileId, CancellationToken ct = default) + { + foreach (var client in _clients) + { + try + { + return await client.GetFile(fileId, ct).ConfigureAwait(false); + } + catch + { + } + } + throw new InvalidOperationException($"All providers failed to get file {fileId}."); + } + + public async Task GetFileDownloadUrl(string fileId, CancellationToken ct = default) + { + foreach (var client in _clients) + { + try + { + return await client.GetFileDownloadUrl(fileId, ct).ConfigureAwait(false); + } + catch + { + } + } + return ""; + } + + public async Task GetFileChangelog(string fileId, CancellationToken ct = default) + { + foreach (var client in _clients) + { + try + { + return await client.GetFileChangelog(fileId, ct).ConfigureAwait(false); + } + catch + { + } + } + return ""; + } + + public async Task>> MatchFingerprints( + IEnumerable hashes, HashAlgorithm algo, CancellationToken ct = default) + { + var result = new Dictionary>(); + + foreach (var client in _clients) + { + try + { + var clientResult = await client.MatchFingerprints(hashes, algo, ct).ConfigureAwait(false); + foreach (var (key, files) in clientResult) + { + if (!result.ContainsKey(key)) + result[key] = files; + } + } + catch + { + } + } + + return result; + } + + public async Task CheckForUpdates( + string fileHash, HashAlgorithm algo, CompSearchFilter? filter = null, CancellationToken ct = default) + { + foreach (var client in _clients) + { + try + { + var result = await client.CheckForUpdates(fileHash, algo, filter, ct).ConfigureAwait(false); + if (result is not null) return result; + } + catch + { + } + } + return null; + } + + public async Task> GetGameVersions(CancellationToken ct = default) + { + var tasks = _clients.Select(c => _TryGetGameVersions(c, ct)).ToArray(); + await Task.WhenAll(tasks).ConfigureAwait(false); + + return tasks + .Where(t => t.IsCompletedSuccessfully && t.Result is not null) + .SelectMany(t => t.Result!) + .DistinctBy(gv => gv.Version) + .ToList(); + } + + public async Task> GetLoaders(CancellationToken ct = default) + { + var tasks = _clients.Select(c => _TryGetLoaders(c, ct)).ToArray(); + await Task.WhenAll(tasks).ConfigureAwait(false); + + return tasks + .Where(t => t.IsCompletedSuccessfully && t.Result is not null) + .SelectMany(t => t.Result!) + .DistinctBy(l => l.Name) + .ToList(); + } + + public async Task> GetCategories(CancellationToken ct = default) + { + var tasks = _clients.Select(c => _TryGetCategories(c, ct)).ToArray(); + await Task.WhenAll(tasks).ConfigureAwait(false); + + return tasks + .Where(t => t.IsCompletedSuccessfully && t.Result is not null) + .SelectMany(t => t.Result!) + .DistinctBy(c => c.Slug) + .ToList(); + } + + private static async Task?> _TryGetProjects(ICompClient client, List ids, CancellationToken ct) + { + try { return await client.GetProjects(ids, ct).ConfigureAwait(false); } + catch { return null; } + } + + private static async Task?> _TryGetFeatured(ICompClient client, string? gameVersion, CancellationToken ct) + { + try { return await client.GetFeaturedProjects(gameVersion, ct).ConfigureAwait(false); } + catch { return null; } + } + + private static async Task?> _TryGetFiles(ICompClient client, string projectId, CompSearchFilter? filter, CancellationToken ct) + { + try { return await client.GetProjectFiles(projectId, filter, ct).ConfigureAwait(false); } + catch { return null; } + } + + private static async Task?> _TryGetGameVersions(ICompClient client, CancellationToken ct) + { + try { return await client.GetGameVersions(ct).ConfigureAwait(false); } + catch { return null; } + } + + private static async Task?> _TryGetLoaders(ICompClient client, CancellationToken ct) + { + try { return await client.GetLoaders(ct).ConfigureAwait(false); } + catch { return null; } + } + + private static async Task?> _TryGetCategories(ICompClient client, CancellationToken ct) + { + try { return await client.GetCategories(ct).ConfigureAwait(false); } + catch { return null; } + } +} diff --git a/PCL.Core/Minecraft/ResourceProject/Comp/Clients/CompClientFactory.cs b/PCL.Core/Minecraft/ResourceProject/Comp/Clients/CompClientFactory.cs new file mode 100644 index 0000000000..0fd94b8b4b --- /dev/null +++ b/PCL.Core/Minecraft/ResourceProject/Comp/Clients/CompClientFactory.cs @@ -0,0 +1,21 @@ +using PCL.Core.Minecraft.ResourceProject.Comp.Abstractions; + +namespace PCL.Core.Minecraft.ResourceProject.Comp.Clients; + +public sealed class CompClientFactory : ICompClientFactory +{ + public ICompClient CreateCurseForgeClient(string apiKey) + { + return new CurseForgeClient(apiKey); + } + + public ICompClient CreateModrinthClient(string? accessToken = null) + { + return new ModrinthClient(accessToken); + } + + public ICompClient CreateAggregateClient(params ICompClient[] clients) + { + return new AggregateClient(clients); + } +} diff --git a/PCL.Core/Minecraft/ResourceProject/Comp/Clients/CurseForgeClient.cs b/PCL.Core/Minecraft/ResourceProject/Comp/Clients/CurseForgeClient.cs new file mode 100644 index 0000000000..6e1783233e --- /dev/null +++ b/PCL.Core/Minecraft/ResourceProject/Comp/Clients/CurseForgeClient.cs @@ -0,0 +1,361 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using PCL.Core.IO.Net.Http; +using PCL.Core.Minecraft.ResourceProject.Comp.Abstractions; +using PCL.Core.Minecraft.ResourceProject.Comp.Converters; +using PCL.Core.Minecraft.ResourceProject.Comp.Infrastructure; +using PCL.Core.Minecraft.ResourceProject.Comp.Models; +using PCL.Core.Minecraft.ResourceProject.Comp.Models.Enums; +using PCL.Core.Utils; + +namespace PCL.Core.Minecraft.ResourceProject.Comp.Clients; + +public sealed class CurseForgeClient : ICompClient +{ + private const string BaseUrl = "https://api.curseforge.com"; + private const int GameId = 432; + + private readonly string _apiKey; + private readonly HttpClient? _httpClient; + private readonly RateLimiter _rateLimiter; + + public CurseForgeClient(string apiKey, HttpClient? httpClient = null) + { + _apiKey = apiKey ?? throw new ArgumentNullException(nameof(apiKey)); + _httpClient = httpClient; + _rateLimiter = new RateLimiter(300); + } + + public async Task SearchProjects(CompSearchFilter filter, CancellationToken ct = default) + { + var url = _BuildSearchUrl(filter); + var root = await _GetJsonAsync(url, ct).ConfigureAwait(false); + return CurseForgeModelConverter.ToSearchResult(root, filter.Offset, filter.Limit); + } + + public async Task GetProject(string projectId, CancellationToken ct = default) + { + var url = $"{BaseUrl}/v1/mods/{projectId}"; + var root = await _GetJsonAsync(url, ct).ConfigureAwait(false); + var data = root.GetProperty("data"); + return CurseForgeModelConverter.ToProject(data); + } + + public async Task> GetProjects(IEnumerable projectIds, CancellationToken ct = default) + { + var ids = projectIds.ToList(); + if (ids.Count == 0) return []; + + var url = $"{BaseUrl}/v1/mods"; + var body = JsonSerializer.Serialize(new { modIds = ids.Select(int.Parse).ToList() }, JsonCompat.SerializerOptions); + + using var request = HttpRequest.CreatePost(url) + .WithContent(body, "application/json") + .ApplyCurseForgeAuth(_apiKey); + using var response = await request.SendAsync(httpClient: _httpClient, addMetedata: _httpClient is null, cancellationToken: ct).ConfigureAwait(false); + await _EnsureSuccess(response, ct).ConfigureAwait(false); + + var root = JsonSerializer.Deserialize(await response.AsStringAsync(ct).ConfigureAwait(false), JsonCompat.SerializerOptions); + var data = root.GetProperty("data"); + var results = new List(); + if (data.ValueKind == JsonValueKind.Array) + { + foreach (var item in data.EnumerateArray()) + results.Add(CurseForgeModelConverter.ToProject(item)); + } + return results; + } + + public async Task> GetFeaturedProjects(string? gameVersion = null, CancellationToken ct = default) + { + var url = $"{BaseUrl}/v1/mods/featured"; + var bodyObj = new Dictionary { ["gameId"] = GameId }; + if (!string.IsNullOrEmpty(gameVersion)) + bodyObj["featuredFilter"] = gameVersion; + + var body = JsonSerializer.Serialize(bodyObj, JsonCompat.SerializerOptions); + + using var request = HttpRequest.CreatePost(url) + .WithContent(body, "application/json") + .ApplyCurseForgeAuth(_apiKey); + using var response = await request.SendAsync(httpClient: _httpClient, addMetedata: _httpClient is null, cancellationToken: ct).ConfigureAwait(false); + await _EnsureSuccess(response, ct).ConfigureAwait(false); + + var root = JsonSerializer.Deserialize(await response.AsStringAsync(ct).ConfigureAwait(false), JsonCompat.SerializerOptions); + var data = root.GetProperty("data"); + + var results = new List(); + if (data.TryGetProperty("featured", out var featured) && featured.ValueKind == JsonValueKind.Array) + { + foreach (var item in featured.EnumerateArray()) + results.Add(CurseForgeModelConverter.ToProject(item)); + } + if (data.TryGetProperty("popular", out var popular) && popular.ValueKind == JsonValueKind.Array) + { + foreach (var item in popular.EnumerateArray()) + results.Add(CurseForgeModelConverter.ToProject(item)); + } + if (data.TryGetProperty("recentlyUpdated", out var updated) && updated.ValueKind == JsonValueKind.Array) + { + foreach (var item in updated.EnumerateArray()) + results.Add(CurseForgeModelConverter.ToProject(item)); + } + return results; + } + + public async Task GetProjectDescription(string projectId, CancellationToken ct = default) + { + var url = $"{BaseUrl}/v1/mods/{projectId}/description"; + var root = await _GetJsonAsync(url, ct).ConfigureAwait(false); + return root.GetProperty("data").GetString() ?? ""; + } + + public async Task> GetProjectFiles(string projectId, CompSearchFilter? filter = null, CancellationToken ct = default) + { + var url = $"{BaseUrl}/v1/mods/{projectId}/files"; + if (filter is not null) + { + var queryParams = new List(); + if (filter.Offset > 0) queryParams.Add($"index={filter.Offset}"); + if (filter.Limit > 0) queryParams.Add($"pageSize={filter.Limit}"); + if (!string.IsNullOrEmpty(filter.GameVersion)) + queryParams.Add($"gameVersion={Uri.EscapeDataString(filter.GameVersion)}"); + if (filter.Loaders.Count > 0) + queryParams.Add($"modLoaderType={(int)filter.Loaders[0]}"); + if (queryParams.Count > 0) + url += "?" + string.Join("&", queryParams); + } + + var root = await _GetJsonAsync(url, ct).ConfigureAwait(false); + var data = root.GetProperty("data"); + var results = new List(); + if (data.ValueKind == JsonValueKind.Array) + { + foreach (var item in data.EnumerateArray()) + results.Add(CurseForgeModelConverter.ToFile(item, int.Parse(projectId))); + } + return results; + } + + public async Task GetFile(string fileId, CancellationToken ct = default) + { + // CurseForge files API requires modId, so use the bulk endpoint for a single file + var body = JsonSerializer.Serialize(new { fileIds = new[] { int.Parse(fileId) } }, JsonCompat.SerializerOptions); + + using var request = HttpRequest.CreatePost($"{BaseUrl}/v1/mods/files") + .WithContent(body, "application/json") + .ApplyCurseForgeAuth(_apiKey); + using var response = await request.SendAsync(httpClient: _httpClient, addMetedata: _httpClient is null, cancellationToken: ct).ConfigureAwait(false); + await _EnsureSuccess(response, ct).ConfigureAwait(false); + + var root = JsonSerializer.Deserialize( + await response.AsStringAsync(ct).ConfigureAwait(false), JsonCompat.SerializerOptions); + var data = root.GetProperty("data"); + if (data.ValueKind == JsonValueKind.Array && data.GetArrayLength() > 0) + { + var fileEl = data[0]; + var modId = fileEl.TryGetProperty("modId", out var mid) ? mid.GetInt32() : 0; + return CurseForgeModelConverter.ToFile(fileEl, modId); + } + throw new InvalidOperationException($"File {fileId} not found on CurseForge."); + } + + public async Task GetFileDownloadUrl(string fileId, CancellationToken ct = default) + { + var file = await GetFile(fileId, ct).ConfigureAwait(false); + return file.DownloadUrl?.ToString() ?? ""; + } + + public async Task GetFileChangelog(string fileId, CancellationToken ct = default) + { + // Need modId for changelog endpoint; fetch file first to get it + var body = JsonSerializer.Serialize(new { fileIds = new[] { int.Parse(fileId) } }, JsonCompat.SerializerOptions); + + using var request = HttpRequest.CreatePost($"{BaseUrl}/v1/mods/files") + .WithContent(body, "application/json") + .ApplyCurseForgeAuth(_apiKey); + using var response = await request.SendAsync(httpClient: _httpClient, addMetedata: _httpClient is null, cancellationToken: ct).ConfigureAwait(false); + await _EnsureSuccess(response, ct).ConfigureAwait(false); + + var root = JsonSerializer.Deserialize( + await response.AsStringAsync(ct).ConfigureAwait(false), JsonCompat.SerializerOptions); + var data = root.GetProperty("data"); + if (data.ValueKind == JsonValueKind.Array && data.GetArrayLength() > 0) + { + var fileEl = data[0]; + var modId = fileEl.TryGetProperty("modId", out var mid) ? mid.GetInt32() : 0; + + var url = $"{BaseUrl}/v1/mods/{modId}/files/{fileId}/changelog"; + var changelogRoot = await _GetJsonAsync(url, ct).ConfigureAwait(false); + return changelogRoot.GetProperty("data").GetString() ?? ""; + } + return ""; + } + + public async Task>> MatchFingerprints( + IEnumerable hashes, HashAlgorithm algo, CancellationToken ct = default) + { + var hashList = hashes.ToList(); + if (hashList.Count == 0) return new Dictionary>(); + + var numericHashes = hashList.Select(h => _ToNumericHash(h)).ToList(); + + var body = JsonSerializer.Serialize( + new { fingerprints = numericHashes }, + JsonCompat.SerializerOptions); + + using var request = HttpRequest.CreatePost($"{BaseUrl}/v1/fingerprints") + .WithContent(body, "application/json") + .ApplyCurseForgeAuth(_apiKey); + using var response = await request.SendAsync(httpClient: _httpClient, addMetedata: _httpClient is null, cancellationToken: ct).ConfigureAwait(false); + await _EnsureSuccess(response, ct).ConfigureAwait(false); + + var root = JsonSerializer.Deserialize( + await response.AsStringAsync(ct).ConfigureAwait(false), JsonCompat.SerializerOptions); + + var result = new Dictionary>(); + if (root.TryGetProperty("data", out var data) && + data.TryGetProperty("exactMatches", out var matches) && + matches.ValueKind == JsonValueKind.Array) + { + foreach (var match in matches.EnumerateArray()) + { + if (match.TryGetProperty("id", out var idProp)) + { + var fileId = idProp.GetInt32().ToString(); + if (match.TryGetProperty("file", out var fileEl)) + { + var modId = fileEl.TryGetProperty("modId", out var mi) ? mi.GetInt32() : 0; + var compFile = CurseForgeModelConverter.ToFile(fileEl, modId); + result[fileId] = [compFile]; + } + } + } + } + return result; + } + + public async Task CheckForUpdates( + string fileHash, HashAlgorithm algo, CompSearchFilter? filter = null, CancellationToken ct = default) + { + var matchResult = await MatchFingerprints([fileHash], algo, ct).ConfigureAwait(false); + if (matchResult.Count == 0) return null; + + var (fileId, files) = matchResult.First(); + var firstFile = files.FirstOrDefault(); + if (firstFile is null) return null; + + var projectFiles = await GetProjectFiles(firstFile.ProjectId, filter, ct).ConfigureAwait(false); + return projectFiles.Count > 0 ? projectFiles[0] : null; + } + + public async Task> GetGameVersions(CancellationToken ct = default) + { + var root = await _GetJsonAsync($"{BaseUrl}/v1/minecraft/version", ct).ConfigureAwait(false); + var data = root.GetProperty("data"); + var results = new List(); + if (data.ValueKind == JsonValueKind.Array) + { + foreach (var item in data.EnumerateArray()) + results.Add(CurseForgeModelConverter.ToGameVersion(item)); + } + return results; + } + + public async Task> GetLoaders(CancellationToken ct = default) + { + var root = await _GetJsonAsync($"{BaseUrl}/v1/minecraft/modloader", ct).ConfigureAwait(false); + var data = root.GetProperty("data"); + var results = new List(); + if (data.ValueKind == JsonValueKind.Array) + { + foreach (var item in data.EnumerateArray()) + results.Add(CurseForgeModelConverter.ToLoader(item)); + } + return results; + } + + public async Task> GetCategories(CancellationToken ct = default) + { + var root = await _GetJsonAsync($"{BaseUrl}/v1/categories?gameId={GameId}", ct).ConfigureAwait(false); + var data = root.GetProperty("data"); + var results = new List(); + if (data.ValueKind == JsonValueKind.Array) + { + foreach (var item in data.EnumerateArray()) + results.Add(CurseForgeModelConverter.ToCategory(item)); + } + return results; + } + + private async Task _GetJsonAsync(string url, CancellationToken ct) + { + await _rateLimiter.WaitIfNeeded(ct).ConfigureAwait(false); + + using var request = HttpRequest.Create(url) + .ApplyCurseForgeAuth(_apiKey); + using var response = await request.SendAsync(httpClient: _httpClient, addMetedata: _httpClient is null, cancellationToken: ct).ConfigureAwait(false); + await _EnsureSuccess(response, ct).ConfigureAwait(false); + + var json = await response.AsStringAsync(ct).ConfigureAwait(false); + return JsonSerializer.Deserialize(json, JsonCompat.SerializerOptions); + } + + private static async Task _EnsureSuccess(HttpResponseMessage response, CancellationToken ct) + { + if (response.IsSuccessStatusCode) return; + + var body = await response.AsStringAsync(ct).ConfigureAwait(false); + throw new CompApiException( + response.StatusCode, + "CurseForge", + $"CurseForge API returned {(int)response.StatusCode}: {response.ReasonPhrase}", + body); + } + + private static string _BuildSearchUrl(CompSearchFilter filter) + { + var queryParams = new List + { + $"gameId={GameId}" + }; + + if (!string.IsNullOrEmpty(filter.Query)) + queryParams.Add($"searchFilter={Uri.EscapeDataString(filter.Query)}"); + if (!string.IsNullOrEmpty(filter.Category)) + queryParams.Add($"categoryId={filter.Category}"); + if (!string.IsNullOrEmpty(filter.GameVersion)) + queryParams.Add($"gameVersion={Uri.EscapeDataString(filter.GameVersion)}"); + if (filter.Loaders.Count > 0) + queryParams.Add($"modLoaderType={(int)filter.Loaders[0]}"); + if (filter.SortField != CompSortField.Relevance) + { + var fieldMap = new Dictionary + { + [CompSortField.Downloads] = 2, + [CompSortField.Updated] = 3, + [CompSortField.Created] = 1, + }; + if (fieldMap.TryGetValue(filter.SortField, out var fieldId)) + queryParams.Add($"sortField={fieldId}"); + } + queryParams.Add($"sortOrder={(filter.SortOrder == SortOrder.Asc ? "asc" : "desc")}"); + queryParams.Add($"index={filter.Offset}"); + queryParams.Add($"pageSize={filter.Limit}"); + + return $"{BaseUrl}/v1/mods/search?{string.Join("&", queryParams)}"; + } + + private static long _ToNumericHash(string hash) + { + if (long.TryParse(hash, System.Globalization.NumberStyles.HexNumber, null, out var result)) + return result; + return hash.GetHashCode(); + } +} diff --git a/PCL.Core/Minecraft/ResourceProject/Comp/Clients/ModrinthClient.cs b/PCL.Core/Minecraft/ResourceProject/Comp/Clients/ModrinthClient.cs new file mode 100644 index 0000000000..3d14323365 --- /dev/null +++ b/PCL.Core/Minecraft/ResourceProject/Comp/Clients/ModrinthClient.cs @@ -0,0 +1,340 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using PCL.Core.IO.Net.Http; +using PCL.Core.Minecraft.ResourceProject.Comp.Abstractions; +using PCL.Core.Minecraft.ResourceProject.Comp.Converters; +using PCL.Core.Minecraft.ResourceProject.Comp.Infrastructure; +using PCL.Core.Minecraft.ResourceProject.Comp.Models; +using PCL.Core.Minecraft.ResourceProject.Comp.Models.Enums; +using PCL.Core.Utils; + +namespace PCL.Core.Minecraft.ResourceProject.Comp.Clients; + +public sealed class ModrinthClient : ICompClient +{ + private const string BaseUrl = "https://api.modrinth.com/v2"; + + private readonly string? _accessToken; + private readonly HttpClient? _httpClient; + private readonly RateLimiter _rateLimiter; + + public ModrinthClient(string? accessToken = null, HttpClient? httpClient = null) + { + _accessToken = accessToken; + _httpClient = httpClient; + _rateLimiter = new RateLimiter(300); + } + + public async Task SearchProjects(CompSearchFilter filter, CancellationToken ct = default) + { + var url = _BuildSearchUrl(filter); + var root = await _GetJsonAsync(url, ct).ConfigureAwait(false); + return ModrinthModelConverter.ToSearchResult(root, filter.Offset, filter.Limit); + } + + public async Task GetProject(string projectId, CancellationToken ct = default) + { + var url = $"{BaseUrl}/project/{projectId}"; + var root = await _GetJsonAsync(url, ct).ConfigureAwait(false); + return ModrinthModelConverter.ToProject(root); + } + + public async Task> GetProjects(IEnumerable projectIds, CancellationToken ct = default) + { + var ids = projectIds.ToList(); + if (ids.Count == 0) return []; + + var idsJson = JsonSerializer.Serialize(ids, JsonCompat.SerializerOptions); + var url = $"{BaseUrl}/projects?ids={Uri.EscapeDataString(idsJson)}"; + var root = await _GetJsonAsync(url, ct).ConfigureAwait(false); + + var results = new List(); + if (root.ValueKind == JsonValueKind.Array) + { + foreach (var item in root.EnumerateArray()) + results.Add(ModrinthModelConverter.ToProject(item)); + } + return results; + } + + public async Task> GetFeaturedProjects(string? gameVersion = null, CancellationToken ct = default) + { + var filter = new CompSearchFilter + { + SortField = CompSortField.Downloads, + SortOrder = SortOrder.Desc, + Limit = 20, + GameVersion = gameVersion + }; + var result = await SearchProjects(filter, ct).ConfigureAwait(false); + return result.Hits; + } + + public async Task GetProjectDescription(string projectId, CancellationToken ct = default) + { + var project = await GetProject(projectId, ct).ConfigureAwait(false); + return project.DescriptionHtml ?? project.Summary; + } + + public async Task> GetProjectFiles(string projectId, CompSearchFilter? filter = null, CancellationToken ct = default) + { + var url = $"{BaseUrl}/project/{projectId}/version"; + if (filter is not null) + { + var queryParams = new List(); + if (!string.IsNullOrEmpty(filter.GameVersion)) + queryParams.Add($"game_versions=[\"{filter.GameVersion}\"]"); + if (filter.Loaders.Count > 0) + queryParams.Add($"loaders=[{string.Join(",", filter.Loaders.Select(l => $"\"{_LoaderToString(l)}\""))}]"); + if (queryParams.Count > 0) + url += "?" + string.Join("&", queryParams); + } + + var root = await _GetJsonAsync(url, ct).ConfigureAwait(false); + var results = new List(); + if (root.ValueKind == JsonValueKind.Array) + { + foreach (var item in root.EnumerateArray()) + results.Add(ModrinthModelConverter.ToFile(item)); + } + return results; + } + + public async Task GetFile(string fileId, CancellationToken ct = default) + { + var url = $"{BaseUrl}/version/{fileId}"; + var root = await _GetJsonAsync(url, ct).ConfigureAwait(false); + return ModrinthModelConverter.ToFile(root); + } + + public async Task GetFileDownloadUrl(string fileId, CancellationToken ct = default) + { + var file = await GetFile(fileId, ct).ConfigureAwait(false); + return file.DownloadUrl?.ToString() ?? ""; + } + + public async Task GetFileChangelog(string fileId, CancellationToken ct = default) + { + var file = await GetFile(fileId, ct).ConfigureAwait(false); + return file.Changelog ?? ""; + } + + public async Task>> MatchFingerprints( + IEnumerable hashes, HashAlgorithm algo, CancellationToken ct = default) + { + var hashList = hashes.ToList(); + if (hashList.Count == 0) return new Dictionary>(); + + var algoStr = algo switch + { + HashAlgorithm.Sha1 => "sha1", + HashAlgorithm.Sha512 => "sha512", + HashAlgorithm.Md5 => "md5", + _ => "sha1" + }; + + var body = JsonSerializer.Serialize( + new { hashes = hashList, algorithm = algoStr }, + JsonCompat.SerializerOptions); + + await _rateLimiter.WaitIfNeeded(ct).ConfigureAwait(false); + + using var request = HttpRequest.CreatePost($"{BaseUrl}/version_files/from_hashes") + .WithContent(body, "application/json") + .ApplyModrinthAuth(_accessToken); + using var response = await request.SendAsync(httpClient: _httpClient, addMetedata: _httpClient is null, cancellationToken: ct).ConfigureAwait(false); + await _EnsureSuccess(response, ct).ConfigureAwait(false); + + var root = JsonSerializer.Deserialize( + await response.AsStringAsync(ct).ConfigureAwait(false), JsonCompat.SerializerOptions); + + var result = new Dictionary>(); + foreach (var entry in root.EnumerateObject()) + { + var files = new List(); + if (entry.Value.ValueKind == JsonValueKind.Array) + { + foreach (var item in entry.Value.EnumerateArray()) + files.Add(ModrinthModelConverter.ToFile(item)); + } + result[entry.Name] = files; + } + return result; + } + + public async Task CheckForUpdates( + string fileHash, HashAlgorithm algo, CompSearchFilter? filter = null, CancellationToken ct = default) + { + var algoStr = algo switch + { + HashAlgorithm.Sha1 => "sha1", + HashAlgorithm.Sha512 => "sha512", + HashAlgorithm.Md5 => "md5", + _ => "sha1" + }; + + var url = $"{BaseUrl}/version_file/{fileHash}/update"; + if (filter is not null) + { + var queryParams = new List(); + if (!string.IsNullOrEmpty(filter.GameVersion)) + queryParams.Add($"game_versions=[\"{filter.GameVersion}\"]"); + if (filter.Loaders.Count > 0) + queryParams.Add($"loaders=[{string.Join(",", filter.Loaders.Select(l => $"\"{_LoaderToString(l)}\""))}]"); + if (queryParams.Count > 0) + url += "?" + string.Join("&", queryParams); + } + + await _rateLimiter.WaitIfNeeded(ct).ConfigureAwait(false); + + using var request = HttpRequest.Create(url).ApplyModrinthAuth(_accessToken); + using var response = await request.SendAsync(httpClient: _httpClient, addMetedata: _httpClient is null, cancellationToken: ct).ConfigureAwait(false); + + if (response.StatusCode == System.Net.HttpStatusCode.NotFound) + return null; + + await _EnsureSuccess(response, ct).ConfigureAwait(false); + + var root = JsonSerializer.Deserialize( + await response.AsStringAsync(ct).ConfigureAwait(false), JsonCompat.SerializerOptions); + return ModrinthModelConverter.ToFile(root); + } + + public async Task> GetGameVersions(CancellationToken ct = default) + { + var root = await _GetJsonAsync($"{BaseUrl}/tag/game_version", ct).ConfigureAwait(false); + var results = new List(); + if (root.ValueKind == JsonValueKind.Array) + { + foreach (var item in root.EnumerateArray()) + results.Add(ModrinthModelConverter.ToGameVersion(item)); + } + return results; + } + + public async Task> GetLoaders(CancellationToken ct = default) + { + var root = await _GetJsonAsync($"{BaseUrl}/tag/loader", ct).ConfigureAwait(false); + var results = new List(); + if (root.ValueKind == JsonValueKind.Array) + { + foreach (var item in root.EnumerateArray()) + results.Add(ModrinthModelConverter.ToLoader(item)); + } + return results; + } + + public async Task> GetCategories(CancellationToken ct = default) + { + var root = await _GetJsonAsync($"{BaseUrl}/tag/category", ct).ConfigureAwait(false); + var results = new List(); + if (root.ValueKind == JsonValueKind.Array) + { + foreach (var item in root.EnumerateArray()) + results.Add(ModrinthModelConverter.ToCategory(item)); + } + return results; + } + + private async Task _GetJsonAsync(string url, CancellationToken ct) + { + await _rateLimiter.WaitIfNeeded(ct).ConfigureAwait(false); + + using var request = HttpRequest.Create(url).ApplyModrinthAuth(_accessToken); + using var response = await request.SendAsync(httpClient: _httpClient, addMetedata: _httpClient is null, cancellationToken: ct).ConfigureAwait(false); + await _EnsureSuccess(response, ct).ConfigureAwait(false); + + var json = await response.AsStringAsync(ct).ConfigureAwait(false); + return JsonSerializer.Deserialize(json, JsonCompat.SerializerOptions); + } + + private static async Task _EnsureSuccess(HttpResponseMessage response, CancellationToken ct) + { + if (response.IsSuccessStatusCode) return; + + var body = await response.AsStringAsync(ct).ConfigureAwait(false); + throw new CompApiException( + response.StatusCode, + "Modrinth", + $"Modrinth API returned {(int)response.StatusCode}: {response.ReasonPhrase}", + body); + } + + private static string _BuildSearchUrl(CompSearchFilter filter) + { + var queryParams = new List(); + + if (!string.IsNullOrEmpty(filter.Query)) + queryParams.Add($"query={Uri.EscapeDataString(filter.Query)}"); + + var facets = new List(); + if (filter.ProjectType.HasValue && filter.ProjectType.Value != CompProjectType.Unknown) + { + var typeStr = filter.ProjectType.Value switch + { + CompProjectType.Mod => "mod", + CompProjectType.Modpack => "modpack", + CompProjectType.ResourcePack => "resourcepack", + CompProjectType.Shader => "shader", + CompProjectType.DataPack => "datapack", + _ => null + }; + if (typeStr is not null) + facets.Add($"project_type:{typeStr}"); + } + if (!string.IsNullOrEmpty(filter.Category)) + facets.Add($"categories:{filter.Category}"); + if (!string.IsNullOrEmpty(filter.GameVersion)) + facets.Add($"versions:{filter.GameVersion}"); + if (filter.Loaders.Count > 0) + { + foreach (var loader in filter.Loaders) + { + var loaderStr = _LoaderToString(loader); + if (loaderStr is not null) + facets.Add($"categories:{loaderStr}"); + } + } + + if (facets.Count > 0) + { + var facetsJson = JsonSerializer.Serialize(facets.Select(f => new[] { f }), JsonCompat.SerializerOptions); + queryParams.Add($"facets={Uri.EscapeDataString(facetsJson)}"); + } + + var indexStr = filter.SortField switch + { + CompSortField.Downloads => "downloads", + CompSortField.Follows => "follows", + CompSortField.Updated => "updated", + CompSortField.Created => "newest", + _ => "relevance" + }; + queryParams.Add($"index={indexStr}"); + + queryParams.Add($"offset={filter.Offset}"); + queryParams.Add($"limit={filter.Limit}"); + + return $"{BaseUrl}/search?{string.Join("&", queryParams)}"; + } + + private static string? _LoaderToString(ModLoaderType loader) + { + return loader switch + { + ModLoaderType.Forge => "forge", + ModLoaderType.Fabric => "fabric", + ModLoaderType.Quilt => "quilt", + ModLoaderType.NeoForge => "neoforge", + ModLoaderType.Rift => "rift", + ModLoaderType.LiteLoader => "liteloader", + ModLoaderType.Any => null, + _ => null + }; + } +} diff --git a/PCL.Core/Minecraft/ResourceProject/Comp/Converters/CurseForgeModelConverter.cs b/PCL.Core/Minecraft/ResourceProject/Comp/Converters/CurseForgeModelConverter.cs new file mode 100644 index 0000000000..b330c8744b --- /dev/null +++ b/PCL.Core/Minecraft/ResourceProject/Comp/Converters/CurseForgeModelConverter.cs @@ -0,0 +1,315 @@ +using System; +using System.Collections.Generic; +using System.Text.Json; +using PCL.Core.Minecraft.ResourceProject.Comp.Models; +using PCL.Core.Minecraft.ResourceProject.Comp.Models.Enums; + +namespace PCL.Core.Minecraft.ResourceProject.Comp.Converters; + +public static class CurseForgeModelConverter +{ + public static CompProject ToProject(JsonElement data) + { + var id = data.GetProperty("id").GetInt32().ToString(); + var name = data.GetProperty("name").GetString() ?? ""; + var slug = data.GetProperty("slug").GetString() ?? ""; + var summary = data.GetProperty("summary").GetString() ?? ""; + + Uri? iconUrl = null; + if (data.TryGetProperty("logo", out var logo) && logo.ValueKind == JsonValueKind.Object) + { + var urlStr = logo.GetProperty("url").GetString(); + if (!string.IsNullOrEmpty(urlStr) && Uri.TryCreate(urlStr, UriKind.Absolute, out var uri)) + iconUrl = uri; + } + + var categories = new List(); + if (data.TryGetProperty("categories", out var cats) && cats.ValueKind == JsonValueKind.Array) + { + foreach (var c in cats.EnumerateArray()) + { + var cSlug = c.GetProperty("slug").GetString(); + if (!string.IsNullOrEmpty(cSlug)) + categories.Add(cSlug); + } + } + + var gameVersions = new List(); + if (data.TryGetProperty("latestFiles", out var files) && files.ValueKind == JsonValueKind.Array) + { + foreach (var f in files.EnumerateArray()) + { + if (f.TryGetProperty("gameVersions", out var gvs) && gvs.ValueKind == JsonValueKind.Array) + { + foreach (var gv in gvs.EnumerateArray()) + { + var gvStr = gv.GetString(); + if (!string.IsNullOrEmpty(gvStr) && !gameVersions.Contains(gvStr)) + gameVersions.Add(gvStr); + } + } + } + } + + var downloadCount = data.TryGetProperty("downloadCount", out var dc) ? dc.GetInt32() : 0; + var dateCreated = _ParseDateTime(data, "dateCreated"); + var dateModified = _ParseDateTime(data, "dateModified"); + + string? author = null; + if (data.TryGetProperty("authors", out var authors) && authors.ValueKind == JsonValueKind.Array) + { + foreach (var a in authors.EnumerateArray()) + { + author = a.GetProperty("name").GetString(); + if (!string.IsNullOrEmpty(author)) break; + } + } + + Uri? siteUrl = null; + Uri? sourceUrl = null; + if (data.TryGetProperty("links", out var links) && links.ValueKind == JsonValueKind.Object) + { + var ws = links.GetProperty("websiteUrl").GetString(); + if (!string.IsNullOrEmpty(ws) && Uri.TryCreate(ws, UriKind.Absolute, out var sui)) + siteUrl = sui; + + var src = links.GetProperty("sourceUrl").GetString(); + if (!string.IsNullOrEmpty(src) && Uri.TryCreate(src, UriKind.Absolute, out var sui2)) + sourceUrl = sui2; + } + + var status = _MapStatus(data.TryGetProperty("status", out var st) ? st.GetInt32() : 0); + + return new CompProject( + id, "CurseForge", slug, name, summary, null, iconUrl, + categories, gameVersions, downloadCount, null, + CompProjectType.Mod, null, dateCreated, dateModified, + author, siteUrl, null, sourceUrl, status); + } + + public static CompFile ToFile(JsonElement data, int modId) + { + var fileId = data.GetProperty("id").GetInt32().ToString(); + var displayName = data.GetProperty("displayName").GetString() ?? ""; + var fileName = data.GetProperty("fileName").GetString() ?? ""; + + Uri? downloadUrl = null; + if (data.TryGetProperty("downloadUrl", out var du) && du.ValueKind == JsonValueKind.String) + { + var duStr = du.GetString(); + if (!string.IsNullOrEmpty(duStr) && Uri.TryCreate(duStr, UriKind.Absolute, out var uri)) + downloadUrl = uri; + } + + var fileLength = data.TryGetProperty("fileLength", out var fl) ? fl.GetInt64() : 0L; + + var releaseType = data.TryGetProperty("releaseType", out var rt) ? rt.GetInt32() : 0; + var releaseTypeStr = releaseType switch + { + 1 => "release", + 2 => "beta", + 3 => "alpha", + _ => "release" + }; + + var gameVersions = new List(); + if (data.TryGetProperty("gameVersions", out var gvs) && gvs.ValueKind == JsonValueKind.Array) + { + foreach (var gv in gvs.EnumerateArray()) + { + var gvStr = gv.GetString(); + if (!string.IsNullOrEmpty(gvStr)) gameVersions.Add(gvStr); + } + } + + var loaders = new List(); + if (data.TryGetProperty("sortableGameVersions", out var sgvs) && sgvs.ValueKind == JsonValueKind.Array) + { + foreach (var sgv in sgvs.EnumerateArray()) + { + if (sgv.TryGetProperty("modLoader", out var ml) && ml.ValueKind == JsonValueKind.Number) + { + loaders.Add(_MapLoaderType(ml.GetInt32())); + } + } + } + + var hashes = new Dictionary(); + if (data.TryGetProperty("hashes", out var hashesEl) && hashesEl.ValueKind == JsonValueKind.Array) + { + foreach (var h in hashesEl.EnumerateArray()) + { + var algo = h.GetProperty("algo").GetInt32(); + var value = h.GetProperty("value").GetString() ?? ""; + var key = algo switch + { + 1 => HashAlgorithm.Sha1, + 2 => HashAlgorithm.Md5, + _ => (HashAlgorithm?)null + }; + if (key.HasValue && !string.IsNullOrEmpty(value)) + hashes[key.Value] = value; + } + } + + var dependencies = new List(); + if (data.TryGetProperty("dependencies", out var deps) && deps.ValueKind == JsonValueKind.Array) + { + foreach (var d in deps.EnumerateArray()) + { + var depFileId = d.GetProperty("modId").GetInt32().ToString(); + var relationType = d.GetProperty("relationType").GetInt32() switch + { + 1 => "required", + 2 => "optional", + 3 => "incompatible", + _ => "unknown" + }; + dependencies.Add(new CompFileDependency(depFileId, depFileId, relationType)); + } + } + + var datePublished = _ParseDateTime(data, "fileDate"); + var downloadCount = data.TryGetProperty("downloadCount", out var dn) ? dn.GetInt32() : 0; + var isAvailable = data.TryGetProperty("isAvailable", out var ia) && ia.GetBoolean(); + + return new CompFile( + fileId, modId.ToString(), displayName, fileName, downloadUrl, + fileLength, releaseTypeStr, gameVersions, loaders, hashes, + dependencies, null, datePublished, downloadCount, isAvailable); + } + + public static CompSearchResult ToSearchResult(JsonElement root, int offset, int limit) + { + var data = root.GetProperty("data"); + var pagination = root.GetProperty("pagination"); + var totalCount = pagination.GetProperty("totalCount").GetInt32(); + + var hits = new List(); + if (data.ValueKind == JsonValueKind.Array) + { + foreach (var item in data.EnumerateArray()) + hits.Add(ToProject(item)); + } + + return new CompSearchResult(hits, totalCount, offset, limit); + } + + public static CompCategory ToCategory(JsonElement data) + { + var id = data.GetProperty("id").GetInt32().ToString(); + var name = data.GetProperty("name").GetString() ?? ""; + var slug = data.GetProperty("slug").GetString() ?? ""; + + Uri? iconUrl = null; + if (data.TryGetProperty("iconUrl", out var ic) && ic.ValueKind == JsonValueKind.String) + { + var icStr = ic.GetString(); + if (!string.IsNullOrEmpty(icStr) && Uri.TryCreate(icStr, UriKind.Absolute, out var uri)) + iconUrl = uri; + } + + string? parentId = null; + if (data.TryGetProperty("parentCategoryId", out var pid) && pid.ValueKind == JsonValueKind.Number) + { + var pidVal = pid.GetInt32(); + if (pidVal > 0) parentId = pidVal.ToString(); + } + + string? classId = null; + if (data.TryGetProperty("classId", out var cid) && cid.ValueKind == JsonValueKind.Number) + { + classId = cid.GetInt32().ToString(); + } + + return new CompCategory(id, name, slug, iconUrl, parentId, classId); + } + + public static CompGameVersion ToGameVersion(JsonElement data) + { + var id = data.TryGetProperty("id", out var idProp) ? idProp.GetInt32().ToString() : ""; + var version = data.GetProperty("versionString").GetString() ?? ""; + + string? versionType = null; + if (data.TryGetProperty("type", out var vt) && vt.ValueKind == JsonValueKind.Number) + { + versionType = vt.GetInt32() switch + { + 1 => "release", + 2 => "snapshot", + 3 => "alpha", + 4 => "beta", + _ => null + }; + } + + DateTime? dateModified = _ParseDateTimeOrNull(data, "modified"); + + return new CompGameVersion(id, version, versionType, dateModified); + } + + public static CompLoader ToLoader(JsonElement data) + { + var name = data.GetProperty("name").GetString() ?? ""; + var displayName = data.TryGetProperty("displayName", out var dn) + ? dn.GetString() + : null; + + var loaderType = _MapLoaderType(data.TryGetProperty("id", out var id) ? id.GetInt32() : 0); + + var isLatest = data.TryGetProperty("latest", out var lt) && lt.GetBoolean(); + + return new CompLoader(name, displayName, loaderType, isLatest); + } + + private static CompProjectStatus _MapStatus(int cfStatus) + { + return cfStatus switch + { + 1 => CompProjectStatus.Draft, + 2 => CompProjectStatus.Processing, + 3 => CompProjectStatus.Processing, + 4 => CompProjectStatus.Rejected, + 5 => CompProjectStatus.Approved, + 6 => CompProjectStatus.Approved, + _ => CompProjectStatus.Unknown + }; + } + + private static ModLoaderType _MapLoaderType(int cfLoader) + { + return cfLoader switch + { + 0 => ModLoaderType.Any, + 1 => ModLoaderType.Forge, + 2 => ModLoaderType.Cauldron, + 3 => ModLoaderType.LiteLoader, + 4 => ModLoaderType.Fabric, + 5 => ModLoaderType.Quilt, + 6 => ModLoaderType.NeoForge, + _ => ModLoaderType.Any + }; + } + + private static DateTime _ParseDateTime(JsonElement data, string property) + { + if (data.TryGetProperty(property, out var prop) && prop.ValueKind == JsonValueKind.String) + { + var str = prop.GetString(); + if (!string.IsNullOrEmpty(str) && DateTime.TryParse(str, out var dt)) + return dt; + } + return DateTime.MinValue; + } + + private static DateTime? _ParseDateTimeOrNull(JsonElement data, string property) + { + if (data.TryGetProperty(property, out var prop) && prop.ValueKind == JsonValueKind.String) + { + var str = prop.GetString(); + if (!string.IsNullOrEmpty(str) && DateTime.TryParse(str, out var dt)) + return dt; + } + return null; + } +} diff --git a/PCL.Core/Minecraft/ResourceProject/Comp/Converters/ModrinthModelConverter.cs b/PCL.Core/Minecraft/ResourceProject/Comp/Converters/ModrinthModelConverter.cs new file mode 100644 index 0000000000..ce3a2eb830 --- /dev/null +++ b/PCL.Core/Minecraft/ResourceProject/Comp/Converters/ModrinthModelConverter.cs @@ -0,0 +1,365 @@ +using System; +using System.Collections.Generic; +using System.Text.Json; +using PCL.Core.Minecraft.ResourceProject.Comp.Models; +using PCL.Core.Minecraft.ResourceProject.Comp.Models.Enums; + +namespace PCL.Core.Minecraft.ResourceProject.Comp.Converters; + +public static class ModrinthModelConverter +{ + + public static CompProject ToProject(JsonElement data) + { + // Search hits use "project_id", full project uses "id" + var id = data.TryGetProperty("project_id", out var pidEl) + ? pidEl.GetString() ?? "" + : data.TryGetProperty("id", out var idEl) + ? idEl.GetString() ?? "" + : ""; + + var slug = data.GetProperty("slug").GetString() ?? ""; + var title = data.GetProperty("title").GetString() ?? ""; + var description = data.GetProperty("description").GetString() ?? ""; + + string? bodyHtml = null; + if (data.TryGetProperty("body", out var body) && body.ValueKind == JsonValueKind.String) + bodyHtml = body.GetString(); + + Uri? iconUrl = null; + if (data.TryGetProperty("icon_url", out var icon) && icon.ValueKind == JsonValueKind.String) + { + var iconStr = icon.GetString(); + if (!string.IsNullOrEmpty(iconStr) && Uri.TryCreate(iconStr, UriKind.Absolute, out var uri)) + iconUrl = uri; + } + + var categories = new List(); + if (data.TryGetProperty("categories", out var cats) && cats.ValueKind == JsonValueKind.Array) + { + foreach (var c in cats.EnumerateArray()) + { + var cStr = c.GetString(); + if (!string.IsNullOrEmpty(cStr)) categories.Add(cStr); + } + } + + // Search returns "versions", full project returns "game_versions" + var gameVersions = new List(); + JsonElement gvEl; + if (data.TryGetProperty("game_versions", out gvEl) && gvEl.ValueKind == JsonValueKind.Array) + { + foreach (var gv in gvEl.EnumerateArray()) + { + var gvStr = gv.GetString(); + if (!string.IsNullOrEmpty(gvStr)) gameVersions.Add(gvStr); + } + } + else if (data.TryGetProperty("versions", out gvEl) && gvEl.ValueKind == JsonValueKind.Array) + { + foreach (var gv in gvEl.EnumerateArray()) + { + var gvStr = gv.GetString(); + if (!string.IsNullOrEmpty(gvStr)) gameVersions.Add(gvStr); + } + } + + var downloads = data.TryGetProperty("downloads", out var dn) ? dn.GetInt32() : 0; + + // Search returns "follows", full project returns "followers" + var followers = data.TryGetProperty("followers", out var fw) + ? fw.GetInt32() + : data.TryGetProperty("follows", out var fl) + ? fl.GetInt32() + : 0; + + var projectType = data.TryGetProperty("project_type", out var ptEl) + ? _MapProjectType(ptEl.GetString()) + : CompProjectType.Mod; + + // License: search returns string, full project returns object + string? license = null; + if (data.TryGetProperty("license", out var lic)) + { + if (lic.ValueKind == JsonValueKind.Object) + { + if (lic.TryGetProperty("id", out var licId)) + license = licId.GetString(); + } + else if (lic.ValueKind == JsonValueKind.String) + { + license = lic.GetString(); + } + } + + // Dates: search uses date_created/date_modified, full uses published/updated + var datePublished = _ParseDateTime(data, "published") != DateTime.MinValue + ? _ParseDateTime(data, "published") + : _ParseDateTime(data, "date_created"); + var dateUpdated = _ParseDateTime(data, "updated") != DateTime.MinValue + ? _ParseDateTime(data, "updated") + : _ParseDateTime(data, "date_modified"); + + string? author = null; + if (data.TryGetProperty("author", out var au) && au.ValueKind == JsonValueKind.String) + author = au.GetString(); + + Uri? issuesUrl = null; + if (data.TryGetProperty("issues_url", out var iu) && iu.ValueKind == JsonValueKind.String) + { + var iuStr = iu.GetString(); + if (!string.IsNullOrEmpty(iuStr) && Uri.TryCreate(iuStr, UriKind.Absolute, out var uri)) + issuesUrl = uri; + } + + Uri? sourceUrl = null; + if (data.TryGetProperty("source_url", out var su) && su.ValueKind == JsonValueKind.String) + { + var suStr = su.GetString(); + if (!string.IsNullOrEmpty(suStr) && Uri.TryCreate(suStr, UriKind.Absolute, out var uri)) + sourceUrl = uri; + } + + // Search hits don't have "status", default to Approved + var status = data.TryGetProperty("status", out var stEl) && stEl.ValueKind == JsonValueKind.String + ? _MapStatus(stEl.GetString()) + : CompProjectStatus.Approved; + + return new CompProject( + id, "Modrinth", slug, title, description, bodyHtml, iconUrl, + categories, gameVersions, downloads, followers, + projectType, license, datePublished, dateUpdated, + author, null, issuesUrl, sourceUrl, status); + } + + public static CompFile ToFile(JsonElement data) + { + var id = data.GetProperty("id").GetString() ?? ""; + var projectId = data.TryGetProperty("project_id", out var pid) + ? pid.GetString() ?? "" + : ""; + + var displayName = data.GetProperty("name").GetString() ?? ""; + + string? fileName = null; + Uri? downloadUrl = null; + long fileLength = 0; + var hashes = new Dictionary(); + + if (data.TryGetProperty("files", out var filesArr) && filesArr.ValueKind == JsonValueKind.Array) + { + foreach (var f in filesArr.EnumerateArray()) + { + fileName ??= f.GetProperty("filename").GetString(); + + if (downloadUrl is null && f.TryGetProperty("url", out var url) && url.ValueKind == JsonValueKind.String) + { + var urlStr = url.GetString(); + if (!string.IsNullOrEmpty(urlStr) && Uri.TryCreate(urlStr, UriKind.Absolute, out var uri)) + downloadUrl = uri; + } + + if (f.TryGetProperty("size", out var sz)) + fileLength = sz.GetInt64(); + + if (f.TryGetProperty("hashes", out var hs) && hs.ValueKind == JsonValueKind.Object) + { + foreach (var h in hs.EnumerateObject()) + { + var algo = h.Name switch + { + "sha1" => HashAlgorithm.Sha1, + "sha512" => HashAlgorithm.Sha512, + "md5" => HashAlgorithm.Md5, + _ => (HashAlgorithm?)null + }; + if (algo.HasValue) + { + var val = h.Value.GetString(); + if (!string.IsNullOrEmpty(val)) + hashes[algo.Value] = val; + } + } + } + } + } + + var releaseType = data.TryGetProperty("version_type", out var vt) + ? vt.GetString() ?? "release" + : "release"; + + var gameVersions = new List(); + if (data.TryGetProperty("game_versions", out var gvs) && gvs.ValueKind == JsonValueKind.Array) + { + foreach (var gv in gvs.EnumerateArray()) + { + var gvStr = gv.GetString(); + if (!string.IsNullOrEmpty(gvStr)) gameVersions.Add(gvStr); + } + } + + var loaders = new List(); + if (data.TryGetProperty("loaders", out var lds) && lds.ValueKind == JsonValueKind.Array) + { + foreach (var l in lds.EnumerateArray()) + { + var loader = l.GetString(); + if (!string.IsNullOrEmpty(loader)) + loaders.Add(_MapLoaderString(loader)); + } + } + + var dependencies = new List(); + if (data.TryGetProperty("dependencies", out var deps) && deps.ValueKind == JsonValueKind.Array) + { + foreach (var d in deps.EnumerateArray()) + { + var depProjectId = d.TryGetProperty("project_id", out var dpid) + ? dpid.GetString() ?? "" + : ""; + var depFileName = d.TryGetProperty("file_name", out var dfn) + ? dfn.GetString() + : null; + var relationType = d.GetProperty("dependency_type").GetString() ?? "required"; + dependencies.Add(new CompFileDependency(depFileName ?? depProjectId, depProjectId, relationType)); + } + } + + string? changelog = null; + if (data.TryGetProperty("changelog", out var cl) && cl.ValueKind == JsonValueKind.String) + changelog = cl.GetString(); + + var datePublished = _ParseDateTime(data, "date_published"); + var downloadCount = data.TryGetProperty("downloads", out var dnl) ? dnl.GetInt32() : 0; + + var isAvailable = data.TryGetProperty("status", out var st) + ? st.GetString() == "listed" + : true; + + return new CompFile( + id, projectId, displayName, fileName ?? "", downloadUrl, + fileLength, releaseType, gameVersions, loaders, hashes, + dependencies, changelog, datePublished, downloadCount, isAvailable); + } + + public static CompSearchResult ToSearchResult(JsonElement root, int offset, int limit) + { + var hits = root.GetProperty("hits"); + var totalCount = root.TryGetProperty("total_hits", out var tc) ? tc.GetInt32() : 0; + + var projects = new List(); + if (hits.ValueKind == JsonValueKind.Array) + { + foreach (var hit in hits.EnumerateArray()) + projects.Add(ToProject(hit)); + } + + return new CompSearchResult(projects, totalCount, offset, limit); + } + + public static CompCategory ToCategory(JsonElement data) + { + var name = data.GetProperty("name").GetString() ?? ""; + var icon = data.TryGetProperty("icon", out var ic) ? ic.GetString() : null; + + Uri? iconUrl = null; + if (!string.IsNullOrEmpty(icon) && Uri.TryCreate(icon, UriKind.Absolute, out var uri)) + iconUrl = uri; + + string? projectType = null; + if (data.TryGetProperty("project_type", out var pt)) + projectType = pt.GetString(); + + return new CompCategory(name, name, name.ToLowerInvariant(), iconUrl, null, projectType); + } + + public static CompGameVersion ToGameVersion(JsonElement data) + { + var version = data.GetProperty("version").GetString() ?? ""; + var versionType = data.TryGetProperty("version_type", out var vt) + ? vt.GetString() + : null; + + DateTime? dateModified = null; + if (data.TryGetProperty("updated", out var up) && up.ValueKind == JsonValueKind.String) + { + var upStr = up.GetString(); + if (!string.IsNullOrEmpty(upStr) && DateTime.TryParse(upStr, out var dt)) + dateModified = dt; + } + + return new CompGameVersion(version, version, versionType, dateModified); + } + + public static CompLoader ToLoader(JsonElement data) + { + var name = data.GetProperty("name").GetString() ?? ""; + var displayName = data.TryGetProperty("display_name", out var dn) + ? dn.GetString() + : null; + + var loaderType = _MapLoaderString(data.TryGetProperty("name", out var nm) + ? nm.GetString() ?? "" + : ""); + + var isLatest = data.TryGetProperty("latest", out var lt) && lt.GetBoolean(); + + return new CompLoader(name, displayName, loaderType, isLatest); + } + + private static CompProjectType _MapProjectType(string? type) + { + return type?.ToLowerInvariant() switch + { + "mod" => CompProjectType.Mod, + "modpack" => CompProjectType.Modpack, + "resourcepack" => CompProjectType.ResourcePack, + "shader" => CompProjectType.Shader, + "datapack" => CompProjectType.DataPack, + _ => CompProjectType.Unknown + }; + } + + private static CompProjectStatus _MapStatus(string? status) + { + return status?.ToLowerInvariant() switch + { + "approved" => CompProjectStatus.Approved, + "draft" => CompProjectStatus.Draft, + "rejected" => CompProjectStatus.Rejected, + "archived" => CompProjectStatus.Archived, + "unlisted" => CompProjectStatus.Unlisted, + "processing" => CompProjectStatus.Processing, + "withheld" => CompProjectStatus.Withheld, + "scheduled" => CompProjectStatus.Scheduled, + _ => CompProjectStatus.Unknown + }; + } + + private static ModLoaderType _MapLoaderString(string loader) + { + return loader.ToLowerInvariant() switch + { + "forge" => ModLoaderType.Forge, + "fabric" => ModLoaderType.Fabric, + "quilt" => ModLoaderType.Quilt, + "neoforge" => ModLoaderType.NeoForge, + "rift" => ModLoaderType.Rift, + "liteloader" => ModLoaderType.LiteLoader, + "cauldron" => ModLoaderType.Cauldron, + "data" => ModLoaderType.Data, + _ => ModLoaderType.Any + }; + } + + private static DateTime _ParseDateTime(JsonElement data, string property) + { + if (data.TryGetProperty(property, out var prop) && prop.ValueKind == JsonValueKind.String) + { + var str = prop.GetString(); + if (!string.IsNullOrEmpty(str) && DateTime.TryParse(str, out var dt)) + return dt; + } + return DateTime.MinValue; + } +} diff --git a/PCL.Core/Minecraft/ResourceProject/Comp/Infrastructure/AuthHandler.cs b/PCL.Core/Minecraft/ResourceProject/Comp/Infrastructure/AuthHandler.cs new file mode 100644 index 0000000000..0913f25c98 --- /dev/null +++ b/PCL.Core/Minecraft/ResourceProject/Comp/Infrastructure/AuthHandler.cs @@ -0,0 +1,19 @@ +using System.Net.Http; +using PCL.Core.IO.Net.Http; + +namespace PCL.Core.Minecraft.ResourceProject.Comp.Infrastructure; + +public static class AuthHandler +{ + public static HttpRequestMessage ApplyCurseForgeAuth(this HttpRequestMessage request, string apiKey) + { + return request.WithHeader("x-api-key", apiKey); + } + + public static HttpRequestMessage ApplyModrinthAuth(this HttpRequestMessage request, string? accessToken) + { + if (!string.IsNullOrEmpty(accessToken)) + return request.WithBearerToken(accessToken); + return request; + } +} diff --git a/PCL.Core/Minecraft/ResourceProject/Comp/Infrastructure/CompApiException.cs b/PCL.Core/Minecraft/ResourceProject/Comp/Infrastructure/CompApiException.cs new file mode 100644 index 0000000000..3bdcf72905 --- /dev/null +++ b/PCL.Core/Minecraft/ResourceProject/Comp/Infrastructure/CompApiException.cs @@ -0,0 +1,29 @@ +using System; +using System.Net; + +namespace PCL.Core.Minecraft.ResourceProject.Comp.Infrastructure; + +public sealed class CompApiException : Exception +{ + public HttpStatusCode StatusCode { get; } + public string Provider { get; } + public string? RawResponse { get; } + + public CompApiException( + HttpStatusCode statusCode, + string provider, + string message, + string? rawResponse = null, + Exception? innerException = null) + : base(message, innerException) + { + StatusCode = statusCode; + Provider = provider; + RawResponse = rawResponse; + } + + public bool IsRateLimited => StatusCode == HttpStatusCode.TooManyRequests; + public bool IsNotFound => StatusCode == HttpStatusCode.NotFound; + public bool IsUnauthorized => StatusCode == HttpStatusCode.Unauthorized; + public bool IsServerError => (int)StatusCode >= 500; +} diff --git a/PCL.Core/Minecraft/ResourceProject/Comp/Infrastructure/RateLimiter.cs b/PCL.Core/Minecraft/ResourceProject/Comp/Infrastructure/RateLimiter.cs new file mode 100644 index 0000000000..e3c9528253 --- /dev/null +++ b/PCL.Core/Minecraft/ResourceProject/Comp/Infrastructure/RateLimiter.cs @@ -0,0 +1,53 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace PCL.Core.Minecraft.ResourceProject.Comp.Infrastructure; + +public sealed class RateLimiter +{ + private readonly int _maxRequests; + private readonly TimeSpan _window; + private readonly SemaphoreSlim _semaphore; + private int _requestsInWindow; + private DateTime _windowStart; + + public RateLimiter(int maxRequestsPerMinute = 300) + { + _maxRequests = maxRequestsPerMinute; + _window = TimeSpan.FromMinutes(1); + _semaphore = new SemaphoreSlim(1, 1); + _windowStart = DateTime.UtcNow; + } + + public async Task WaitIfNeeded(CancellationToken ct = default) + { + await _semaphore.WaitAsync(ct).ConfigureAwait(false); + try + { + var now = DateTime.UtcNow; + if (now - _windowStart >= _window) + { + _requestsInWindow = 0; + _windowStart = now; + } + + if (_requestsInWindow >= _maxRequests) + { + var delay = _windowStart + _window - now; + if (delay > TimeSpan.Zero) + { + await Task.Delay(delay, ct).ConfigureAwait(false); + } + _requestsInWindow = 0; + _windowStart = DateTime.UtcNow; + } + + Interlocked.Increment(ref _requestsInWindow); + } + finally + { + _semaphore.Release(); + } + } +} diff --git a/PCL.Core/Minecraft/ResourceProject/Comp/Infrastructure/RetryHandler.cs b/PCL.Core/Minecraft/ResourceProject/Comp/Infrastructure/RetryHandler.cs new file mode 100644 index 0000000000..ea2689e5a5 --- /dev/null +++ b/PCL.Core/Minecraft/ResourceProject/Comp/Infrastructure/RetryHandler.cs @@ -0,0 +1,43 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +namespace PCL.Core.Minecraft.ResourceProject.Comp.Infrastructure; + +public static class RetryHandler +{ + public static async Task ExecuteWithRetryAsync( + Func> sendAsync, + int maxRetries = 3, + CancellationToken ct = default) + { + for (var attempt = 0; attempt <= maxRetries; attempt++) + { + ct.ThrowIfCancellationRequested(); + + var response = await sendAsync().ConfigureAwait(false); + + if (response.IsSuccessStatusCode) + return response; + + if (attempt < maxRetries && _ShouldRetry(response.StatusCode)) + { + var delay = TimeSpan.FromMilliseconds(Math.Pow(2, attempt) * 1000); + await Task.Delay(delay, ct).ConfigureAwait(false); + continue; + } + + return response; + } + + throw new InvalidOperationException("Retry logic reached unreachable state."); + } + + private static bool _ShouldRetry(HttpStatusCode statusCode) + { + return statusCode is HttpStatusCode.TooManyRequests or HttpStatusCode.RequestTimeout + or HttpStatusCode.ServiceUnavailable or HttpStatusCode.GatewayTimeout; + } +} diff --git a/PCL.Core/Minecraft/ResourceProject/Comp/Models/CompCategory.cs b/PCL.Core/Minecraft/ResourceProject/Comp/Models/CompCategory.cs new file mode 100644 index 0000000000..9b3d152eff --- /dev/null +++ b/PCL.Core/Minecraft/ResourceProject/Comp/Models/CompCategory.cs @@ -0,0 +1,12 @@ +using System; + +namespace PCL.Core.Minecraft.ResourceProject.Comp.Models; + +[Serializable] +public sealed record class CompCategory( + string Id, + string Name, + string Slug, + Uri? IconUrl, + string? ParentId, + string? ClassId); diff --git a/PCL.Core/Minecraft/ResourceProject/Comp/Models/CompFile.cs b/PCL.Core/Minecraft/ResourceProject/Comp/Models/CompFile.cs new file mode 100644 index 0000000000..747e3b0e6d --- /dev/null +++ b/PCL.Core/Minecraft/ResourceProject/Comp/Models/CompFile.cs @@ -0,0 +1,29 @@ +using System; +using System.Collections.Generic; +using PCL.Core.Minecraft.ResourceProject.Comp.Models.Enums; + +namespace PCL.Core.Minecraft.ResourceProject.Comp.Models; + +[Serializable] +public sealed record class CompFile( + string Id, + string ProjectId, + string DisplayName, + string FileName, + Uri? DownloadUrl, + long FileLength, + string ReleaseType, + List GameVersions, + List Loaders, + Dictionary Hashes, + List Dependencies, + string? Changelog, + DateTime DatePublished, + int DownloadCount, + bool IsAvailable); + +[Serializable] +public sealed record class CompFileDependency( + string FileId, + string ProjectId, + string RelationType); diff --git a/PCL.Core/Minecraft/ResourceProject/Comp/Models/CompGameVersion.cs b/PCL.Core/Minecraft/ResourceProject/Comp/Models/CompGameVersion.cs new file mode 100644 index 0000000000..e828fe8001 --- /dev/null +++ b/PCL.Core/Minecraft/ResourceProject/Comp/Models/CompGameVersion.cs @@ -0,0 +1,10 @@ +using System; + +namespace PCL.Core.Minecraft.ResourceProject.Comp.Models; + +[Serializable] +public sealed record class CompGameVersion( + string Id, + string Version, + string? VersionType, + DateTime? DateModified); diff --git a/PCL.Core/Minecraft/ResourceProject/Comp/Models/CompLoader.cs b/PCL.Core/Minecraft/ResourceProject/Comp/Models/CompLoader.cs new file mode 100644 index 0000000000..f629e94998 --- /dev/null +++ b/PCL.Core/Minecraft/ResourceProject/Comp/Models/CompLoader.cs @@ -0,0 +1,11 @@ +using System; +using PCL.Core.Minecraft.ResourceProject.Comp.Models.Enums; + +namespace PCL.Core.Minecraft.ResourceProject.Comp.Models; + +[Serializable] +public sealed record class CompLoader( + string Name, + string? DisplayName, + ModLoaderType LoaderType, + bool IsLatest); diff --git a/PCL.Core/Minecraft/ResourceProject/Comp/Models/CompProject.cs b/PCL.Core/Minecraft/ResourceProject/Comp/Models/CompProject.cs new file mode 100644 index 0000000000..7f8e740612 --- /dev/null +++ b/PCL.Core/Minecraft/ResourceProject/Comp/Models/CompProject.cs @@ -0,0 +1,34 @@ +using System; +using System.Collections.Generic; +using PCL.Core.Minecraft.ResourceProject.Comp.Models.Enums; + +namespace PCL.Core.Minecraft.ResourceProject.Comp.Models; + +[Serializable] +public sealed record class CompProject( + string Id, + string Provider, + string Slug, + string Name, + string Summary, + string? DescriptionHtml, + Uri? IconUrl, + List Categories, + List GameVersions, + int DownloadCount, + int? FollowCount, + CompProjectType ProjectType, + string? License, + DateTime DateCreated, + DateTime DateModified, + string? Author, + Uri? SiteUrl, + Uri? IssuesUrl, + Uri? SourceUrl, + CompProjectStatus Status) +{ + public bool IsMod => ProjectType is CompProjectType.Mod or CompProjectType.Unknown; + public bool IsModpack => ProjectType == CompProjectType.Modpack; + public bool IsResourcePack => ProjectType == CompProjectType.ResourcePack; + public bool IsShader => ProjectType == CompProjectType.Shader; +} diff --git a/PCL.Core/Minecraft/ResourceProject/Comp/Models/CompSearchFilter.cs b/PCL.Core/Minecraft/ResourceProject/Comp/Models/CompSearchFilter.cs new file mode 100644 index 0000000000..37322e7982 --- /dev/null +++ b/PCL.Core/Minecraft/ResourceProject/Comp/Models/CompSearchFilter.cs @@ -0,0 +1,17 @@ +using System.Collections.Generic; +using PCL.Core.Minecraft.ResourceProject.Comp.Models.Enums; + +namespace PCL.Core.Minecraft.ResourceProject.Comp.Models; + +public sealed record class CompSearchFilter +{ + public string? Query { get; init; } + public string? GameVersion { get; init; } + public List Loaders { get; init; } = []; + public string? Category { get; init; } + public CompProjectType? ProjectType { get; init; } + public CompSortField SortField { get; init; } = CompSortField.Relevance; + public SortOrder SortOrder { get; init; } = SortOrder.Desc; + public int Offset { get; init; } + public int Limit { get; init; } = 20; +} diff --git a/PCL.Core/Minecraft/ResourceProject/Comp/Models/CompSearchResult.cs b/PCL.Core/Minecraft/ResourceProject/Comp/Models/CompSearchResult.cs new file mode 100644 index 0000000000..f08e735e1a --- /dev/null +++ b/PCL.Core/Minecraft/ResourceProject/Comp/Models/CompSearchResult.cs @@ -0,0 +1,9 @@ +using System.Collections.Generic; + +namespace PCL.Core.Minecraft.ResourceProject.Comp.Models; + +public sealed record class CompSearchResult( + List Hits, + int TotalCount, + int Offset, + int Limit); diff --git a/PCL.Core/Minecraft/ResourceProject/Comp/Models/Enums/CompProjectStatus.cs b/PCL.Core/Minecraft/ResourceProject/Comp/Models/Enums/CompProjectStatus.cs new file mode 100644 index 0000000000..f65b8b2d8d --- /dev/null +++ b/PCL.Core/Minecraft/ResourceProject/Comp/Models/Enums/CompProjectStatus.cs @@ -0,0 +1,17 @@ +using System; + +namespace PCL.Core.Minecraft.ResourceProject.Comp.Models.Enums; + +[Serializable] +public enum CompProjectStatus +{ + Approved, + Draft, + Rejected, + Archived, + Unlisted, + Processing, + Withheld, + Scheduled, + Unknown +} diff --git a/PCL.Core/Minecraft/ResourceProject/Comp/Models/Enums/CompProjectType.cs b/PCL.Core/Minecraft/ResourceProject/Comp/Models/Enums/CompProjectType.cs new file mode 100644 index 0000000000..a603013d91 --- /dev/null +++ b/PCL.Core/Minecraft/ResourceProject/Comp/Models/Enums/CompProjectType.cs @@ -0,0 +1,14 @@ +using System; + +namespace PCL.Core.Minecraft.ResourceProject.Comp.Models.Enums; + +[Serializable] +public enum CompProjectType +{ + Mod, + Modpack, + ResourcePack, + Shader, + DataPack, + Unknown +} diff --git a/PCL.Core/Minecraft/ResourceProject/Comp/Models/Enums/CompSortField.cs b/PCL.Core/Minecraft/ResourceProject/Comp/Models/Enums/CompSortField.cs new file mode 100644 index 0000000000..b7f4c3e032 --- /dev/null +++ b/PCL.Core/Minecraft/ResourceProject/Comp/Models/Enums/CompSortField.cs @@ -0,0 +1,13 @@ +using System; + +namespace PCL.Core.Minecraft.ResourceProject.Comp.Models.Enums; + +[Serializable] +public enum CompSortField +{ + Relevance, + Downloads, + Follows, + Updated, + Created +} diff --git a/PCL.Core/Minecraft/ResourceProject/Comp/Models/Enums/HashAlgorithm.cs b/PCL.Core/Minecraft/ResourceProject/Comp/Models/Enums/HashAlgorithm.cs new file mode 100644 index 0000000000..47ed93acf9 --- /dev/null +++ b/PCL.Core/Minecraft/ResourceProject/Comp/Models/Enums/HashAlgorithm.cs @@ -0,0 +1,11 @@ +using System; + +namespace PCL.Core.Minecraft.ResourceProject.Comp.Models.Enums; + +[Serializable] +public enum HashAlgorithm +{ + Sha1, + Md5, + Sha512 +} diff --git a/PCL.Core/Minecraft/ResourceProject/Comp/Models/Enums/ModLoaderType.cs b/PCL.Core/Minecraft/ResourceProject/Comp/Models/Enums/ModLoaderType.cs new file mode 100644 index 0000000000..aab7e91f20 --- /dev/null +++ b/PCL.Core/Minecraft/ResourceProject/Comp/Models/Enums/ModLoaderType.cs @@ -0,0 +1,17 @@ +using System; + +namespace PCL.Core.Minecraft.ResourceProject.Comp.Models.Enums; + +[Serializable] +public enum ModLoaderType +{ + Any, + Forge, + Cauldron, + LiteLoader, + Fabric, + Quilt, + NeoForge, + Rift, + Data +} diff --git a/PCL.Core/Minecraft/ResourceProject/Comp/Models/Enums/SortOrder.cs b/PCL.Core/Minecraft/ResourceProject/Comp/Models/Enums/SortOrder.cs new file mode 100644 index 0000000000..6d89183351 --- /dev/null +++ b/PCL.Core/Minecraft/ResourceProject/Comp/Models/Enums/SortOrder.cs @@ -0,0 +1,10 @@ +using System; + +namespace PCL.Core.Minecraft.ResourceProject.Comp.Models.Enums; + +[Serializable] +public enum SortOrder +{ + Asc, + Desc +} diff --git a/PCL.Core/Minecraft/ResourceProject/Curseforge/CurseforgeAuthors.cs b/PCL.Core/Minecraft/ResourceProject/Curseforge/CurseforgeAuthors.cs index ccbcd41358..6c8a9b0f8c 100644 --- a/PCL.Core/Minecraft/ResourceProject/Curseforge/CurseforgeAuthors.cs +++ b/PCL.Core/Minecraft/ResourceProject/Curseforge/CurseforgeAuthors.cs @@ -1,9 +1,10 @@ using System; +using System.Text.Json.Serialization; namespace PCL.Core.Minecraft.ResourceProject.Curseforge; [Serializable] public record CurseforgeAuthors( - int id, - string name, - string url); \ No newline at end of file + [property: JsonPropertyName("id")] int Id, + [property: JsonPropertyName("name")] string Name, + [property: JsonPropertyName("url")] string Url); diff --git a/PCL.Core/Minecraft/ResourceProject/Curseforge/CurseforgeCategories.cs b/PCL.Core/Minecraft/ResourceProject/Curseforge/CurseforgeCategories.cs index 7c2d9471a9..a4bae674b7 100644 --- a/PCL.Core/Minecraft/ResourceProject/Curseforge/CurseforgeCategories.cs +++ b/PCL.Core/Minecraft/ResourceProject/Curseforge/CurseforgeCategories.cs @@ -1,17 +1,18 @@ using System; +using System.Text.Json.Serialization; namespace PCL.Core.Minecraft.ResourceProject.Curseforge; [Serializable] public record CurseforgeCategories( - int id, - int gameId, - string name, - string slug, - string url, - string iconUrl, - string dateModified, - bool isClass, - int classId, - int parentCategoryId, - int displayIndex); \ No newline at end of file + [property: JsonPropertyName("id")] int Id, + [property: JsonPropertyName("gameId")] int GameId, + [property: JsonPropertyName("name")] string Name, + [property: JsonPropertyName("slug")] string Slug, + [property: JsonPropertyName("url")] string Url, + [property: JsonPropertyName("iconUrl")] string IconUrl, + [property: JsonPropertyName("dateModified")] string DateModified, + [property: JsonPropertyName("isClass")] bool IsClass, + [property: JsonPropertyName("classId")] int ClassId, + [property: JsonPropertyName("parentCategoryId")] int ParentCategoryId, + [property: JsonPropertyName("displayIndex")] int DisplayIndex); diff --git a/PCL.Core/Minecraft/ResourceProject/Curseforge/CurseforgeFile.cs b/PCL.Core/Minecraft/ResourceProject/Curseforge/CurseforgeFile.cs index 3ac54f35df..7e08d497fc 100644 --- a/PCL.Core/Minecraft/ResourceProject/Curseforge/CurseforgeFile.cs +++ b/PCL.Core/Minecraft/ResourceProject/Curseforge/CurseforgeFile.cs @@ -1,15 +1,23 @@ using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; namespace PCL.Core.Minecraft.ResourceProject.Curseforge; [Serializable] public record CurseforgeFile( - int id, - int gameId, - int modId, - bool isAvailable, - string displayName, - string fileName, - int releaseType, - int fileStatus, - CurseforgeHashes hashes); \ No newline at end of file + [property: JsonPropertyName("id")] int Id, + [property: JsonPropertyName("gameId")] int GameId, + [property: JsonPropertyName("modId")] int ModId, + [property: JsonPropertyName("isAvailable")] bool IsAvailable, + [property: JsonPropertyName("displayName")] string DisplayName, + [property: JsonPropertyName("fileName")] string FileName, + [property: JsonPropertyName("releaseType")] int ReleaseType, + [property: JsonPropertyName("fileStatus")] int FileStatus, + [property: JsonPropertyName("hashes")] List Hashes, + [property: JsonPropertyName("fileDate")] string? FileDate, + [property: JsonPropertyName("fileLength")] long FileLength, + [property: JsonPropertyName("downloadCount")] long DownloadCount, + [property: JsonPropertyName("downloadUrl")] string? DownloadUrl, + [property: JsonPropertyName("gameVersions")] List? GameVersions, + [property: JsonPropertyName("dependencies")] List? Dependencies); diff --git a/PCL.Core/Minecraft/ResourceProject/Curseforge/CurseforgeFileDependency.cs b/PCL.Core/Minecraft/ResourceProject/Curseforge/CurseforgeFileDependency.cs new file mode 100644 index 0000000000..5be4f485a8 --- /dev/null +++ b/PCL.Core/Minecraft/ResourceProject/Curseforge/CurseforgeFileDependency.cs @@ -0,0 +1,9 @@ +using System; +using System.Text.Json.Serialization; + +namespace PCL.Core.Minecraft.ResourceProject.Curseforge; + +[Serializable] +public record CurseforgeFileDependency( + [property: JsonPropertyName("modId")] int ModId, + [property: JsonPropertyName("relationType")] int RelationType); diff --git a/PCL.Core/Minecraft/ResourceProject/Curseforge/CurseforgeHashes.cs b/PCL.Core/Minecraft/ResourceProject/Curseforge/CurseforgeHashes.cs index 5260066176..402007199b 100644 --- a/PCL.Core/Minecraft/ResourceProject/Curseforge/CurseforgeHashes.cs +++ b/PCL.Core/Minecraft/ResourceProject/Curseforge/CurseforgeHashes.cs @@ -1,8 +1,9 @@ using System; +using System.Text.Json.Serialization; namespace PCL.Core.Minecraft.ResourceProject.Curseforge; [Serializable] public record CurseforgeHashes( - string value, - int algo); \ No newline at end of file + [property: JsonPropertyName("value")] string Value, + [property: JsonPropertyName("algo")] int Algo); diff --git a/PCL.Core/Minecraft/ResourceProject/Curseforge/CurseforgeLinks.cs b/PCL.Core/Minecraft/ResourceProject/Curseforge/CurseforgeLinks.cs index f4d29383d5..f5b5a138ed 100644 --- a/PCL.Core/Minecraft/ResourceProject/Curseforge/CurseforgeLinks.cs +++ b/PCL.Core/Minecraft/ResourceProject/Curseforge/CurseforgeLinks.cs @@ -1,10 +1,11 @@ using System; +using System.Text.Json.Serialization; namespace PCL.Core.Minecraft.ResourceProject.Curseforge; [Serializable] public record CurseforgeLinks( - string websiteUrl, - string wikiUrl, - string issuesUrl, - string sourceUrl); \ No newline at end of file + [property: JsonPropertyName("websiteUrl")] string WebsiteUrl, + [property: JsonPropertyName("wikiUrl")] string WikiUrl, + [property: JsonPropertyName("issuesUrl")] string IssuesUrl, + [property: JsonPropertyName("sourceUrl")] string SourceUrl); diff --git a/PCL.Core/Minecraft/ResourceProject/Curseforge/CurseforgePictures.cs b/PCL.Core/Minecraft/ResourceProject/Curseforge/CurseforgePictures.cs index 4694e37856..5c0973d8d8 100644 --- a/PCL.Core/Minecraft/ResourceProject/Curseforge/CurseforgePictures.cs +++ b/PCL.Core/Minecraft/ResourceProject/Curseforge/CurseforgePictures.cs @@ -1,12 +1,13 @@ using System; +using System.Text.Json.Serialization; namespace PCL.Core.Minecraft.ResourceProject.Curseforge; [Serializable] public record CurseforgePictures( - int id, - int modId, - string title, - string description, - string thumbnailUrl, - string url); \ No newline at end of file + [property: JsonPropertyName("id")] int Id, + [property: JsonPropertyName("modId")] int ModId, + [property: JsonPropertyName("title")] string Title, + [property: JsonPropertyName("description")] string Description, + [property: JsonPropertyName("thumbnailUrl")] string ThumbnailUrl, + [property: JsonPropertyName("url")] string Url); diff --git a/PCL.Core/Minecraft/ResourceProject/Curseforge/CurseforgeProject.cs b/PCL.Core/Minecraft/ResourceProject/Curseforge/CurseforgeProject.cs index fd2a46ea7d..bc5f3fdb62 100644 --- a/PCL.Core/Minecraft/ResourceProject/Curseforge/CurseforgeProject.cs +++ b/PCL.Core/Minecraft/ResourceProject/Curseforge/CurseforgeProject.cs @@ -1,24 +1,25 @@ using System; using System.Collections.Generic; +using System.Text.Json.Serialization; namespace PCL.Core.Minecraft.ResourceProject.Curseforge; [Serializable] public record class CurseforgeProject( - int id, - int gameId, - string name, - string slug, - CurseforgeLinks links, - string summary, - int status, - int downloadCount, - bool isFeatured, - int primaryCategoryId, - List categories, - int classId, - List authors, - CurseforgePictures logo, - List screenshots, - int mainFileId, - object latestFiles); \ No newline at end of file + [property: JsonPropertyName("id")] int Id, + [property: JsonPropertyName("gameId")] int GameId, + [property: JsonPropertyName("name")] string Name, + [property: JsonPropertyName("slug")] string Slug, + [property: JsonPropertyName("links")] CurseforgeLinks Links, + [property: JsonPropertyName("summary")] string Summary, + [property: JsonPropertyName("status")] int Status, + [property: JsonPropertyName("downloadCount")] int DownloadCount, + [property: JsonPropertyName("isFeatured")] bool IsFeatured, + [property: JsonPropertyName("primaryCategoryId")] int PrimaryCategoryId, + [property: JsonPropertyName("categories")] List Categories, + [property: JsonPropertyName("classId")] int ClassId, + [property: JsonPropertyName("authors")] List Authors, + [property: JsonPropertyName("logo")] CurseforgePictures Logo, + [property: JsonPropertyName("screenshots")] List Screenshots, + [property: JsonPropertyName("mainFileId")] int MainFileId, + [property: JsonPropertyName("latestFiles")] object LatestFiles); diff --git a/PCL.Core/Minecraft/ResourceProject/Curseforge/CurseforgeResponseData.cs b/PCL.Core/Minecraft/ResourceProject/Curseforge/CurseforgeResponseData.cs index 143ada94f9..601c7dae52 100644 --- a/PCL.Core/Minecraft/ResourceProject/Curseforge/CurseforgeResponseData.cs +++ b/PCL.Core/Minecraft/ResourceProject/Curseforge/CurseforgeResponseData.cs @@ -1,9 +1,13 @@ using System; using System.Collections.Generic; +using System.Text.Json.Serialization; namespace PCL.Core.Minecraft.ResourceProject.Curseforge; [Serializable] -public record class CurseforgeProjectResponse(CurseforgeProject data); +public record class CurseforgeProjectResponse( + [property: JsonPropertyName("data")] CurseforgeProject Data); + [Serializable] -public record class CurseforgeProjectsResponse(List data); +public record class CurseforgeProjectsResponse( + [property: JsonPropertyName("data")] List Data); diff --git a/PCL.Core/Minecraft/ResourceProject/Curseforge/CurseforgeScreenshots.cs b/PCL.Core/Minecraft/ResourceProject/Curseforge/CurseforgeScreenshots.cs index c4aa6b3be4..64b6232748 100644 --- a/PCL.Core/Minecraft/ResourceProject/Curseforge/CurseforgeScreenshots.cs +++ b/PCL.Core/Minecraft/ResourceProject/Curseforge/CurseforgeScreenshots.cs @@ -1,11 +1,12 @@ using System; +using System.Text.Json.Serialization; -namespace PCL.Core.Model.ResourceProject.Curseforge; +namespace PCL.Core.Minecraft.ResourceProject.Curseforge; [Serializable] public record CurseforgeScreenshots( - int id, - int modId, - string title, - string description, - string thumbnailUrl); \ No newline at end of file + [property: JsonPropertyName("id")] int Id, + [property: JsonPropertyName("modId")] int ModId, + [property: JsonPropertyName("title")] string Title, + [property: JsonPropertyName("description")] string Description, + [property: JsonPropertyName("thumbnailUrl")] string ThumbnailUrl); diff --git a/PCL.Core/Minecraft/ResourceProject/Curseforge/CurseforgeSortableGameVersion.cs b/PCL.Core/Minecraft/ResourceProject/Curseforge/CurseforgeSortableGameVersion.cs new file mode 100644 index 0000000000..126be78181 --- /dev/null +++ b/PCL.Core/Minecraft/ResourceProject/Curseforge/CurseforgeSortableGameVersion.cs @@ -0,0 +1,13 @@ +using System; +using System.Text.Json.Serialization; + +namespace PCL.Core.Minecraft.ResourceProject.Curseforge; + +[Serializable] +public record CurseforgeSortableGameVersion( + [property: JsonPropertyName("gameVersionName")] string? GameVersionName, + [property: JsonPropertyName("gameVersionPadded")] string? GameVersionPadded, + [property: JsonPropertyName("gameVersion")] string? GameVersion, + [property: JsonPropertyName("gameVersionReleaseDate")] string? GameVersionReleaseDate, + [property: JsonPropertyName("gameVersionTypeId")] int? GameVersionTypeId, + [property: JsonPropertyName("modLoader")] int? ModLoader); diff --git a/PCL.Core/Minecraft/ResourceProject/ModDependencyResolver.cs b/PCL.Core/Minecraft/ResourceProject/ModDependencyResolver.cs index b6f84795a2..daa0ca1eff 100644 --- a/PCL.Core/Minecraft/ResourceProject/ModDependencyResolver.cs +++ b/PCL.Core/Minecraft/ResourceProject/ModDependencyResolver.cs @@ -81,7 +81,7 @@ public sealed record class IgnoredDependency public sealed class ModDependencyResolver { private const int MaxDepth = 32; - private static readonly StringComparer Comparer = StringComparer.OrdinalIgnoreCase; + private static readonly StringComparer _Comparer = StringComparer.OrdinalIgnoreCase; public ModDependencyResolutionResult Resolve(ModDependencyRequest request) { @@ -91,13 +91,13 @@ public ModDependencyResolutionResult Resolve(ModDependencyRequest request) var context = new ResolutionContext(request); foreach (var dependency in request.RequiredDependencies) { - ResolveDependency(context, dependency, 0); + _ResolveDependency(context, dependency, 0); } return context.Result; } - private static void ResolveDependency(ResolutionContext context, ModDependencyReference dependency, int depth) + private static void _ResolveDependency(ResolutionContext context, ModDependencyReference dependency, int depth) { if (string.IsNullOrWhiteSpace(dependency.ProjectId) || string.IsNullOrWhiteSpace(dependency.Source)) { @@ -135,7 +135,7 @@ private static void ResolveDependency(ResolutionContext context, ModDependencyRe return; } - var selectedFile = SelectBestFile(project.Files, context.TargetMinecraftVersion, context.TargetLoaders); + var selectedFile = _SelectBestFile(project.Files, context.TargetMinecraftVersion, context.TargetLoaders); if (selectedFile is null) { context.AddUnresolved(project.ProjectId, project.Source, "No compatible file was found."); @@ -146,27 +146,27 @@ private static void ResolveDependency(ResolutionContext context, ModDependencyRe foreach (var nestedDependency in selectedFile.RequiredDependencies) { - ResolveDependency(context, nestedDependency, depth + 1); + _ResolveDependency(context, nestedDependency, depth + 1); } } - private static ModDependencyFile? SelectBestFile( + private static ModDependencyFile? _SelectBestFile( IEnumerable files, string targetMinecraftVersion, HashSet targetLoaders) { return files - .Where(file => IsCompatibleFile(file, targetMinecraftVersion, targetLoaders)) - .OrderByDescending(file => HasExactGameVersionMatch(file, targetMinecraftVersion)) - .ThenByDescending(file => HasLoaderMatch(file, targetLoaders)) - .ThenBy(file => NormalizeReleaseType(file.ReleaseType)) + .Where(file => _IsCompatibleFile(file, targetMinecraftVersion, targetLoaders)) + .OrderByDescending(file => _HasExactGameVersionMatch(file, targetMinecraftVersion)) + .ThenByDescending(file => _HasLoaderMatch(file, targetLoaders)) + .ThenBy(file => _NormalizeReleaseType(file.ReleaseType)) .ThenByDescending(file => file.ReleaseDate) .FirstOrDefault(); } - private static bool IsCompatibleFile(ModDependencyFile file, string targetMinecraftVersion, HashSet targetLoaders) + private static bool _IsCompatibleFile(ModDependencyFile file, string targetMinecraftVersion, HashSet targetLoaders) { - if (!HasExactGameVersionMatch(file, targetMinecraftVersion)) + if (!_HasExactGameVersionMatch(file, targetMinecraftVersion)) { return false; } @@ -184,12 +184,12 @@ private static bool IsCompatibleFile(ModDependencyFile file, string targetMinecr return file.Loaders.Any(loader => targetLoaders.Contains(loader)); } - private static bool HasExactGameVersionMatch(ModDependencyFile file, string targetMinecraftVersion) + private static bool _HasExactGameVersionMatch(ModDependencyFile file, string targetMinecraftVersion) { - return file.GameVersions.Any(version => Comparer.Equals(version, targetMinecraftVersion)); + return file.GameVersions.Any(version => _Comparer.Equals(version, targetMinecraftVersion)); } - private static bool HasLoaderMatch(ModDependencyFile file, HashSet targetLoaders) + private static bool _HasLoaderMatch(ModDependencyFile file, HashSet targetLoaders) { if (targetLoaders.Count == 0) { @@ -199,7 +199,7 @@ private static bool HasLoaderMatch(ModDependencyFile file, HashSet targe return file.Loaders.Any(loader => targetLoaders.Contains(loader)); } - private static int NormalizeReleaseType(int releaseType) + private static int _NormalizeReleaseType(int releaseType) { return releaseType switch { @@ -212,20 +212,20 @@ private static int NormalizeReleaseType(int releaseType) private sealed class ResolutionContext { - private readonly HashSet _installDedupe = new(Comparer); - private readonly HashSet _unresolvedDedupe = new(Comparer); - private readonly HashSet _satisfiedDedupe = new(Comparer); + private readonly HashSet _installDedupe = new(_Comparer); + private readonly HashSet _unresolvedDedupe = new(_Comparer); + private readonly HashSet _satisfiedDedupe = new(_Comparer); public ResolutionContext(ModDependencyRequest request) { Request = request; Result = new ModDependencyResolutionResult(); - Visited = new HashSet(Comparer); + Visited = new HashSet(_Comparer); TargetMinecraftVersion = request.TargetMinecraftVersion ?? string.Empty; TargetLoaders = new HashSet( request.TargetLoaders.Where(static loader => !string.IsNullOrWhiteSpace(loader)), - Comparer); - LoaderSetKey = string.Join(",", TargetLoaders.OrderBy(static loader => loader, Comparer)); + _Comparer); + _LoaderSetKey = string.Join(",", TargetLoaders.OrderBy(static loader => loader, _Comparer)); } public ModDependencyRequest Request { get; } @@ -233,25 +233,25 @@ public ResolutionContext(ModDependencyRequest request) public HashSet Visited { get; } public string TargetMinecraftVersion { get; } public HashSet TargetLoaders { get; } - private string LoaderSetKey { get; } + private string _LoaderSetKey { get; } public string GetVisitedKey(string projectId, string source) { - return $"{source}:{projectId}:{TargetMinecraftVersion}:{LoaderSetKey}"; + return $"{source}:{projectId}:{TargetMinecraftVersion}:{_LoaderSetKey}"; } public bool IsInstalledCompatible(string projectId, string source) { return Request.InstalledMods.Any(installed => - Comparer.Equals(installed.SourceProjectId, projectId) - && Comparer.Equals(installed.Source, source) - && installed.GameVersions.Any(version => Comparer.Equals(version, TargetMinecraftVersion)) - && LoadersCompatible(installed.Loaders)); + _Comparer.Equals(installed.SourceProjectId, projectId) + && _Comparer.Equals(installed.Source, source) + && installed.GameVersions.Any(version => _Comparer.Equals(version, TargetMinecraftVersion)) + && _LoadersCompatible(installed.Loaders)); } public void AddInstall(ModDependencyProject project, ModDependencyFile file) { - var dedupeKey = GetProjectKey(project.ProjectId, project.Source); + var dedupeKey = _GetProjectKey(project.ProjectId, project.Source); if (!_installDedupe.Add(dedupeKey)) { return; @@ -268,7 +268,7 @@ public void AddInstall(ModDependencyProject project, ModDependencyFile file) public void AddUnresolved(string projectId, string source, string reason) { - var dedupeKey = GetProjectKey(projectId, source); + var dedupeKey = _GetProjectKey(projectId, source); if (!_unresolvedDedupe.Add(dedupeKey)) { return; @@ -284,7 +284,7 @@ public void AddUnresolved(string projectId, string source, string reason) public void AddSatisfied(string projectId, string source, string reason) { - var dedupeKey = GetProjectKey(projectId, source); + var dedupeKey = _GetProjectKey(projectId, source); if (!_satisfiedDedupe.Add(dedupeKey)) { return; @@ -298,7 +298,7 @@ public void AddSatisfied(string projectId, string source, string reason) }); } - private bool LoadersCompatible(List installedLoaders) + private bool _LoadersCompatible(List installedLoaders) { if (TargetLoaders.Count == 0 || installedLoaders.Count == 0) { @@ -308,7 +308,7 @@ private bool LoadersCompatible(List installedLoaders) return installedLoaders.Any(loader => TargetLoaders.Contains(loader)); } - private static string GetProjectKey(string projectId, string source) + private static string _GetProjectKey(string projectId, string source) { return $"{source}:{projectId}"; } diff --git a/PCL.Core/Minecraft/ResourceProject/Modrinth/ModrinthDonationUrl.cs b/PCL.Core/Minecraft/ResourceProject/Modrinth/ModrinthDonationUrl.cs index 84e041c9cb..7495575149 100644 --- a/PCL.Core/Minecraft/ResourceProject/Modrinth/ModrinthDonationUrl.cs +++ b/PCL.Core/Minecraft/ResourceProject/Modrinth/ModrinthDonationUrl.cs @@ -1,9 +1,10 @@ using System; +using System.Text.Json.Serialization; namespace PCL.Core.Minecraft.ResourceProject.Modrinth; [Serializable] public record ModrinthDonationUrl( - string id, - string platform, - string url); \ No newline at end of file + [property: JsonPropertyName("id")] string Id, + [property: JsonPropertyName("platform")] string Platform, + [property: JsonPropertyName("url")] string Url); diff --git a/PCL.Core/Minecraft/ResourceProject/Modrinth/ModrinthGallery.cs b/PCL.Core/Minecraft/ResourceProject/Modrinth/ModrinthGallery.cs index 8b5fb51b2e..2d53ce79ff 100644 --- a/PCL.Core/Minecraft/ResourceProject/Modrinth/ModrinthGallery.cs +++ b/PCL.Core/Minecraft/ResourceProject/Modrinth/ModrinthGallery.cs @@ -1,12 +1,13 @@ using System; +using System.Text.Json.Serialization; namespace PCL.Core.Minecraft.ResourceProject.Modrinth; [Serializable] public record ModrinthGallery( - string url, - bool featured, - string? title, - string? description, - string created, - int ordering); \ No newline at end of file + [property: JsonPropertyName("url")] string Url, + [property: JsonPropertyName("featured")] bool Featured, + [property: JsonPropertyName("title")] string? Title, + [property: JsonPropertyName("description")] string? Description, + [property: JsonPropertyName("created")] string Created, + [property: JsonPropertyName("ordering")] int Ordering); diff --git a/PCL.Core/Minecraft/ResourceProject/Modrinth/ModrinthLicense.cs b/PCL.Core/Minecraft/ResourceProject/Modrinth/ModrinthLicense.cs index 5096634bce..07c567a25b 100644 --- a/PCL.Core/Minecraft/ResourceProject/Modrinth/ModrinthLicense.cs +++ b/PCL.Core/Minecraft/ResourceProject/Modrinth/ModrinthLicense.cs @@ -1,9 +1,10 @@ using System; +using System.Text.Json.Serialization; namespace PCL.Core.Minecraft.ResourceProject.Modrinth; [Serializable] public record ModrinthLicense( - string id, - string name, - string? url); \ No newline at end of file + [property: JsonPropertyName("id")] string Id, + [property: JsonPropertyName("name")] string Name, + [property: JsonPropertyName("url")] string? Url); diff --git a/PCL.Core/Minecraft/ResourceProject/Modrinth/ModrinthModeratorMessage.cs b/PCL.Core/Minecraft/ResourceProject/Modrinth/ModrinthModeratorMessage.cs index edeecbead3..4bb1c725f9 100644 --- a/PCL.Core/Minecraft/ResourceProject/Modrinth/ModrinthModeratorMessage.cs +++ b/PCL.Core/Minecraft/ResourceProject/Modrinth/ModrinthModeratorMessage.cs @@ -1,8 +1,9 @@ using System; +using System.Text.Json.Serialization; namespace PCL.Core.Minecraft.ResourceProject.Modrinth; [Serializable] public record ModrinthModeratorMessage( - string message, - string? body); \ No newline at end of file + [property: JsonPropertyName("message")] string Message, + [property: JsonPropertyName("body")] string? Body); diff --git a/PCL.Core/Minecraft/ResourceProject/Modrinth/ModrinthProject.cs b/PCL.Core/Minecraft/ResourceProject/Modrinth/ModrinthProject.cs index 56c1c659dc..7174561816 100644 --- a/PCL.Core/Minecraft/ResourceProject/Modrinth/ModrinthProject.cs +++ b/PCL.Core/Minecraft/ResourceProject/Modrinth/ModrinthProject.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Text.Json.Serialization; namespace PCL.Core.Minecraft.ResourceProject.Modrinth; @@ -9,27 +10,27 @@ public record class ModrinthProject( string title, string description, List categories, - string client_side, - string server_side, + [property: JsonPropertyName("client_side")] string clientSide, + [property: JsonPropertyName("server_side")] string serverSide, string body, string status, - string? requested_status, - List additional_categories, - string? issues_url, - string? source_url, - string? wiki_url, - string? discord_url, - List donation_urls, - string project_type, + [property: JsonPropertyName("requested_status")] string? requestedStatus, + [property: JsonPropertyName("additional_categories")] List additionalCategories, + [property: JsonPropertyName("issues_url")] string? issuesUrl, + [property: JsonPropertyName("source_url")] string? sourceUrl, + [property: JsonPropertyName("wiki_url")] string? wikiUrl, + [property: JsonPropertyName("discord_url")] string? discordUrl, + [property: JsonPropertyName("donation_urls")] List donationUrls, + [property: JsonPropertyName("project_type")] string projectType, int downloads, - string icon_url, + [property: JsonPropertyName("icon_url")] string iconUrl, int color, - string thread_id, - string monetization_status, + [property: JsonPropertyName("thread_id")] string threadId, + [property: JsonPropertyName("monetization_status")] string monetizationStatus, string id, string team, - string body_url, - ModrinthModeratorMessage moderator_message, + [property: JsonPropertyName("body_url")] string bodyUrl, + [property: JsonPropertyName("moderator_message")] ModrinthModeratorMessage moderatorMessage, string published, string updated, string? approved, @@ -37,6 +38,6 @@ public record class ModrinthProject( int followers, ModrinthLicense license, List versions, - List game_versions, + [property: JsonPropertyName("game_versions")] List gameVersions, List loaders, List gallery); \ No newline at end of file diff --git a/comp-layer-plan.md b/comp-layer-plan.md new file mode 100644 index 0000000000..e167a9ed7d --- /dev/null +++ b/comp-layer-plan.md @@ -0,0 +1,318 @@ +# Comp 兼容层 — 统一 CurseForge & Modrinth API 规划 + +## 目标 + +在 C# 中设计一个统一的抽象层(Comp),屏蔽 CurseForge API(v1)和 Modrinth API(v2.7.0)的差异,为 Minecraft 第三方启动器提供一致的接口。 + +## 核心功能域 + +以下按启动器关注度从高到低排列: + +| 功能域 | CurseForge | Modrinth | 启动器场景 | +|--------|-----------|----------|-----------| +| 项目搜索 | `GET /v1/mods/search` | `GET /v2/search` | 模组/整合包浏览 | +| 项目详情 | `GET /v1/mods/{modId}` | `GET /v2/project/{id\|slug}` | 展示项目信息 | +| 项目批量获取 | `POST /v1/mods` | `GET /v2/projects?ids=...` | 列表/收藏同步 | +| 文件列表 | `GET /v1/mods/{modId}/files` | `GET /v2/project/{id\|slug}/version` | 选择版本下载 | +| 文件详情 | `GET /v1/mods/{modId}/files/{fileId}` | `GET /v2/version/{id}` | 获取文件元数据 | +| 文件下载 URL | `GET /v1/mods/{modId}/files/{fileId}/download-url` | 文件对象自带 URL | 直接下载 | +| 哈希匹配 | `POST /v1/fingerprints` | `POST /v2/version_files/from_hashes` | 本地文件识别 | +| 哈希更新检测 | — | `POST /v2/version_file/{hash}/update` | 检查更新 | +| 推荐/热门 | `POST /v1/mods/featured` | `GET /v2/search?index=downloads` | 首页推荐 | +| 游戏版本列表 | `GET /v1/minecraft/version` | `GET /v2/tag/game_version` | 版本筛选 | +| ModLoader 列表 | `GET /v1/minecraft/modloader` | `GET /v2/tag/loader` | 加载器筛选 | +| 分类/标签 | `GET /v1/categories` | `GET /v2/tag/category` | 分类浏览 | +| 项目描述 | `GET /v1/mods/{modId}/description` | 项目详情自带 `body` 字段 | 展示说明 | + +--- + +## 统一数据模型(建议) + +### `CompProject` + +| 字段 | CurseForge 来源 | Modrinth 来源 | 说明 | +|------|----------------|---------------|------| +| `Id` | `data.id` (int) | `id` (string base62) | 统一暴露为 string,内部保留原始类型 | +| `Provider` | `CurseForge` | `Modrinth` | 枚举标识来源 | +| `Slug` | `data.slug` | `slug` | | +| `Name` | `data.name` | `title` | | +| `Summary` | `data.summary` | `description` | 短描述 | +| `DescriptionHtml` | `GET description` 返回 | `body` (Markdown) | 长描述,注意格式差异 | +| `IconUrl` | `data.logo.url` | `icon_url` | | +| `Categories` | `data.categories[]` → 展开 | `categories[]` | | +| `GameVersions` | `data.latestFiles[].gameVersions` | `game_versions[]` | 最新版本支持的游戏版本 | +| `DownloadCount` | `data.downloadCount` | `downloads` | | +| `FollowCount` | — | `followers` | 无对应字段时返回 null | +| `ProjectType` | 固定 `mod` | `project_type` | mod/modpack/resourcepack/shader | +| `License` | `data.latestFiles[].displayName` 间接 | `license.id` | | +| `DateCreated` | `data.dateCreated` | `published` | | +| `DateModified` | `data.dateModified` | `updated` | | +| `Author` | `data.authors[0].name` | `author` (search) / team 解析 | 主作者 | +| `SiteUrl` | `data.links.websiteUrl` | — | | +| `IssuesUrl` | — | `issues_url` | | +| `SourceUrl` | `data.links.sourceUrl` | `source_url` | | +| `Status` | `data.status` 映射 | `status` | 见状态映射表 | + +### `CompFile` + +| 字段 | CurseForge 来源 | Modrinth 来源 | +|------|----------------|---------------| +| `Id` | `file.fileId` / `file.id` (int) | `id` (base62) | +| `ProjectId` | `modId` | `project_id` | +| `DisplayName` | `displayName` | `name` | +| `FileName` | `fileName` | `files[].filename` | +| `DownloadUrl` | `downloadUrl` | `files[].url` | +| `FileLength` | `fileLength` | `files[].size` | +| `ReleaseType` | `releaseType` (Release/Beta/Alpha) | `version_type` (release/beta/alpha) | +| `GameVersions` | `gameVersions[]` | `game_versions[]` | +| `Loaders` | `sortableGameVersions[].modLoader` → ModLoaderType | `loaders[]` | +| `Hashes` | `hashes[]` (Sha1/Md5) | `files[].hashes` (sha1/sha512) | +| `Dependencies` | `dependencies[]` | `dependencies[]` | +| `Changelog` | `GET /files/{fileId}/changelog` | `changelog` | +| `DatePublished` | `fileDate` | `date_published` | +| `DownloadCount` | `downloadCount` | `downloads` | +| `IsAvailable` | `isAvailable` | `status == "listed"` | + +### `CompSearchResult` + +```csharp +class CompSearchResult { + List Hits { get; set; } + int TotalCount { get; set; } + int Offset { get; set; } + int Limit { get; set; } +} +``` + +### `CompSearchFilter` + +```csharp +class CompSearchFilter { + string? Query; // 搜索关键词 + string? GameVersion; // 如 "1.20.1" + List Loaders; // Forge, Fabric, Quilt, NeoForge + string? Category; // 分类 slug 或 id + CompProjectType? ProjectType; // mod, modpack, resourcepack 等 + CompSortField SortField; // Relevance, Downloads, Follows, Updated, Created + SortOrder SortOrder; // Asc, Desc + int Offset; // 分页偏移 + int Limit; // 每页数量 +} +``` + +--- + +## 核心接口定义(建议) + +### `ICompClient` + +```csharp +interface ICompClient { + // ===== 项目 ===== + Task SearchProjects(CompSearchFilter filter, CancellationToken ct = default); + Task GetProject(string projectId, CancellationToken ct = default); + Task> GetProjects(IEnumerable projectIds, CancellationToken ct = default); + Task> GetFeaturedProjects(string? gameVersion = null, CancellationToken ct = default); + Task GetProjectDescription(string projectId, CancellationToken ct = default); + + // ===== 文件/版本 ===== + Task> GetProjectFiles(string projectId, CompFileFilter? filter = null, CancellationToken ct = default); + Task GetFile(string fileId, CancellationToken ct = default); + Task GetFileDownloadUrl(string fileId, CancellationToken ct = default); // 主要对 CF 需要 + Task GetFileChangelog(string fileId, CancellationToken ct = default); + + // ===== 哈希匹配 ===== + Task>> MatchFingerprints(IEnumerable hashes, HashAlgorithm algo, CancellationToken ct = default); + Task CheckForUpdates(string fileHash, HashAlgorithm algo, CompUpdateFilter? filter = null, CancellationToken ct = default); + + // ===== 元数据 ===== + Task> GetGameVersions(CancellationToken ct = default); + Task> GetLoaders(CancellationToken ct = default); + Task> GetCategories(CancellationToken ct = default); +} +``` + +### `ICompClientFactory` + +```csharp +interface ICompClientFactory { + ICompClient CreateCurseForgeClient(string apiKey); + ICompClient CreateModrinthClient(string? accessToken = null); + ICompClient CreateAggregateClient(params ICompClient[] clients); // 统一搜索多个源 +} +``` + +--- + +## 关键差异与处理策略 + +### 1. 认证方式 + +| | CurseForge | Modrinth | +|--|-----------|----------| +| 方式 | `x-api-key` header | `Authorization: mrp_...` header | +| 必要性 | 所有请求必须 | 读操作可选,写操作需认证 | +| Scope | 无 | OAuth scope 体系 | + +**策略**:`CompClient` 构造时接收凭证参数,自动注入对应的认证 header。 + +### 2. 项目 ID 体系 + +| | CurseForge | Modrinth | +|--|-----------|----------| +| 类型 | 自增 int32(如 `12345`) | 8 位 base62 字符串(如 `AABBCCDD`) | +| 长度范围 | 1 ~ ~20 亿 | 固定 8 字符 | +| 前缀区分 | 无 | 无官方前缀,但可以约定使用 `mr-` / `cf-` 前缀统一标识 | + +**策略**:统一暴露为 `string`。提供辅助方法 `ProjectId.TryParse()` 支持 `"cf-12345"` / `"mr-AABBCCDD"` / 纯数字自动判断。 + +### 3. 分页 + +| | CurseForge | Modrinth | +|--|-----------|----------| +| 方式 | `index` + `pageSize` | `offset` + `limit` | +| 上限 | `index + pageSize <= 10000` | 默认 10,最大 100 | +| 下一页 | 计算 `index + pageSize` | 计算 `offset + limit` | +| 总数 | `pagination.totalCount` | `total_hits`(搜索) | + +**策略**:统一使用 `Offset` / `Limit`。CurseForge 适配层做 `index → offset` 的直接映射。 + +### 4. 哈希算法 + +| | CurseForge | Modrinth | +|--|-----------|----------| +| 可用 | Sha1, Md5 | Sha1, Sha512 | +| 哈希匹配 | `POST /v1/fingerprints`(模糊 + 精确) | `POST /v2/version_files/from_hashes` | +| 更新检测 | 无专用端点 | `POST /v2/version_file/{hash}/update` | + +**策略**:`MatchFingerprints` 同时对接两个端点。CurseForge 借助指纹匹配 + 文件 ID 找最新文件模拟更新检测。 + +### 5. 状态枚举差异 + +**CompProjectStatus**(统一枚举): + +| Comp 值 | CurseForge (`CoreStatus`) | Modrinth 字符串 | +|---------|--------------------------|-----------------| +| `Approved` | 5=Approved, 6=Live | `approved` | +| `Draft` | 1=Draft | `draft` | +| `Rejected` | 4=Rejected | `rejected` | +| `Archived` | — | `archived` | +| `Unlisted` | — | `unlisted` | +| `Processing` | 2=Test, 3=PendingReview | `processing` | +| `Withheld` | — | `withheld` | +| `Scheduled` | — | `scheduled` | +| `Unknown` | 其他 | `unknown` | + +**ModLoaderType**(统一枚举): + +| Comp 值 | CurseForge 值 | Modrinth 字符串 | +|---------|--------------|-----------------| +| `Any` | 0=Any | — | +| `Forge` | 1=Forge | `forge` | +| `Cauldron` | 2=Cauldron | — | +| `LiteLoader` | 3=LiteLoader | — | +| `Fabric` | 4=Fabric | `fabric` | +| `Quilt` | 5=Quilt | `quilt` | +| `NeoForge` | 6=NeoForge | `neoforge` | +| `Rift` | — | `rift` | +| `Data` | — | `data` | + +### 6. 分类体系差异 + +**CurseForge**: +- 双层结构:Class(大类)→ Category(子类) +- 每个分类有 `id`、`gameId`、`slug`、`iconUrl` +- 通过 `GET /v1/categories?gameId=432`(Minecraft=432)获取 + +**Modrinth**: +- 扁平分类标签列表,与 Loader 分离 +- 每个分类有 `name`、`icon`(SVG)、`project_type` 归属 +- 通过 `GET /v2/tag/category` 获取 + +**策略**:统一模型 `CompCategory { Id, Name, Slug, IconUrl, ParentId }`。Modrinth 适配层将扁平分类映射为 ParentId=null 的根分类。 + +### 7. 下载机制差异 + +| | CurseForge | Modrinth | +|--|-----------|----------| +| 下载 URL 提供方式 | 文件对象含 `downloadUrl`,另有专用端点刷新 | 文件对象直接含 `url` | +| CDN 行为 | 直接返回 URL | CDN 文件自动分发 | +| 文件哈希 | `hashes[]` (Sha1/Md5) | `files[].hashes` (sha1/sha512) | + +**策略**:优先使用文件对象自带的 URL。CurseForge 客户端可缓存 `downloadUrl` 并在 403 时调用刷新端点。 + +### 8. 限流差异 + +| | CurseForge | Modrinth | +|--|-----------|----------| +| 频率 | 未公开(API Key 机制未显示配额) | 300 req/min | +| Header | 未明确 | `X-Ratelimit-*` | +| 超限响应 | 429 | 429 | + +**策略**:内置可配置的限流器,默认遵守 300 req/min。支持 Retry-After 响应处理。 + +--- + +## 架构建议 + +``` +┌─────────────────────────────────────────────────────┐ +│ 启动器应用 │ +├─────────────────────────────────────────────────────┤ +│ ICompClient (统一接口) │ +├──────────────────┬──────────────────┬────────────────┤ +│ CurseForgeClient │ ModrinthClient │ AggregateClient│ +│ (ICompClient) │ (ICompClient) │ (路由/合并) │ +├──────────────────┴──────────────────┴────────────────┤ +│ HTTP 层 (IHttpClientFactory) │ +│ 限流 / 重试 / 日志 / 认证注入 / User-Agent │ +├─────────────────────────────────────────────────────┤ +│ CurseForge REST API │ +│ Modrinth REST API │ +└─────────────────────────────────────────────────────┘ +``` + +### 扩展建议 + +- **插件化 Provider**:通过 DI 注册 `ICompClient` 的具体实现,启动器可同时持有两个客户端。 +- **缓存层**:元数据(游戏版本、分类、加载器)应缓存,避免重复请求。 +- **错误处理**:统一抛出自定义 `CompApiException`,携带 HTTP 状态码、Provider、原始错误消息。 +- **User-Agent**:按 Modrinth 要求,在所有请求中注入 `启动器名/版本 (联系方式)`。 + +--- + +## 文件结构建议 + +``` +Comp/ +├── Models/ +│ ├── CompProject.cs +│ ├── CompFile.cs +│ ├── CompCategory.cs +│ ├── CompGameVersion.cs +│ ├── CompLoader.cs +│ ├── CompSearchFilter.cs +│ ├── CompSearchResult.cs +│ └── Enums/ +│ ├── CompProjectType.cs +│ ├── CompProjectStatus.cs +│ ├── ModLoaderType.cs +│ ├── HashAlgorithm.cs +│ ├── CompSortField.cs +│ └── SortOrder.cs +├── Abstractions/ +│ ├── ICompClient.cs +│ └── ICompClientFactory.cs +├── Clients/ +│ ├── CurseForgeClient.cs +│ ├── ModrinthClient.cs +│ └── AggregateClient.cs +├── Infrastructure/ +│ ├── RateLimiter.cs +│ ├── RetryHandler.cs +│ ├── AuthHandler.cs +│ └── CompApiException.cs +└── Converters/ + ├── CurseForgeModelConverter.cs + └── ModrinthModelConverter.cs +``` diff --git a/curseforge-api.md b/curseforge-api.md new file mode 100644 index 0000000000..cdfe63ac42 --- /dev/null +++ b/curseforge-api.md @@ -0,0 +1,2640 @@ +# CurseForge for Studios API Documentation + +Source: https://docs.curseforge.com/rest-api/ + +--- + +## Getting Started + +### An introduction to CurseForge + +CurseForge is the home to your modding needs. Mods are in-game content that is created by independent creators, and adds a new layer of fun to games, often altering games so much that it creates brand new and exciting experiences within these games. + +Any game that supports mods can be listed on CurseForge. + +### What is CurseForge for Studios + +CurseForge for Studios is a new way for game developers to bring their games to the forefront of modding. When logged in to the CurseForge for Studios console, game developers can set up their games to be available on the CurseForge website [www.curseforge.com](http://www.curseforge.com). Once on the CurseForge Website, authors can start uploading their creations for your game, and users playing your game can then download them through various methods. + +> **Note:** Not a game developer? If you're running a 3rd party modding service, we have an API solution for you. Click [here](https://forms.monday.com/forms/dce5ccb7afda9a1c21dab1a1aa1d84eb?r=use1) to apply for a key. +> +> *Note: Any registered account which is not a game developer and did not request a key, will be deleted* + +### What can CurseForge for Studios do? What can you do with it? + +CurseForge for Studios games are supported on multiple platforms, from PC, to consoles and more. + +As a game developer, you can decide if you want your game users to download mods through various methods, such as a frame within your game launcher (if you have one), an in-game UI built using our advanced API options, plugins for different consoles, such as XBOX, Playstation, etc. In addition, you can make your game's mods available publicly on the CurseForge website, the CurseForge client, and other 3rd party mod managers out there if you choose so. + +Last but not least, you can choose to let us moderate the content created for your game, or moderate it yourself with your own moderation team. + +### Supported download options + +- Cross-Platform plugins, SDKs +- In-Game UI, using CurseForge's API +- Game-Launcher UI that you build, using CurseForge's API +- Game-Launcher UI, using a pre-built customizable frame +- CurseForge Website +- CurseForge Client +- 3rd Party mod managers + +### Your next steps + +1. **Create an account on CurseForge for Studios Console** — Go to the [CurseForge for Studios Create account page](https://console.curseforge.com/?#/signup), and create your account. +2. **Set up your game** — Hit the "Add a game" button and follow the process. +3. **Test your game** — Once your game is set up, start testing it to make sure everything works. +4. **Publish** — When done testing, send your game for approval. Once approved, you can publish whenever you're ready. +5. **Talk to us for any help** — [Contact us](https://support.curseforge.com/en/support/solutions/articles/9000205544-contact-us). + +### Setting up a game, Step by step guide + +1. Login, or create an account on CurseForge for Studios +2. Set up your organization account, or skip +3. Once on the home page, click the "Add a game" button +4. Fill in the required details in the following setup screens (Game assets, Game versions, Categories & Projects) +5. Choose the users you want to test it in the "Testers tab" +6. When done testing, visit the "Status" tab and submit your game for review +7. Once approved, go ahead and publish whenever you are ready +8. Your game is now published and available on the CurseForge website and the API + +--- + +## Accessing the service + +### Base URL + +All endpoints use the same base URL — `https://api.curseforge.com` + +### Pagination Limits + +The maximum page size is 50 results per page and capped at 10000 total results. + +> **Note:** The limit is (index + pageSize <= 10,000). + +### Notes + +- Unless stated otherwise all int32 responses are unsigned. + +### Authentication + +- **API Key (API_KEY)** — Parameter Name: `x-api-key`, in: header. The API key can be generated in the CurseForge for Studios [developer console](https://console.curseforge.com/). + +--- + +## Games + +### Get Games + +``` +GET /v1/games +``` + +```shell +curl -X GET /v1/games \ + -H 'Accept: application/json' \ + -H 'x-api-key: API_KEY' +``` + +Get all games that are available to the provided API key. + +**Parameters** + +| Name | In | Type | Required | Description | +|------|----|------|----------|-------------| +| index | query | integer(int32) | false | A zero based index of the first item to include in the response, the limit is: (index + pageSize <= 10,000). | +| pageSize | query | integer(int32) | false | The number of items to include in the response, the default/maximum value is 50. | + +**Example Response (200)** + +```json +{ + "data": [ + { + "id": 0, + "name": "string", + "slug": "string", + "dateModified": "2019-08-24T14:15:22Z", + "assets": { + "iconUrl": "string", + "tileUrl": "string", + "coverUrl": "string" + }, + "status": 1, + "apiStatus": 1 + } + ], + "pagination": { + "index": 0, + "pageSize": 0, + "resultCount": 0, + "totalCount": 0 + } +} +``` + +**Responses** + +| Status | Meaning | Description | Schema | +|--------|---------|-------------|--------| +| 200 | OK | none | Get Games Response | +| 500 | Internal Server Error | none | None | + +*Authentication: API_KEY* + +--- + +### Get Game + +``` +GET /v1/games/{gameId} +``` + +```shell +curl -X GET /v1/games/{gameId} \ + -H 'Accept: application/json' \ + -H 'x-api-key: API_KEY' +``` + +Get a single game. A private game is only accessible by its respective API key. + +**Parameters** + +| Name | In | Type | Required | Description | +|------|----|------|----------|-------------| +| gameId | path | integer(int32) | true | A game unique id | + +**Example Response (200)** + +```json +{ + "data": { + "id": 0, + "name": "string", + "slug": "string", + "dateModified": "2019-08-24T14:15:22Z", + "assets": { + "iconUrl": "string", + "tileUrl": "string", + "coverUrl": "string" + }, + "status": 1, + "apiStatus": 1 + } +} +``` + +**Responses** + +| Status | Meaning | Description | Schema | +|--------|---------|-------------|--------| +| 200 | OK | none | Get Game Response | +| 404 | Not Found | none | None | +| 500 | Internal Server Error | none | None | + +*Authentication: API_KEY* + +--- + +### Get Versions + +``` +GET /v1/games/{gameId}/versions +``` + +```shell +curl -X GET /v1/games/{gameId}/versions \ + -H 'Accept: application/json' \ + -H 'x-api-key: API_KEY' +``` + +Get all available versions for each known version type of the specified game. A private game is only accessible to its respective API key. + +**Parameters** + +| Name | In | Type | Required | Description | +|------|----|------|----------|-------------| +| gameId | path | integer(int32) | true | A game unique id | + +**Example Response (200)** + +```json +{ + "data": [ + { + "type": 0, + "versions": [ "string" ] + } + ] +} +``` + +**Responses** + +| Status | Meaning | Description | Schema | +|--------|---------|-------------|--------| +| 200 | OK | none | Get Versions Response - V1 | +| 404 | Not Found | none | None | +| 500 | Internal Server Error | none | None | + +*Authentication: API_KEY* + +--- + +### Get Version Types + +``` +GET /v1/games/{gameId}/version-types +``` + +```shell +curl -X GET /v1/games/{gameId}/version-types \ + -H 'Accept: application/json' \ + -H 'x-api-key: API_KEY' +``` + +Get all available version types of the specified game. A private game is only accessible to its respective API key. + +Currently, when creating games via the CurseForge for Studios Console, you are limited to a single game version type. + +**Parameters** + +| Name | In | Type | Required | Description | +|------|----|------|----------|-------------| +| gameId | path | integer(int32) | true | A game unique id | + +**Example Response (200)** + +```json +{ + "data": [ + { + "id": 0, + "gameId": 0, + "name": "string", + "slug": "string", + "isSyncable": true, + "status": 1 + } + ] +} +``` + +**Responses** + +| Status | Meaning | Description | Schema | +|--------|---------|-------------|--------| +| 200 | OK | none | Get Version Types Response | +| 404 | Not Found | none | None | +| 500 | Internal Server Error | none | None | + +*Authentication: API_KEY* + +--- + +### Get Versions - V2 + +``` +GET /v2/games/{gameId}/versions +``` + +```shell +curl -X GET /v2/games/{gameId}/versions \ + -H 'Accept: application/json' \ + -H 'x-api-key: API_KEY' +``` + +Get all available versions for each known version type of the specified game. A private game is only accessible to its respective API key. + +**Parameters** + +| Name | In | Type | Required | Description | +|------|----|------|----------|-------------| +| gameId | path | integer(int32) | true | A game unique id | + +**Example Response (200)** + +```json +{ + "data": [ + { + "type": 0, + "versions": [ + { + "id": 0, + "slug": "string", + "name": "string" + } + ] + } + ] +} +``` + +**Responses** + +| Status | Meaning | Description | Schema | +|--------|---------|-------------|--------| +| 200 | OK | none | Get Versions Response - V2 | +| 404 | Not Found | none | None | +| 500 | Internal Server Error | none | None | + +*Authentication: API_KEY* + +--- + +## Categories + +### Get Categories + +``` +GET /v1/categories +``` + +```shell +curl -X GET /v1/categories?gameId=0 \ + -H 'Accept: application/json' \ + -H 'x-api-key: API_KEY' +``` + +Get all available classes and categories of the specified game. Specify a game id for a list of all game categories, or a class id for a list of categories under that class. Specify the classesOnly flag to just get the classes for a given game. + +**Parameters** + +| Name | In | Type | Required | Description | +|------|----|------|----------|-------------| +| gameId | query | integer(int32) | true | A game unique id | +| classId | query | integer(int32) | false | A class unique id | +| classesOnly | query | boolean | false | A flag used with gameId to return only classes | + +**Example Response (200)** + +```json +{ + "data": [ + { + "id": 0, + "gameId": 0, + "name": "string", + "slug": "string", + "url": "string", + "iconUrl": "string", + "dateModified": "2019-08-24T14:15:22Z", + "isClass": true, + "classId": 0, + "parentCategoryId": 0, + "displayIndex": 0 + } + ] +} +``` + +**Responses** + +| Status | Meaning | Description | Schema | +|--------|---------|-------------|--------| +| 200 | OK | none | Get Categories Response | +| 404 | Not Found | none | None | +| 500 | Internal Server Error | none | None | + +*Authentication: API_KEY* + +--- + +## Mods + +### Search Mods + +``` +GET /v1/mods/search +``` + +```shell +curl -X GET /v1/mods/search?gameId=0 \ + -H 'Accept: application/json' \ + -H 'x-api-key: API_KEY' +``` + +Get all mods that match the search criteria. + +**Parameters** + +| Name | In | Type | Required | Description | +|------|----|------|----------|-------------| +| gameId | query | integer(int32) | true | Filter by game id | +| classId | query | integer(int32) | false | Filter by section id (discoverable via Categories) | +| categoryId | query | integer(int32) | false | Filter by category id | +| categoryIds | query | string | false | Filter by a list of category ids - this will override categoryId | +| gameVersion | query | string | false | Filter by game version string | +| gameVersions | query | string | false | Filter by a list of game version strings - this will override gameVersion | +| searchFilter | query | string | false | Filter by free text search in the mod name and author | +| sortField | query | ModsSearchSortField | false | Filter by ModsSearchSortField enumeration | +| sortOrder | query | SortOrder | false | 'asc' if sort is in ascending order, 'desc' if sort is in descending order | +| modLoaderType | query | ModLoaderType | false | Filter only mods associated to a given modloader (Forge, Fabric ...). Must be coupled with gameVersion. | +| modLoaderTypes | query | string | false | Filter by a list of mod loader types - this will override modLoaderType | +| gameVersionTypeId | query | integer(int32) | false | Filter only mods that contain files tagged with versions of the given gameVersionTypeId | +| authorId | query | integer(int32) | false | Filter only mods that the given authorId is a member of | +| primaryAuthorId | query | integer(int32) | false | Filter only mods that the given primaryAuthorId is the owner of | +| slug | query | string | false | Filter by slug (coupled with classId will result in a unique result) | +| index | query | integer(int32) | false | A zero based index of the first item to include in the response, the limit is: (index + pageSize <= 10,000). | +| pageSize | query | integer(int32) | false | The number of items to include in the response, the default/maximum value is 50 | + +**Detailed descriptions** + +- **categoryIds**: Filter by a list of category ids — this will override categoryId. Format: `categoryIds=[1,2,3...]`. Max 10 category ids per query. +- **gameVersions**: Filter by a list of game version strings — this will override gameVersion. Format: `gameVersions=["1.19.1", "1.19.2"...]`. Max 4 per query. +- **modLoaderTypes**: Filter by a list of mod loader types — this will override modLoaderType. Format: `modLoaderTypes=[Forge, Fabric, ...]`. Max 5 values. + +**Enumerated Values** + +| Parameter | Value | +|-----------|-------| +| sortField | 1=Featured, 2=Popularity, 3=LastUpdated, 4=Name, 5=Author, 6=TotalDownloads, 7=Category, 8=GameVersion, 9=EarlyAccess, 10=FeaturedReleased, 11=ReleasedDate, 12=Rating | +| sortOrder | asc, desc | +| modLoaderType | 0=Any, 1=Forge, 2=Cauldron, 3=LiteLoader, 4=Fabric, 5=Quilt, 6=NeoForge | + +**Responses** + +| Status | Meaning | Description | Schema | +|--------|---------|-------------|--------| +| 200 | OK | none | Search Mods Response | +| 400 | Bad Request | none | None | +| 500 | Internal Server Error | none | None | + +*Authentication: API_KEY* + +--- + +### Get Mod + +``` +GET /v1/mods/{modId} +``` + +```shell +curl -X GET /v1/mods/{modId} \ + -H 'Accept: application/json' \ + -H 'x-api-key: API_KEY' +``` + +Get a single mod. + +**Parameters** + +| Name | In | Type | Required | Description | +|------|----|------|----------|-------------| +| modId | path | integer(int32) | true | The mod id | + +**Responses** + +| Status | Meaning | Description | Schema | +|--------|---------|-------------|--------| +| 200 | OK | none | Get Mod Response | +| 404 | Not Found | none | None | +| 500 | Internal Server Error | none | None | + +*Authentication: API_KEY* + +--- + +### Get Mods + +``` +POST /v1/mods +``` + +```shell +curl -X POST /v1/mods \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json' \ + -H 'x-api-key: API_KEY' +``` + +Get a list of mods belonging to the same game. + +**Body parameter** + +```json +{ + "modIds": [ 0 ], + "filterPcOnly": true +} +``` + +**Parameters** + +| Name | In | Type | Required | Description | +|------|----|------|----------|-------------| +| body | body | GetModsByIdsListRequestBody | true | Request body containing an array of mod ids, mod ids must belong to the same game | + +**Responses** + +| Status | Meaning | Description | Schema | +|--------|---------|-------------|--------| +| 200 | OK | none | Get Mods Response | +| 400 | Bad Request | none | None | +| 500 | Internal Server Error | none | None | + +*Authentication: API_KEY* + +--- + +### Get Featured Mods + +``` +POST /v1/mods/featured +``` + +```shell +curl -X POST /v1/mods/featured \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json' \ + -H 'x-api-key: API_KEY' +``` + +Get a list of featured, popular and recently updated mods. + +**Body parameter** + +```json +{ + "gameId": 0, + "excludedModIds": [ 0 ], + "gameVersionTypeId": 0 +} +``` + +**Parameters** + +| Name | In | Type | Required | Description | +|------|----|------|----------|-------------| +| body | body | GetFeaturedModsRequestBody | true | Match results for a game and exclude specific mods | + +**Responses** + +| Status | Meaning | Description | Schema | +|--------|---------|-------------|--------| +| 200 | OK | none | Get Featured Mods Response | +| 400 | Bad Request | none | None | +| 404 | Not Found | none | None | +| 500 | Internal Server Error | none | None | + +*Authentication: API_KEY* + +--- + +### Get Mod Description + +``` +GET /v1/mods/{modId}/description +``` + +```shell +curl -X GET /v1/mods/{modId}/description \ + -H 'Accept: application/json' \ + -H 'x-api-key: API_KEY' +``` + +Get the full description of a mod in HTML format. + +**Parameters** + +| Name | In | Type | Required | Description | +|------|----|------|----------|-------------| +| modId | path | integer(int32) | true | The mod id | +| raw | query | boolean | false | none | +| stripped | query | boolean | false | none | +| markup | query | boolean | false | none | + +**Example Response (200)** + +```json +{ + "data": "string" +} +``` + +**Responses** + +| Status | Meaning | Description | Schema | +|--------|---------|-------------|--------| +| 200 | OK | none | String Response | +| 404 | Not Found | none | None | +| 500 | Internal Server Error | none | None | + +*Authentication: API_KEY* + +--- + +## Files + +### Get Mod File + +``` +GET /v1/mods/{modId}/files/{fileId} +``` + +```shell +curl -X GET /v1/mods/{modId}/files/{fileId} \ + -H 'Accept: application/json' \ + -H 'x-api-key: API_KEY' +``` + +Get a single file of the specified mod. + +**Parameters** + +| Name | In | Type | Required | Description | +|------|----|------|----------|-------------| +| modId | path | integer(int32) | true | The mod id the file belongs to | +| fileId | path | integer(int32) | true | The file id | + +**Responses** + +| Status | Meaning | Description | Schema | +|--------|---------|-------------|--------| +| 200 | OK | none | Get Mod File Response | +| 404 | Not Found | none | None | +| 500 | Internal Server Error | none | None | + +*Authentication: API_KEY* + +--- + +### Get Mod Files + +``` +GET /v1/mods/{modId}/files +``` + +```shell +curl -X GET /v1/mods/{modId}/files \ + -H 'Accept: application/json' \ + -H 'x-api-key: API_KEY' +``` + +Get all files of the specified mod. + +**Parameters** + +| Name | In | Type | Required | Description | +|------|----|------|----------|-------------| +| modId | path | integer(int32) | true | The mod id the files belong to | +| gameVersion | query | string | false | Filter by game version string | +| modLoaderType | query | ModLoaderType | false | ModLoaderType enumeration | +| gameVersionTypeId | query | integer(int32) | false | Filter only files that are tagged with versions of the given gameVersionTypeId | +| index | query | integer(int32) | false | A zero based index of the first item to include in the response | +| pageSize | query | integer(int32) | false | The number of items to include in the response, the default/maximum value is 50 | + +**Detailed descriptions** + +- **modLoaderType**: ModLoaderType enumeration. Filter only files associated to a given modloader (Forge, Fabric ...). + +**Enumerated Values** + +| Parameter | Value | +|-----------|-------| +| modLoaderType | 0=Any, 1=Forge, 2=Cauldron, 3=LiteLoader, 4=Fabric, 5=Quilt, 6=NeoForge | + +**Responses** + +| Status | Meaning | Description | Schema | +|--------|---------|-------------|--------| +| 200 | OK | none | Get Mod Files Response | +| 404 | Not Found | none | None | +| 500 | Internal Server Error | none | None | + +*Authentication: API_KEY* + +--- + +### Get Files + +``` +POST /v1/mods/files +``` + +```shell +curl -X POST /v1/mods/files \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json' \ + -H 'x-api-key: API_KEY' +``` + +Get a list of files. + +**Body parameter** + +```json +{ + "fileIds": [ 0 ] +} +``` + +**Parameters** + +| Name | In | Type | Required | Description | +|------|----|------|----------|-------------| +| body | body | GetModFilesRequestBody | true | Request body containing a list of file ids to fetch | + +**Responses** + +| Status | Meaning | Description | Schema | +|--------|---------|-------------|--------| +| 200 | OK | none | Get Files Response | +| 400 | Bad Request | none | None | +| 404 | Not Found | none | None | +| 500 | Internal Server Error | none | None | + +*Authentication: API_KEY* + +--- + +### Get Mod File Changelog + +``` +GET /v1/mods/{modId}/files/{fileId}/changelog +``` + +```shell +curl -X GET /v1/mods/{modId}/files/{fileId}/changelog \ + -H 'Accept: application/json' \ + -H 'x-api-key: API_KEY' +``` + +Get the changelog of a file in HTML format. + +**Parameters** + +| Name | In | Type | Required | Description | +|------|----|------|----------|-------------| +| modId | path | integer(int32) | true | The mod id the file belongs to | +| fileId | path | integer(int32) | true | The file id | + +**Example Response (200)** + +```json +{ + "data": "string" +} +``` + +**Responses** + +| Status | Meaning | Description | Schema | +|--------|---------|-------------|--------| +| 200 | OK | none | String Response | +| 404 | Not Found | none | None | +| 500 | Internal Server Error | none | None | + +*Authentication: API_KEY* + +--- + +### Get Mod File Download URL + +``` +GET /v1/mods/{modId}/files/{fileId}/download-url +``` + +```shell +curl -X GET /v1/mods/{modId}/files/{fileId}/download-url \ + -H 'Accept: application/json' \ + -H 'x-api-key: API_KEY' +``` + +Get a download url for a specific file. + +**Parameters** + +| Name | In | Type | Required | Description | +|------|----|------|----------|-------------| +| modId | path | integer(int32) | true | The mod id the file belongs to | +| fileId | path | integer(int32) | true | The file id | + +**Example Response (200)** + +```json +{ + "data": "string" +} +``` + +**Responses** + +| Status | Meaning | Description | Schema | +|--------|---------|-------------|--------| +| 200 | OK | none | String Response | +| 404 | Not Found | none | None | +| 500 | Internal Server Error | none | None | + +*Authentication: API_KEY* + +--- + +## Fingerprints + +### Get Fingerprints Matches By Game Id + +``` +POST /v1/fingerprints/{gameId} +``` + +```shell +curl -X POST /v1/fingerprints/{gameId} \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json' \ + -H 'x-api-key: API_KEY' +``` + +Get mod files that match a list of fingerprints for a given game id. + +**Body parameter** + +```json +{ + "fingerprints": [ 0 ] +} +``` + +**Parameters** + +| Name | In | Type | Required | Description | +|------|----|------|----------|-------------| +| gameId | path | integer(int32) | true | The game id for matching fingerprints | +| body | body | GetFingerprintMatchesRequestBody | true | The request body containing an array of fingerprints | + +**Responses** + +| Status | Meaning | Description | Schema | +|--------|---------|-------------|--------| +| 200 | OK | none | Get Fingerprint Matches Response | +| 400 | Bad Request | none | None | +| 503 | Service Unavailable | none | string | + +*Authentication: API_KEY* + +--- + +### Get Fingerprints Matches + +``` +POST /v1/fingerprints +``` + +```shell +curl -X POST /v1/fingerprints \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json' \ + -H 'x-api-key: API_KEY' +``` + +Get mod files that match a list of fingerprints. + +**Body parameter** + +```json +{ + "fingerprints": [ 0 ] +} +``` + +**Parameters** + +| Name | In | Type | Required | Description | +|------|----|------|----------|-------------| +| body | body | GetFingerprintMatchesRequestBody | true | The request body containing an array of fingerprints | + +**Responses** + +| Status | Meaning | Description | Schema | +|--------|---------|-------------|--------| +| 200 | OK | none | Get Fingerprint Matches Response | +| 400 | Bad Request | none | None | +| 503 | Service Unavailable | none | string | + +*Authentication: API_KEY* + +--- + +### Get Fingerprints Fuzzy Matches By Game Id + +``` +POST /v1/fingerprints/fuzzy/{gameId} +``` + +```shell +curl -X POST /v1/fingerprints/fuzzy/{gameId} \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json' \ + -H 'x-api-key: API_KEY' +``` + +Get mod files that match a list of fingerprints using fuzzy matching. + +**Body parameter** + +```json +{ + "gameId": 0, + "fingerprints": [ + { + "foldername": "string", + "fingerprints": [ 0 ] + } + ] +} +``` + +**Parameters** + +| Name | In | Type | Required | Description | +|------|----|------|----------|-------------| +| gameId | path | integer(int32) | true | The game id for matching fingerprints | +| body | body | GetFuzzyMatchesRequestBody | true | Game id and folder fingerprints options for the fuzzy matching | + +**Responses** + +| Status | Meaning | Description | Schema | +|--------|---------|-------------|--------| +| 200 | OK | none | Get Fingerprints Fuzzy Matches Response | +| 400 | Bad Request | none | None | +| 503 | Service Unavailable | none | string | + +*Authentication: API_KEY* + +--- + +### Get Fingerprints Fuzzy Matches + +``` +POST /v1/fingerprints/fuzzy +``` + +```shell +curl -X POST /v1/fingerprints/fuzzy \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json' \ + -H 'x-api-key: API_KEY' +``` + +Get mod files that match a list of fingerprints using fuzzy matching. + +**Body parameter** + +```json +{ + "gameId": 0, + "fingerprints": [ + { + "foldername": "string", + "fingerprints": [ 0 ] + } + ] +} +``` + +**Parameters** + +| Name | In | Type | Required | Description | +|------|----|------|----------|-------------| +| body | body | GetFuzzyMatchesRequestBody | true | Game id and folder fingerprints options for the fuzzy matching | + +**Responses** + +| Status | Meaning | Description | Schema | +|--------|---------|-------------|--------| +| 200 | OK | none | Get Fingerprints Fuzzy Matches Response | +| 400 | Bad Request | none | None | +| 503 | Service Unavailable | none | string | + +*Authentication: API_KEY* + +--- + +## Minecraft + +### Get Minecraft Versions + +``` +GET /v1/minecraft/version +``` + +```shell +curl -X GET /v1/minecraft/version \ + -H 'Accept: application/json' \ + -H 'x-api-key: API_KEY' +``` + +**Parameters** + +| Name | In | Type | Required | Description | +|------|----|------|----------|-------------| +| sortDescending | query | boolean | false | none | + +**Example Response (200)** + +```json +{ + "data": [ + { + "id": 0, + "gameVersionId": 0, + "versionString": "string", + "jarDownloadUrl": "string", + "jsonDownloadUrl": "string", + "approved": true, + "dateModified": "2019-08-24T14:15:22Z", + "gameVersionTypeId": 0, + "gameVersionStatus": 1, + "gameVersionTypeStatus": 1 + } + ] +} +``` + +**Responses** + +| Status | Meaning | Description | Schema | +|--------|---------|-------------|--------| +| 200 | OK | none | ApiResponseOfListOfMinecraftGameVersion | +| 404 | Not Found | none | None | +| 500 | Internal Server Error | none | None | + +*Authentication: API_KEY* + +--- + +### Get Specific Minecraft Version + +``` +GET /v1/minecraft/version/{gameVersionString} +``` + +```shell +curl -X GET /v1/minecraft/version/{gameVersionString} \ + -H 'Accept: application/json' \ + -H 'x-api-key: API_KEY' +``` + +**Parameters** + +| Name | In | Type | Required | Description | +|------|----|------|----------|-------------| +| gameVersionString | path | string | true | none | + +**Example Response (200)** + +```json +{ + "data": { + "id": 0, + "gameVersionId": 0, + "versionString": "string", + "jarDownloadUrl": "string", + "jsonDownloadUrl": "string", + "approved": true, + "dateModified": "2019-08-24T14:15:22Z", + "gameVersionTypeId": 0, + "gameVersionStatus": 1, + "gameVersionTypeStatus": 1 + } +} +``` + +**Responses** + +| Status | Meaning | Description | Schema | +|--------|---------|-------------|--------| +| 200 | OK | none | ApiResponseOfMinecraftGameVersion | +| 404 | Not Found | none | None | +| 500 | Internal Server Error | none | None | + +*Authentication: API_KEY* + +--- + +### Get Minecraft ModLoaders + +``` +GET /v1/minecraft/modloader +``` + +```shell +curl -X GET /v1/minecraft/modloader \ + -H 'Accept: application/json' \ + -H 'x-api-key: API_KEY' +``` + +**Parameters** + +| Name | In | Type | Required | Description | +|------|----|------|----------|-------------| +| version | query | string | false | none | +| includeAll | query | boolean | false | none | + +**Example Response (200)** + +```json +{ + "data": [ + { + "name": "string", + "gameVersion": "string", + "latest": true, + "recommended": true, + "dateModified": "2019-08-24T14:15:22Z", + "type": 0 + } + ] +} +``` + +**Responses** + +| Status | Meaning | Description | Schema | +|--------|---------|-------------|--------| +| 200 | OK | none | ApiResponseOfListOfMinecraftModLoaderIndex | +| 404 | Not Found | none | None | +| 500 | Internal Server Error | none | None | + +*Authentication: API_KEY* + +--- + +### Get Specific Minecraft ModLoader + +``` +GET /v1/minecraft/modloader/{modLoaderName} +``` + +```shell +curl -X GET /v1/minecraft/modloader/{modLoaderName} \ + -H 'Accept: application/json' \ + -H 'x-api-key: API_KEY' +``` + +**Parameters** + +| Name | In | Type | Required | Description | +|------|----|------|----------|-------------| +| modLoaderName | path | string | true | none | + +**Example Response (200)** + +```json +{ + "data": { + "id": 0, + "gameVersionId": 0, + "minecraftGameVersionId": 0, + "forgeVersion": "string", + "name": "string", + "type": 0, + "downloadUrl": "string", + "filename": "string", + "installMethod": 1, + "latest": true, + "recommended": true, + "approved": true, + "dateModified": "2019-08-24T14:15:22Z", + "mavenVersionString": "string", + "versionJson": "string", + "librariesInstallLocation": "string", + "minecraftVersion": "string", + "additionalFilesJson": "string", + "modLoaderGameVersionId": 0, + "modLoaderGameVersionTypeId": 0, + "modLoaderGameVersionStatus": 1, + "modLoaderGameVersionTypeStatus": 1, + "mcGameVersionId": 0, + "mcGameVersionTypeId": 0, + "mcGameVersionStatus": 1, + "mcGameVersionTypeStatus": 1, + "installProfileJson": "string" + } +} +``` + +**Responses** + +| Status | Meaning | Description | Schema | +|--------|---------|-------------|--------| +| 200 | OK | none | ApiResponseOfMinecraftModLoaderVersion | +| 404 | Not Found | none | None | +| 500 | Internal Server Error | none | None | + +*Authentication: API_KEY* + +--- + +## Schemas + +### ApiResponseOfListOfMinecraftGameVersion + +```json +{ + "data": [ + { + "id": 0, + "gameVersionId": 0, + "versionString": "string", + "jarDownloadUrl": "string", + "jsonDownloadUrl": "string", + "approved": true, + "dateModified": "2019-08-24T14:15:22Z", + "gameVersionTypeId": 0, + "gameVersionStatus": 1, + "gameVersionTypeStatus": 1 + } + ] +} +``` + +**Properties** + +| Name | Type | Description | +|------|------|-------------| +| data | [MinecraftGameVersion] | The response data | + +--- + +### ApiResponseOfListOfMinecraftModLoaderIndex + +```json +{ + "data": [ + { + "name": "string", + "gameVersion": "string", + "latest": true, + "recommended": true, + "dateModified": "2019-08-24T14:15:22Z", + "type": 0 + } + ] +} +``` + +**Properties** + +| Name | Type | Description | +|------|------|-------------| +| data | [MinecraftModLoaderIndex] | The response data | + +--- + +### ApiResponseOfMinecraftGameVersion + +```json +{ + "data": { + "id": 0, + "gameVersionId": 0, + "versionString": "string", + "jarDownloadUrl": "string", + "jsonDownloadUrl": "string", + "approved": true, + "dateModified": "2019-08-24T14:15:22Z", + "gameVersionTypeId": 0, + "gameVersionStatus": 1, + "gameVersionTypeStatus": 1 + } +} +``` + +**Properties** + +| Name | Type | Description | +|------|------|-------------| +| data | MinecraftGameVersion | The response data | + +--- + +### ApiResponseOfMinecraftModLoaderVersion + +```json +{ + "data": { + "id": 0, + "gameVersionId": 0, + "minecraftGameVersionId": 0, + "forgeVersion": "string", + "name": "string", + "type": 0, + "downloadUrl": "string", + "filename": "string", + "installMethod": 1, + "latest": true, + "recommended": true, + "approved": true, + "dateModified": "2019-08-24T14:15:22Z", + "mavenVersionString": "string", + "versionJson": "string", + "librariesInstallLocation": "string", + "minecraftVersion": "string", + "additionalFilesJson": "string", + "modLoaderGameVersionId": 0, + "modLoaderGameVersionTypeId": 0, + "modLoaderGameVersionStatus": 1, + "modLoaderGameVersionTypeStatus": 1, + "mcGameVersionId": 0, + "mcGameVersionTypeId": 0, + "mcGameVersionStatus": 1, + "mcGameVersionTypeStatus": 1, + "installProfileJson": "string" + } +} +``` + +**Properties** + +| Name | Type | Description | +|------|------|-------------| +| data | MinecraftModLoaderVersion | The response data | + +--- + +### Category + +```json +{ + "id": 0, + "gameId": 0, + "name": "string", + "slug": "string", + "url": "string", + "iconUrl": "string", + "dateModified": "2019-08-24T14:15:22Z", + "isClass": true, + "classId": 0, + "parentCategoryId": 0, + "displayIndex": 0 +} +``` + +**Properties** + +| Name | Type | Description | +|------|------|-------------| +| id | integer(int32) | The category id | +| gameId | integer(int32) | The game id related to the category | +| name | string | Category name | +| slug | string | The category slug as it appears in the URL | +| url | string | The category URL | +| iconUrl | string | URL for the category icon | +| dateModified | string(date-time) | Last modified date of the category | +| isClass | boolean\|null | A top level category for other categories | +| classId | integer(int32)\|null | The class id of the category | +| parentCategoryId | integer(int32)\|null | The parent category for this category | +| displayIndex | integer(int32)\|null | The display index for this category | + +--- + +### CoreApiStatus + +``` +1 +``` + +Possible values: 1=Private, 2=Public + +--- + +### CoreStatus + +``` +1 +``` + +Possible values: 1=Draft, 2=Test, 3=PendingReview, 4=Rejected, 5=Approved, 6=Live + +--- + +### FeaturedModsResponse + +```json +{ + "featured": [ { "...mod..." } ], + "popular": [ { "...mod..." } ], + "recentlyUpdated": [ { "...mod..." } ] +} +``` + +**Properties** + +| Name | Type | Description | +|------|------|-------------| +| featured | [Mod] | none | +| popular | [Mod] | none | +| recentlyUpdated | [Mod] | none | + +--- + +### File + +```json +{ + "id": 0, + "gameId": 0, + "modId": 0, + "isAvailable": true, + "displayName": "string", + "fileName": "string", + "releaseType": 1, + "fileStatus": 1, + "hashes": [ + { "value": "string", "algo": 1 } + ], + "fileDate": "2019-08-24T14:15:22Z", + "fileLength": 0, + "downloadCount": 0, + "fileSizeOnDisk": 0, + "downloadUrl": "string", + "gameVersions": [ "string" ], + "sortableGameVersions": [ + { + "gameVersionName": "string", + "gameVersionPadded": "string", + "gameVersion": "string", + "gameVersionReleaseDate": "2019-08-24T14:15:22Z", + "gameVersionTypeId": 0 + } + ], + "dependencies": [ + { "modId": 0, "relationType": 1 } + ], + "exposeAsAlternative": true, + "parentProjectFileId": 0, + "alternateFileId": 0, + "isServerPack": true, + "serverPackFileId": 0, + "isEarlyAccessContent": true, + "earlyAccessEndDate": "2019-08-24T14:15:22Z", + "fileFingerprint": 0, + "modules": [ + { "name": "string", "fingerprint": 0 } + ] +} +``` + +**Properties** + +| Name | Type | Description | +|------|------|-------------| +| id | integer(int32) | The file id | +| gameId | integer(int32) | The game id related to the mod that this file belongs to | +| modId | integer(int32) | The mod id | +| isAvailable | boolean | Whether the file is available to download | +| displayName | string | Display name of the file | +| fileName | string | Exact file name | +| releaseType | FileReleaseType | The file release type | +| fileStatus | FileStatus | Status of the file | +| hashes | [FileHash] | The file hash (i.e. md5 or sha1) | +| fileDate | string(date-time) | The file timestamp | +| fileLength | integer(int64) | The file length in bytes | +| downloadCount | integer(int64) | The number of downloads for the file | +| fileSizeOnDisk | integer(int64)\|null | The file's size on disk | +| downloadUrl | string | The file download URL | +| gameVersions | [string] | List of game versions this file is relevant for | +| sortableGameVersions | [SortableGameVersion] | Metadata used for sorting by game versions | +| dependencies | [FileDependency] | List of dependencies files | +| exposeAsAlternative | boolean\|null | none | +| parentProjectFileId | integer(int32)\|null | none | +| alternateFileId | integer(int32)\|null | none | +| isServerPack | boolean\|null | none | +| serverPackFileId | integer(int32)\|null | none | +| isEarlyAccessContent | boolean\|null | none | +| earlyAccessEndDate | string(date-time)\|null | none | +| fileFingerprint | integer(int64) | none | +| modules | [FileModule] | none | + +--- + +### FileDependency + +```json +{ + "modId": 0, + "relationType": 1 +} +``` + +**Properties** + +| Name | Type | Description | +|------|------|-------------| +| modId | integer(int32) | none | +| relationType | FileRelationType | 1=EmbeddedLibrary, 2=OptionalDependency, 3=RequiredDependency, 4=Tool, 5=Incompatible, 6=Include | + +--- + +### FileHash + +```json +{ + "value": "string", + "algo": 1 +} +``` + +**Properties** + +| Name | Type | Description | +|------|------|-------------| +| value | string | none | +| algo | HashAlgo | 1=Sha1, 2=Md5 | + +--- + +### FileIndex + +```json +{ + "gameVersion": "string", + "fileId": 0, + "filename": "string", + "releaseType": 1, + "gameVersionTypeId": 0, + "modLoader": 0 +} +``` + +**Properties** + +| Name | Type | Description | +|------|------|-------------| +| gameVersion | string | none | +| fileId | integer(int32) | none | +| filename | string | none | +| releaseType | FileReleaseType | 1=Release, 2=Beta, 3=Alpha | +| gameVersionTypeId | integer(int32)\|null | none | +| modLoader | ModLoaderType | 0=Any, 1=Forge, 2=Cauldron, 3=LiteLoader, 4=Fabric, 5=Quilt, 6=NeoForge | + +--- + +### FileModule + +```json +{ + "name": "string", + "fingerprint": 0 +} +``` + +**Properties** + +| Name | Type | Description | +|------|------|-------------| +| name | string | none | +| fingerprint | integer(int64) | none | + +--- + +### FileRelationType + +``` +1 +``` + +Possible values: 1=EmbeddedLibrary, 2=OptionalDependency, 3=RequiredDependency, 4=Tool, 5=Incompatible, 6=Include + +--- + +### FileReleaseType + +``` +1 +``` + +Possible values: 1=Release, 2=Beta, 3=Alpha + +--- + +### FileStatus + +``` +1 +``` + +Possible values: 1=Processing, 2=ChangesRequired, 3=UnderReview, 4=Approved, 5=Rejected, 6=MalwareDetected, 7=Deleted, 8=Archived, 9=Testing, 10=Released, 11=ReadyForReview, 12=Deprecated, 13=Baking, 14=AwaitingPublishing, 15=FailedPublishing, 16=Cooking, 17=Cooked, 18=UnderManualReview, 19=ScanningForMalware, 20=ProcessingFile, 21=PendingRelease, 22=ReadyForCooking, 23=PostProcessing + +--- + +### FingerprintFuzzyMatch + +```json +{ + "id": 0, + "file": { "...File..." }, + "latestFiles": [ { "...File..." } ], + "fingerprints": [ 0 ] +} +``` + +**Properties** + +| Name | Type | Description | +|------|------|-------------| +| id | integer(int32) | none | +| file | File | none | +| latestFiles | [File] | none | +| fingerprints | [integer] | none | + +--- + +### FingerprintFuzzyMatchResult + +```json +{ + "fuzzyMatches": [ { "...FingerprintFuzzyMatch..." } ] +} +``` + +**Properties** + +| Name | Type | Description | +|------|------|-------------| +| fuzzyMatches | [FingerprintFuzzyMatch] | none | + +--- + +### FingerprintMatch + +```json +{ + "id": 0, + "file": { "...File..." }, + "latestFiles": [ { "...File..." } ] +} +``` + +**Properties** + +| Name | Type | Description | +|------|------|-------------| +| id | integer(int32) | none | +| file | File | none | +| latestFiles | [File] | none | + +--- + +### FingerprintsMatchesResult + +```json +{ + "isCacheBuilt": true, + "exactMatches": [ { "...FingerprintMatch..." } ], + "exactFingerprints": [ 0 ], + "partialMatches": [ { "...FingerprintMatch..." } ], + "partialMatchFingerprints": { }, + "installedFingerprints": [ 0 ], + "unmatchedFingerprints": [ 0 ] +} +``` + +**Properties** + +| Name | Type | Description | +|------|------|-------------| +| isCacheBuilt | boolean | none | +| exactMatches | [FingerprintMatch] | none | +| exactFingerprints | [integer] | none | +| partialMatches | [FingerprintMatch] | none | +| partialMatchFingerprints | object | none | +| installedFingerprints | [integer] | none | +| unmatchedFingerprints | [integer] | none | + +--- + +### FolderFingerprint + +```json +{ + "foldername": "string", + "fingerprints": [ 0 ] +} +``` + +**Properties** + +| Name | Type | Description | +|------|------|-------------| +| foldername | string | none | +| fingerprints | [integer] | none | + +--- + +### Game + +```json +{ + "id": 0, + "name": "string", + "slug": "string", + "dateModified": "2019-08-24T14:15:22Z", + "assets": { + "iconUrl": "string", + "tileUrl": "string", + "coverUrl": "string" + }, + "status": 1, + "apiStatus": 1 +} +``` + +**Properties** + +| Name | Type | Description | +|------|------|-------------| +| id | integer(int32) | none | +| name | string | none | +| slug | string | none | +| dateModified | string(date-time) | none | +| assets | GameAssets | none | +| status | CoreStatus | 1=Draft, 2=Test, 3=PendingReview, 4=Rejected, 5=Approved, 6=Live | +| apiStatus | CoreApiStatus | 1=Private, 2=Public | + +--- + +### GameAssets + +```json +{ + "iconUrl": "string", + "tileUrl": "string", + "coverUrl": "string" +} +``` + +**Properties** + +| Name | Type | Description | +|------|------|-------------| +| iconUrl | string | none | +| tileUrl | string | none | +| coverUrl | string | none | + +--- + +### GameVersion + +```json +{ + "id": 0, + "slug": "string", + "name": "string" +} +``` + +**Properties** + +| Name | Type | Description | +|------|------|-------------| +| id | integer(int32) | none | +| slug | string | none | +| name | string | none | + +--- + +### GameVersionsByType + +```json +{ + "type": 0, + "versions": [ "string" ] +} +``` + +**Properties** + +| Name | Type | Description | +|------|------|-------------| +| type | integer(int32) | none | +| versions | [string] | none | + +--- + +### GameVersionsByType2 + +```json +{ + "type": 0, + "versions": [ + { "id": 0, "slug": "string", "name": "string" } + ] +} +``` + +**Properties** + +| Name | Type | Description | +|------|------|-------------| +| type | integer(int32) | none | +| versions | [GameVersion] | none | + +--- + +### GameVersionStatus + +``` +1 +``` + +Possible values: 1=Approved, 2=Deleted, 3=New + +--- + +### GameVersionType + +```json +{ + "id": 0, + "gameId": 0, + "name": "string", + "slug": "string", + "isSyncable": true, + "status": 1 +} +``` + +**Properties** + +| Name | Type | Description | +|------|------|-------------| +| id | integer(int32) | none | +| gameId | integer(int32) | none | +| name | string | none | +| slug | string | none | +| isSyncable | boolean | none | +| status | GameVersionTypeStatus | 1=Normal, 2=Deleted | + +--- + +### GameVersionTypeStatus + +``` +1 +``` + +Possible values: 1=Normal, 2=Deleted + +--- + +### Get Categories Response + +```json +{ + "data": [ { "...Category..." } ] +} +``` + +**Properties** + +| Name | Type | Description | +|------|------|-------------| +| data | [Category] | The response data | + +--- + +### Get Featured Mods Response + +```json +{ + "data": { "...FeaturedModsResponse..." } +} +``` + +**Properties** + +| Name | Type | Description | +|------|------|-------------| +| data | FeaturedModsResponse | The response data | + +--- + +### Get Files Response + +```json +{ + "data": [ { "...File..." } ] +} +``` + +**Properties** + +| Name | Type | Description | +|------|------|-------------| +| data | [File] | The response data | + +--- + +### Get Fingerprint Matches Response + +```json +{ + "data": { "...FingerprintsMatchesResult..." } +} +``` + +**Properties** + +| Name | Type | Description | +|------|------|-------------| +| data | FingerprintsMatchesResult | The response data | + +--- + +### Get Fingerprints Fuzzy Matches Response + +```json +{ + "data": { "...FingerprintFuzzyMatchResult..." } +} +``` + +**Properties** + +| Name | Type | Description | +|------|------|-------------| +| data | FingerprintFuzzyMatchResult | The response data | + +--- + +### Get Game Response + +```json +{ + "data": { "...Game..." } +} +``` + +**Properties** + +| Name | Type | Description | +|------|------|-------------| +| data | Game | The response data | + +--- + +### Get Games Response + +```json +{ + "data": [ { "...Game..." } ], + "pagination": { "...Pagination..." } +} +``` + +**Properties** + +| Name | Type | Description | +|------|------|-------------| +| data | [Game] | The response data | +| pagination | Pagination | The response pagination information | + +--- + +### Get Mod File Response + +```json +{ + "data": { "...File..." } +} +``` + +**Properties** + +| Name | Type | Description | +|------|------|-------------| +| data | File | The response data | + +--- + +### Get Mod Files Response + +```json +{ + "data": [ { "...File..." } ], + "pagination": { "...Pagination..." } +} +``` + +**Properties** + +| Name | Type | Description | +|------|------|-------------| +| data | [File] | The response data | +| pagination | Pagination | The response pagination information | + +--- + +### Get Mod Response + +```json +{ + "data": { "...Mod..." } +} +``` + +**Properties** + +| Name | Type | Description | +|------|------|-------------| +| data | Mod | The response data | + +--- + +### Get Mods Response + +```json +{ + "data": [ { "...Mod..." } ] +} +``` + +**Properties** + +| Name | Type | Description | +|------|------|-------------| +| data | [Mod] | The response data | + +--- + +### Get Version Types Response + +```json +{ + "data": [ { "...GameVersionType..." } ] +} +``` + +**Properties** + +| Name | Type | Description | +|------|------|-------------| +| data | [GameVersionType] | The response data | + +--- + +### Get Versions Response - V1 + +```json +{ + "data": [ { "...GameVersionsByType..." } ] +} +``` + +**Properties** + +| Name | Type | Description | +|------|------|-------------| +| data | [GameVersionsByType] | The response data | + +--- + +### Get Versions Response - V2 + +```json +{ + "data": [ { "...GameVersionsByType2..." } ] +} +``` + +**Properties** + +| Name | Type | Description | +|------|------|-------------| +| data | [GameVersionsByType2] | The response data | + +--- + +### GetFeaturedModsRequestBody + +```json +{ + "gameId": 0, + "excludedModIds": [ 0 ], + "gameVersionTypeId": 0 +} +``` + +**Properties** + +| Name | Type | Description | +|------|------|-------------| +| gameId | integer(int32) | none | +| excludedModIds | [integer] | none | +| gameVersionTypeId | integer(int32)\|null | none | + +--- + +### GetFingerprintMatchesRequestBody + +```json +{ + "fingerprints": [ 0 ] +} +``` + +**Properties** + +| Name | Type | Description | +|------|------|-------------| +| fingerprints | [integer] | none | + +--- + +### GetFuzzyMatchesRequestBody + +```json +{ + "gameId": 0, + "fingerprints": [ + { "foldername": "string", "fingerprints": [ 0 ] } + ] +} +``` + +**Properties** + +| Name | Type | Description | +|------|------|-------------| +| gameId | integer(int32) | none | +| fingerprints | [FolderFingerprint] | none | + +--- + +### GetModFilesRequestBody + +```json +{ + "fileIds": [ 0 ] +} +``` + +**Properties** + +| Name | Type | Description | +|------|------|-------------| +| fileIds | [integer] | none | + +--- + +### GetModsByIdsListRequestBody + +```json +{ + "modIds": [ 0 ], + "filterPcOnly": true +} +``` + +**Properties** + +| Name | Type | Description | +|------|------|-------------| +| modIds | [integer] | none | +| filterPcOnly | boolean\|null | none | + +--- + +### HashAlgo + +``` +1 +``` + +Possible values: 1=Sha1, 2=Md5 + +--- + +### MinecraftGameVersion + +```json +{ + "id": 0, + "gameVersionId": 0, + "versionString": "string", + "jarDownloadUrl": "string", + "jsonDownloadUrl": "string", + "approved": true, + "dateModified": "2019-08-24T14:15:22Z", + "gameVersionTypeId": 0, + "gameVersionStatus": 1, + "gameVersionTypeStatus": 1 +} +``` + +**Properties** + +| Name | Type | Description | +|------|------|-------------| +| id | integer(int32) | none | +| gameVersionId | integer(int32) | none | +| versionString | string | none | +| jarDownloadUrl | string | none | +| jsonDownloadUrl | string | none | +| approved | boolean | none | +| dateModified | string(date-time) | none | +| gameVersionTypeId | integer(int32) | none | +| gameVersionStatus | GameVersionStatus | 1=Approved, 2=Deleted, 3=New | +| gameVersionTypeStatus | GameVersionTypeStatus | 1=Normal, 2=Deleted | + +--- + +### MinecraftModLoaderIndex + +```json +{ + "name": "string", + "gameVersion": "string", + "latest": true, + "recommended": true, + "dateModified": "2019-08-24T14:15:22Z", + "type": 0 +} +``` + +**Properties** + +| Name | Type | Description | +|------|------|-------------| +| name | string | none | +| gameVersion | string | none | +| latest | boolean | none | +| recommended | boolean | none | +| dateModified | string(date-time) | none | +| type | ModLoaderType | 0=Any, 1=Forge, 2=Cauldron, 3=LiteLoader, 4=Fabric, 5=Quilt, 6=NeoForge | + +--- + +### MinecraftModLoaderVersion + +```json +{ + "id": 0, + "gameVersionId": 0, + "minecraftGameVersionId": 0, + "forgeVersion": "string", + "name": "string", + "type": 0, + "downloadUrl": "string", + "filename": "string", + "installMethod": 1, + "latest": true, + "recommended": true, + "approved": true, + "dateModified": "2019-08-24T14:15:22Z", + "mavenVersionString": "string", + "versionJson": "string", + "librariesInstallLocation": "string", + "minecraftVersion": "string", + "additionalFilesJson": "string", + "modLoaderGameVersionId": 0, + "modLoaderGameVersionTypeId": 0, + "modLoaderGameVersionStatus": 1, + "modLoaderGameVersionTypeStatus": 1, + "mcGameVersionId": 0, + "mcGameVersionTypeId": 0, + "mcGameVersionStatus": 1, + "mcGameVersionTypeStatus": 1, + "installProfileJson": "string" +} +``` + +**Properties** + +| Name | Type | Description | +|------|------|-------------| +| id | integer(int32) | none | +| gameVersionId | integer(int32) | none | +| minecraftGameVersionId | integer(int32) | none | +| forgeVersion | string | none | +| name | string | none | +| type | ModLoaderType | 0=Any, 1=Forge, 2=Cauldron, 3=LiteLoader, 4=Fabric, 5=Quilt, 6=NeoForge | +| downloadUrl | string | none | +| filename | string | none | +| installMethod | ModLoaderInstallMethod | 1=ForgeInstaller, 2=ForgeJarInstall, 3=ForgeInstaller_v2, 4=FabricInstaller, 5=QuiltInstaller, 6=NeoForgeInstaller | +| latest | boolean | none | +| recommended | boolean | none | +| approved | boolean | none | +| dateModified | string(date-time) | none | +| mavenVersionString | string | none | +| versionJson | string | none | +| librariesInstallLocation | string | none | +| minecraftVersion | string | none | +| additionalFilesJson | string | none | +| modLoaderGameVersionId | integer(int32) | none | +| modLoaderGameVersionTypeId | integer(int32) | none | +| modLoaderGameVersionStatus | GameVersionStatus | 1=Approved, 2=Deleted, 3=New | +| modLoaderGameVersionTypeStatus | GameVersionTypeStatus | 1=Normal, 2=Deleted | +| mcGameVersionId | integer(int32) | none | +| mcGameVersionTypeId | integer(int32) | none | +| mcGameVersionStatus | GameVersionStatus | 1=Approved, 2=Deleted, 3=New | +| mcGameVersionTypeStatus | GameVersionTypeStatus | 1=Normal, 2=Deleted | +| installProfileJson | string | none | + +--- + +### Mod + +```json +{ + "id": 0, + "gameId": 0, + "name": "string", + "slug": "string", + "links": { + "websiteUrl": "string", + "wikiUrl": "string", + "issuesUrl": "string", + "sourceUrl": "string" + }, + "summary": "string", + "status": 1, + "downloadCount": 0, + "isFeatured": true, + "primaryCategoryId": 0, + "categories": [ { "...Category..." } ], + "classId": 0, + "authors": [ { "...ModAuthor..." } ], + "logo": { "...ModAsset..." }, + "screenshots": [ { "...ModAsset..." } ], + "mainFileId": 0, + "latestFiles": [ { "...File..." } ], + "latestFilesIndexes": [ { "...FileIndex..." } ], + "latestEarlyAccessFilesIndexes": [ { "...FileIndex..." } ], + "dateCreated": "2019-08-24T14:15:22Z", + "dateModified": "2019-08-24T14:15:22Z", + "dateReleased": "2019-08-24T14:15:22Z", + "allowModDistribution": true, + "gamePopularityRank": 0, + "isAvailable": true, + "thumbsUpCount": 0, + "rating": 0 +} +``` + +**Properties** + +| Name | Type | Description | +|------|------|-------------| +| id | integer(int32) | The mod id | +| gameId | integer(int32) | The game id this mod is for | +| name | string | The name of the mod | +| slug | string | The mod slug that would appear in the URL | +| links | ModLinks | Relevant links for the mod such as Issue tracker and Wiki | +| summary | string | Mod summary | +| status | ModStatus | Current mod status | +| downloadCount | integer(int64) | Number of downloads for the mod | +| isFeatured | boolean | Whether the mod is included in the featured mods list | +| primaryCategoryId | integer(int32) | The main category of the mod | +| categories | [Category] | List of categories that this mod is related to | +| classId | integer(int32)\|null | The class id this mod belongs to | +| authors | [ModAuthor] | List of the mod's authors | +| logo | ModAsset | The mod's logo asset | +| screenshots | [ModAsset] | List of screenshots assets | +| mainFileId | integer(int32) | The id of the main file of the mod | +| latestFiles | [File] | List of latest files of the mod | +| latestFilesIndexes | [FileIndex] | List of file related details for the latest files | +| latestEarlyAccessFilesIndexes | [FileIndex] | List of file related details for the latest early access files | +| dateCreated | string(date-time) | The creation date of the mod | +| dateModified | string(date-time) | The last time the mod was modified | +| dateReleased | string(date-time) | The release date of the mod | +| allowModDistribution | boolean\|null | Is mod allowed to be distributed | +| gamePopularityRank | integer(int32) | The mod popularity rank for the game | +| isAvailable | boolean | Is the mod available for search | +| thumbsUpCount | integer(int32) | The mod's thumbs up count | +| rating | number(decimal)\|null | The mod's Rating | + +--- + +### ModAsset + +```json +{ + "id": 0, + "modId": 0, + "title": "string", + "description": "string", + "thumbnailUrl": "string", + "url": "string" +} +``` + +**Properties** + +| Name | Type | Description | +|------|------|-------------| +| id | integer(int32) | none | +| modId | integer(int32) | none | +| title | string | none | +| description | string | none | +| thumbnailUrl | string | none | +| url | string | none | + +--- + +### ModAuthor + +```json +{ + "id": 0, + "name": "string", + "url": "string" +} +``` + +**Properties** + +| Name | Type | Description | +|------|------|-------------| +| id | integer(int32) | none | +| name | string | none | +| url | string | none | + +--- + +### ModLinks + +```json +{ + "websiteUrl": "string", + "wikiUrl": "string", + "issuesUrl": "string", + "sourceUrl": "string" +} +``` + +**Properties** + +| Name | Type | Description | +|------|------|-------------| +| websiteUrl | string | none | +| wikiUrl | string | none | +| issuesUrl | string | none | +| sourceUrl | string | none | + +--- + +### ModLoaderInstallMethod + +``` +1 +``` + +Possible values: 1=ForgeInstaller, 2=ForgeJarInstall, 3=ForgeInstaller_v2, 4=FabricInstaller, 5=QuiltInstaller, 6=NeoForgeInstaller + +--- + +### ModLoaderType + +``` +0 +``` + +Possible values: 0=Any, 1=Forge, 2=Cauldron, 3=LiteLoader, 4=Fabric, 5=Quilt, 6=NeoForge + +--- + +### ModsSearchSortField + +``` +1 +``` + +Possible values: 1=Featured, 2=Popularity, 3=LastUpdated, 4=Name, 5=Author, 6=TotalDownloads, 7=Category, 8=GameVersion, 9=EarlyAccess, 10=FeaturedReleased, 11=ReleasedDate, 12=Rating + +--- + +### ModStatus + +``` +1 +``` + +Possible values: 1=New, 2=ChangesRequired, 3=UnderSoftReview, 4=Approved, 5=Rejected, 6=ChangesMade, 7=Inactive, 8=Abandoned, 9=Deleted, 10=UnderReview + +--- + +### Pagination + +```json +{ + "index": 0, + "pageSize": 0, + "resultCount": 0, + "totalCount": 0 +} +``` + +**Properties** + +| Name | Type | Description | +|------|------|-------------| +| index | integer(int32) | A zero based index of the first item that is included in the response | +| pageSize | integer(int32) | The requested number of items to be included in the response | +| resultCount | integer(int32) | The actual number of items that were included in the response | +| totalCount | integer(int64) | The total number of items available by the request | + +--- + +### Search Mods Response + +```json +{ + "data": [ { "...Mod..." } ], + "pagination": { "...Pagination..." } +} +``` + +**Properties** + +| Name | Type | Description | +|------|------|-------------| +| data | [Mod] | The response data | +| pagination | Pagination | The response pagination information | + +--- + +### SortableGameVersion + +```json +{ + "gameVersionName": "string", + "gameVersionPadded": "string", + "gameVersion": "string", + "gameVersionReleaseDate": "2019-08-24T14:15:22Z", + "gameVersionTypeId": 0 +} +``` + +**Properties** + +| Name | Type | Description | +|------|------|-------------| +| gameVersionName | string | Original version name (e.g. 1.5b) | +| gameVersionPadded | string | Used for sorting (e.g. 0000000001.0000000005) | +| gameVersion | string | game version clean name (e.g. 1.5) | +| gameVersionReleaseDate | string(date-time) | Game version release date | +| gameVersionTypeId | integer(int32)\|null | Game version type id | + +--- + +### SortOrder + +``` +"asc" +``` + +Possible values: `asc`, `desc` + +--- + +### String Response + +```json +{ + "data": "string" +} +``` + +**Properties** + +| Name | Type | Description | +|------|------|-------------| +| data | string | The response data | + +--- + +## CurseForge for Studios Terms of Use + +Overwolf Ltd. ("**Overwolf**") makes available the Curseforge platform for user-generated content ("**Curseforge**"), as well as the CurseForge for Studios suite of tools for game developers to integrate in-game support for user-generated content ("**UGC**") in their proprietary game software (the "**Game**"), which may include APIs, plugins and user interfaces (collectively, "**CurseForge for Studios**"). "**You**" or "**Developer**" means anyone who desires to use the CurseForge for Studios Platform. + +Please read these Terms of Use (this "**Agreement**") carefully. All use of CurseForge for Studios is subject to this Agreement. By using or downloading CurseForge for Studios or clicking "accept", you signify your assent to both this Agreement and our [Privacy Policy](https://www.overwolf.com/legal/privacy/). If you do not agree to any terms or conditions of this Agreement, please do not use CurseForge for Studios. + +1. **CurseForge for Studios Platform** — Subject to the terms and conditions hereof, Overwolf grants Developer a limited, non-exclusive license to use the CurseForge for Studios Platform, including all code made available by Overwolf as part of the CurseForge for Studios Platform, and any accompanying documentation files solely for the purpose of integrating in-game UGC support for Developer's game. + +2. **Restrictions** — Developer shall use CurseForge for Studios solely for the Purpose. Developer will not (a) rent, lease, modify, copy, loan, transfer, sublicense, distribute or create derivative works; (b) reverse engineer, decompile, translate, adapt, or disassemble; (c) attempt to disable or circumvent any security mechanism; or (d) remove or obscure any copyright or other notices. + +3. **Games and User-Content** — Developer retains sole ownership of the Game, but provides Overwolf and users of Curseforge with a non-exclusive, royalty-free, worldwide license to the name, logo and trademarks of the Game and Developer for the purpose of indicating the availability of the Game and UGC. + +4. **Payment** — Overwolf currently provides CurseForge for Studios at no charge. Payment and revenue share obligations may be set forth in a separate written agreement. + +5. **The Modding Community** — Overwolf alone will be authorized to manage and moderate the user community and any UGC on the Overwolf platform. + +6. **Moderation Privileges** — If Overwolf consents to Developer exercising moderation privileges, Developer must follow agreed guidelines and applicable legal obligations. + +7. **Representations and Warranties** — Developer warrants that the Game does not infringe third-party intellectual property rights, contain malicious code, or impose open source obligations on Overwolf. + +8. **Indemnification** — Developer shall defend, indemnify and hold harmless Overwolf from any claims arising from the Game, UGC, or Developer's breach of warranties. + +9. **Disclaimer; Limitation of Liability** — CURSEFORGE FOR STUDIOS AND ALL DOCUMENTATION ARE PROVIDED "AS-IS". OVERWOLF EXPRESSLY DISCLAIMS ALL WARRANTIES. + +10. **Confidentiality** — All non-public information regarding CurseForge for Studios or Documentation is proprietary and confidential information of Overwolf. + +11. **Term and Termination** — The Agreement is in effect from the date accepted and continues for an initial term of three years, auto-renewing for subsequent three-year terms. + +12. **Miscellaneous** — This Agreement constitutes the entire agreement between the parties. + +13. **Dispute Resolution; Governing Law** — This Agreement shall be construed in accordance with the laws of the State of Delaware. Any disputes shall be resolved through binding arbitration in the State of Delaware. diff --git a/modrinth-api.md b/modrinth-api.md new file mode 100644 index 0000000000..e18c6fde09 --- /dev/null +++ b/modrinth-api.md @@ -0,0 +1,1885 @@ +# Modrinth API Documentation + +# Overview + +## Labrinth (v2.7.0/366f528) + +This documentation doesn't provide a way to test our API. In order to facilitate testing, we recommend the following tools: + +- cURL (recommended, command-line) +- ReqBIN (recommended, online) +- Postman +- Insomnia +- Your web browser, if you don't need to send headers or a request body + +Once you have a working client, you can test that it works by making a GET request to https://staging-api.modrinth.com/: + +``` +{ + "about": "Welcome traveler!", + "documentation": "https://docs.modrinth.com", + "name": "modrinth-labrinth", + "version": "2.7.0" +} +``` + +If you got a response similar to the one above, you can use the Modrinth API! +When you want to go live using the production API, use api.modrinth.com instead of staging-api.modrinth.com. + +## Authentication + +This API has two options for authentication: personal access tokens and OAuth2. +All tokens are tied to a Modrinth user and use the Authorization header of the request. + +Example: + +``` +Authorization: mrp_RNtLRSPmGj2pd1v1ubi52nX7TJJM9sznrmwhAuj511oe4t1jAqAQ3D6Wc8Ic +``` + +You do not need a token for most requests. Generally speaking, only the following types of requests require a token: + +- those which create data (such as version creation) +- those which modify data (such as editing a project) +- those which access private data (such as draft projects, notifications, emails, and payout data) + +Each request requiring authentication has a certain scope. For example, to view the email of the user being requested, the token must have the USER_READ_EMAIL scope. +You can find the list of available scopes on GitHub. Making a request with an invalid scope will return a 401 error. + +Please note that certain scopes and requests cannot be completed with a personal access token or using OAuth. +For example, deleting a user account can only be done through Modrinth's frontend. + +A detailed guide on OAuth has been published in Modrinth's technical documentation. + +### Personal access tokens + +Personal access tokens (PATs) can be generated in from the user settings. + +### GitHub tokens + +For backwards compatibility purposes, some types of GitHub tokens also work for authenticating a user with Modrinth's API, granting all scopes. +We urge any application still using GitHub tokens to start using personal access tokens for security and reliability purposes. +GitHub tokens will cease to function to authenticate with Modrinth's API as soon as version 3 of the API is made generally available. + +## Cross-Origin Resource Sharing + +This API features Cross-Origin Resource Sharing (CORS) implemented in compliance with the W3C spec. +This allows for cross-domain communication from the browser. +All responses have a wildcard same-origin which makes them completely public and accessible to everyone, including any code on any site. + +## Identifiers + +The majority of items you can interact with in the API have a unique eight-digit base62 ID. +Projects, versions, users, threads, teams, and reports all use this same way of identifying themselves. +Version files use the sha1 or sha512 file hashes as identifiers. + +Each project and user has a friendlier way of identifying them; slugs and usernames, respectively. +While unique IDs are constant, slugs and usernames can change at any moment. +If you want to store something in the long term, it is recommended to use the unique ID. + +## Ratelimits + +The API has a ratelimit defined per IP. Limits and remaining amounts are given in the response headers. + +- X-Ratelimit-Limit: the maximum number of requests that can be made in a minute +- X-Ratelimit-Remaining: the number of requests remaining in the current ratelimit window +- X-Ratelimit-Reset: the time in seconds until the ratelimit window resets + +Ratelimits are the same no matter whether you use a token or not. +The ratelimit is currently 300 requests per minute. If you have a use case requiring a higher limit, please contact us. + +## User Agents + +To access the Modrinth API, you must use provide a uniquely-identifying User-Agent header. +Providing a user agent that only identifies your HTTP client library (such as "okhttp/4.9.3") increases the likelihood that we will block your traffic. +It is recommended, but not required, to include contact information in your user agent. +This allows us to contact you if we would like a change in your application's behavior without having to block your traffic. + +- Bad: User-Agent: okhttp/4.9.3 +- Good: User-Agent: project_name +- Better: User-Agent: github_username/project_name/1.56.0 +- Best: User-Agent: github_username/project_name/1.56.0 (launcher.com) or User-Agent: github_username/project_name/1.56.0 (contact@launcher.com) + +## Versioning + +Modrinth follows a simple pattern for its API versioning. +In the event of a breaking API change, the API version in the URL path is bumped, and migration steps will be published below. + +When an API is no longer the current one, it will immediately be considered deprecated. +No more support will be provided for API versions older than the current one. +It will be kept for some time, but this amount of time is not certain. + +We will exercise various tactics to get people to update their implementation of our API. +One example is by adding something like STOP USING THIS API to various data returned by the API. + +Once an API version is completely deprecated, it will permanently return a 410 error. +Please ensure your application handles these 410 errors. + +### Migrations + +Inside the following spoiler, you will be able to find all changes between versions of the Modrinth API, accompanied by tips and a guide to migrate applications to newer versions. + +Here, you can also find changes for Minotaur, Modrinth's official Gradle plugin. Major versions of Minotaur directly correspond to major versions of the Modrinth API. + +These bullet points cover most changes in the v2 API, but please note that fields containing mod in most contexts have been shifted to project. For example, in the search route, the field mod_id was renamed to project_id. + +- The search route has been moved from /api/v1/mod to /v2/search +- New project fields: project_type (may be mod or modpack), moderation_message (which has a message and body), gallery +- New search facet: project_type +- Alphabetical sort removed (it didn't work and is not possible due to limits in MeiliSearch) +- New search fields: project_type, gallery +- The gallery field is an array of URLs to images that are part of the project's gallery +- The gallery is a new feature which allows the user to upload images showcasing their mod to the CDN which will be displayed on their mod page +- Internal change: Any project file uploaded to Modrinth is now validated to make sure it's a valid Minecraft mod, Modpack, etc. +- In project creation, projects may not upload a mod with no versions to review, however they can be saved as a draft +- Donation URLs have been enabled +- New project status: archived. Projects with this status do not appear in search +- Tags (such as categories, loaders) now have icons (SVGs) and specific project types attached +- Dependencies have been wiped and replaced with a new system +- Notifications now have a type field, such as project_update + +Along with this, project subroutes (such as /v2/project/{id}/version) now allow the slug to be used as the ID. This is also the case with user routes. + +Minotaur 2.x introduced a few breaking changes to how your buildscript is formatted. + +First, instead of registering your own publishModrinth task, Minotaur now automatically creates a modrinth task. As such, you can replace the task publishModrinth(type: TaskModrinthUpload) { line with just modrinth {. + +To declare supported Minecraft versions and mod loaders, the gameVersions and loaders arrays must now be used. The syntax for these are pretty self-explanatory. + +Instead of using releaseType, you must now use versionType. This was actually changed in v1.2.0, but very few buildscripts have moved on from v1.1.0. + +Dependencies have been changed to a special DSL. Create a dependencies block within the modrinth block, and then use scope.type("project/version"). For example, required.project("fabric-api") adds a required project dependency on Fabric API. + +You may now use the slug anywhere that a project ID was previously required. + +- Modrinth Support: https://support.modrinth.com - support@modrinth.com +- Terms of Service +- OpenAPI version: 3.0.0 + +## Authentication + +### TokenAuth + +Security scheme type: apiKey + +Header parameter name: Authorization + +--- + +## Projects + +### Tags + +## projects + +Projects are what Modrinth is centered around, be it mods, modpacks, resource packs, etc. + +# Search projects + +- Production server +- Staging server + +## Parameters + +### Query Parameters + +- **query** (string): stringgravestonesThe query to search for +- **facets** (string): stringFacets are an essential concept for understanding how to filter out results. These are the most commonly used facet types: project_type categories (loaders are lumped in with categories in search) +- **index** (string): string default: relevance Allowed values: relevance downloads follows newest updated downloadsThe sorting method used for sorting search results +- **offset** (integer): integer20The offset into the search. Skips this number of results +- **limit** (integer): integer default: 10 <= 100 20The number of results returned by the search + +## Responses + +### 200 + +Expected response to a valid request + +- **hits** (Array): The list of resultsArray object slug The slug of a project, used for vanity URLs. Regex: ^[\\w!@$()`.+,"\-']{3,64}$stringmy_project title The title or name of the projectstringMy Project description A +- **slug** (string): The slug of a project, used for vanity URLs. Regex: ^[\\w!@$()`.+,"\-']{3,64}$stringmy_project +- **title** (string): The title or name of the projectstringMy Project +- **description** (string): A short description of the projectstringA short description +- **categories** (Array): A list of the categories that the project hasArray[ "technology", "adventure", "fabric"] +- **client_side** (string): The client side support of the projectstringAllowed values: required optional unsupported unknown required +- **server_side** (string): The server side support of the projectstringAllowed values: required optional unsupported unknown optional +- **project_type** (string): The project type of the projectstringAllowed values: mod modpack resourcepack shader mod +- **downloads** (integer): The total number of downloads of the projectinteger +- **icon_url** (string): The URL of the project's iconstring nullable https://cdn.modrinth.com/data/AABBCCDD/b46513nd83hb4792a9a0e1fn28fgi6090c1842639.png +- **color** (integer): The RGB color of the project, automatically generated from the project iconinteger nullable 8703084 +- **thread_id** (string): The ID of the moderation thread associated with this projectstringTTUUVVWW +- **monetization_status** (string): stringAllowed values: monetized demonetized force-demonetized +- **project_id** (string): The ID of the projectstringAABBCCDD +- **author** (string): The username of the project's authorstringmy_user +- **display_categories** (Array): A list of the categories that the project has which are not secondaryArray[ "technology", "fabric"] +- **versions** (Array): A list of the minecraft versions supported by the projectArray[ "1.8", "1.8.9"] +- **follows** (integer): The total number of users following the projectinteger +- **date_created** (string): The date the project was added to searchstring format: ISO-8601 +- **date_modified** (string): The date the project was last modifiedstring format: ISO-8601 +- **latest_version** (string): The latest version of minecraft that this project supportsstring1.8.9 +- **license** (string): The SPDX license ID of a projectstringMIT +- **gallery** (Array): All gallery images attached to the projectArray[ "https://cdn.modrinth.com/data/AABBCCDD/images/009b7d8d6e8bf04968a29421117c59b3efe2351a.png", "https://cdn.modrinth.com/data/AABBCCDD/images/c2 +- **featured_gallery** (string): The featured gallery image of the projectstring nullable +- **offset** (integer): The number of results that were skipped by the queryinteger0 +- **limit** (integer): The number of results that were returned by the queryinteger10 +- **total_hits** (integer): The total number of results that match the queryinteger10 + +### 400 + +Request was invalid, see given error + +- **error** (string): The name of the errorstringinvalid_input +- **description** (string): The contents of the errorstringError while parsing multipart payload + +# Get a project + +- Production server +- Staging server + +## Parameters + +### Path Parameters + +- **id|slug** (string): string[ "AABBCCDD", "my_project"]The ID or slug of the project + +## Responses + +### 200 + +Expected response to a valid request + +- **slug** (string): The slug of a project, used for vanity URLs. Regex: ^[\\w!@$()`.+,"\-']{3,64}$stringmy_project +- **title** (string): The title or name of the projectstringMy Project +- **description** (string): A short description of the projectstringA short description +- **categories** (Array): A list of the categories that the project hasArray[ "technology", "adventure", "fabric"] +- **client_side** (string): The client side support of the projectstringAllowed values: required optional unsupported unknown required +- **server_side** (string): The server side support of the projectstringAllowed values: required optional unsupported unknown optional +- **body** (string): A long form description of the projectstringA long body describing my project in detail +- **status** (string): The status of the projectstringAllowed values: approved archived rejected draft unlisted processing withheld scheduled private unknown approved +- **requested_status** (string): The requested status when submitting for review or scheduling the project for releasestring nullable Allowed values: approved archived unlisted private draft +- **additional_categories** (Array): A list of categories which are searchable but non-primaryArray[ "technology", "adventure", "fabric"] +- **issues_url** (string): An optional link to where to submit bugs or issues with the projectstring nullable https://github.com/my_user/my_project/issues +- **source_url** (string): An optional link to the source code of the projectstring nullable https://github.com/my_user/my_project +- **wiki_url** (string): An optional link to the project's wiki page or other relevant informationstring nullable https://github.com/my_user/my_project/wiki +- **discord_url** (string): An optional invite link to the project's discordstring nullable https://discord.gg/AaBbCcDd +- **donation_urls** (Array): A list of donation links for the projectArray object id The ID of the donation platformstringpatreon platform The donation platform this link is tostringPatreon url The URL of the donation pla +- **id** (string): The ID of the donation platformstringpatreon +- **platform** (string): The donation platform this link is tostringPatreon +- **url** (string): The URL of the donation platform and userstringhttps://www.patreon.com/my_user +- **slug** (string): The slug of a project, used for vanity URLs. Regex: ^[\\w!@$()`.+,"\-']{3,64}$stringmy_project +- **title** (string): The title or name of the projectstringMy Project +- **description** (string): A short description of the projectstringA short description +- **categories** (Array): A list of the categories that the project hasArray[ "technology", "adventure", "fabric"] +- **client_side** (string): The client side support of the projectstringAllowed values: required optional unsupported unknown required +- **server_side** (string): The server side support of the projectstringAllowed values: required optional unsupported unknown optional +- **project_type** (string): The project type of the projectstringAllowed values: mod modpack resourcepack shader mod +- **downloads** (integer): The total number of downloads of the projectinteger +- **icon_url** (string): The URL of the project's iconstring nullable https://cdn.modrinth.com/data/AABBCCDD/b46513nd83hb4792a9a0e1fn28fgi6090c1842639.png +- **color** (integer): The RGB color of the project, automatically generated from the project iconinteger nullable 8703084 +- **thread_id** (string): The ID of the moderation thread associated with this projectstringTTUUVVWW +- **monetization_status** (string): stringAllowed values: monetized demonetized force-demonetized +- **id** (string): The ID of the project, encoded as a base62 stringstringAABBCCDD +- **team** (string): The ID of the team that has ownership of this projectstringMMNNOOPP +- **body_url** (string): The link to the long description of the project. Always null, only kept for legacy compatibility.string nullable +- **moderator_message** (object): A message that a moderator sent regarding the project object message The message that a moderator has left for the projectstring body The longer body of the message that a moderator has left for the p +- **message** (string): The message that a moderator has left for the projectstring +- **body** (string): The longer body of the message that a moderator has left for the projectstring nullable +- **published** (string): The date the project was publishedstring format: ISO-8601 +- **updated** (string): The date the project was last updatedstring format: ISO-8601 +- **approved** (string): The date the project's status was set to an approved statusstring format: ISO-8601 nullable +- **queued** (string): The date the project's status was submitted to moderators for reviewstring format: ISO-8601 nullable +- **followers** (integer): The total number of users following the projectinteger +- **license** (object): The license of the project object id The SPDX license ID of a projectstringLGPL-3.0-or-later name The long name of a licensestringGNU Lesser General Public License v3 or later url The URL to this lice +- **id** (string): The SPDX license ID of a projectstringLGPL-3.0-or-later +- **name** (string): The long name of a licensestringGNU Lesser General Public License v3 or later +- **url** (string): The URL to this licensestring nullable +- **versions** (Array): A list of the version IDs of the project (will never be empty unless draft status)Array[ "IIJJKKLL", "QQRRSSTT"] +- **game_versions** (Array): A list of all of the game versions supported by the projectArray[ "1.19", "1.19.1", "1.19.2", "1.19.3"] +- **loaders** (Array): A list of all of the loaders supported by the projectArray[ "forge", "fabric", "quilt"] +- **gallery** (Array): A list of images that have been uploaded to the project's galleryArray object url required The URL of the gallery imagestringhttps://cdn.modrinth.com/data/AABBCCDD/images/009b7d8d6e8bf04968a29 +- **url** (string): The URL of the gallery imagestringhttps://cdn.modrinth.com/data/AABBCCDD/images/009b7d8d6e8bf04968a29421117c59b3efe2351a.png +- **featured** (boolean): Whether the image is featured in the gallerybooleantrue +- **title** (string): The title of the gallery imagestring nullable My awesome screenshot! +- **description** (string): The description of the gallery imagestring nullable This awesome screenshot shows all of the blocks in my mod! +- **created** (string): The date and time the gallery image was createdstring format: ISO-8601 +- **ordering** (integer): The order of the gallery image. Gallery images are sorted by this field and then alphabetically by title.integer0 + +### 404 + +The requested item(s) were not found or no authorization to access the requested item(s) + +# Delete a project + +- Production server +- Staging server + +## Authorizations + +- TokenAuth PROJECT_DELETE + +## Parameters + +### Path Parameters + +- **id|slug** (string): string[ "AABBCCDD", "my_project"]The ID or slug of the project + +## Responses + +### 204 + +Expected response to a valid request + +### 400 + +Request was invalid, see given error + +- **error** (string): The name of the errorstringinvalid_input +- **description** (string): The contents of the errorstringError while parsing multipart payload + +### 401 + +Incorrect token scopes or no authorization to access the requested item(s) + +- **error** (string): The name of the errorstringunauthorized +- **description** (string): The contents of the errorstringAuthentication Error: Invalid Authentication Credentials + +# Modify a project + +- Production server +- Staging server + +## Authorizations + +- TokenAuth PROJECT_WRITE + +## Parameters + +### Path Parameters + +- **id|slug** (string): string[ "AABBCCDD", "my_project"]The ID or slug of the project + +## Request Body + +Modified project fields + +- **slug** (string): The slug of a project, used for vanity URLs. Regex: ^[\\w!@$()`.+,"\-']{3,64}$stringmy_project +- **title** (string): The title or name of the projectstringMy Project +- **description** (string): A short description of the projectstringA short description +- **categories** (Array): A list of the categories that the project hasArray[ "technology", "adventure", "fabric"] +- **client_side** (string): The client side support of the projectstringAllowed values: required optional unsupported unknown required +- **server_side** (string): The server side support of the projectstringAllowed values: required optional unsupported unknown optional +- **body** (string): A long form description of the projectstringA long body describing my project in detail +- **status** (string): The status of the projectstringAllowed values: approved archived rejected draft unlisted processing withheld scheduled private unknown approved +- **requested_status** (string): The requested status when submitting for review or scheduling the project for releasestring nullable Allowed values: approved archived unlisted private draft +- **additional_categories** (Array): A list of categories which are searchable but non-primaryArray[ "technology", "adventure", "fabric"] +- **issues_url** (string): An optional link to where to submit bugs or issues with the projectstring nullable https://github.com/my_user/my_project/issues +- **source_url** (string): An optional link to the source code of the projectstring nullable https://github.com/my_user/my_project +- **wiki_url** (string): An optional link to the project's wiki page or other relevant informationstring nullable https://github.com/my_user/my_project/wiki +- **discord_url** (string): An optional invite link to the project's discordstring nullable https://discord.gg/AaBbCcDd +- **donation_urls** (Array): A list of donation links for the projectArray object id The ID of the donation platformstringpatreon platform The donation platform this link is tostringPatreon url The URL of the donation pla +- **id** (string): The ID of the donation platformstringpatreon +- **platform** (string): The donation platform this link is tostringPatreon +- **url** (string): The URL of the donation platform and userstringhttps://www.patreon.com/my_user +- **license_id** (string): The SPDX license ID of a projectstringLGPL-3.0-or-later +- **license_url** (string): The URL to this licensestring nullable +- **moderation_message** (string): The title of the moderators' message for the projectstring nullable +- **moderation_message_body** (string): The body of the moderators' message for the projectstring nullable + +## Responses + +### 204 + +Expected response to a valid request + +### 401 + +Incorrect token scopes or no authorization to access the requested item(s) + +- **error** (string): The name of the errorstringunauthorized +- **description** (string): The contents of the errorstringAuthentication Error: Invalid Authentication Credentials + +### 404 + +The requested item(s) were not found or no authorization to access the requested item(s) + +--- + +## Projects (continued) + +**Get multiple projects** + +- Production server, Staging server + +### Parameters + +#### Query Parameters +- **ids** (string): string["AABBCCDD", "EEFFGGHH"]The IDs and/or slugs of the projects + +### Responses +#### 200 +Expected response to a valid request +- **slug** (string): The slug of a project, used for vanity URLs +- **title** (string): The title or name of the project +- **description** (string): A short description of the project +- **categories** (Array): A list of the categories that the project has +- **client_side** (string): The client side support of the project +- **server_side** (string): The server side support of the project +- **body** (string): A long form description of the project +- **status** (string): The status of the project +- **requested_status** (string): The requested status when submitting for review +- **additional_categories** (Array): A list of categories which are searchable but non-primary +- **issues_url** (string): An optional link to where to submit bugs or issues +- **source_url** (string): An optional link to the source code +- **wiki_url** (string): An optional link to the project's wiki page +- **discord_url** (string): An optional invite link to the project's discord +- **donation_urls** (Array): A list of donation links for the project +- **project_type** (string): The project type +- **downloads** (integer): The total number of downloads +- **icon_url** (string): The URL of the project's icon +- **color** (integer): The RGB color of the project +- **thread_id** (string): The ID of the moderation thread +- **monetization_status** (string): The monetization status +- **id** (string): The ID of the project +- **team** (string): The ID of the team +- **moderator_message** (object): A message that a moderator sent +- **published** (string): The date the project was published +- **updated** (string): The date the project was last updated +- **followers** (integer): The total number of users following the project +- **license** (object): The license of the project +- **versions** (Array): A list of the version IDs +- **game_versions** (Array): A list of all of the game versions supported +- **loaders** (Array): A list of all of the loaders supported +- **gallery** (Array): A list of images in the project's gallery + +--- + +**Bulk-edit multiple projects** + +- Production server, Staging server + +### Authorizations +- TokenAuth PROJECT_WRITE + +### Parameters +#### Query Parameters +- **ids** (string): The IDs and/or slugs of the projects + +### Request Body +Fields to edit on all projects specified +- **categories** (Array): Set all categories +- **add_categories** (Array): Add categories +- **remove_categories** (Array): Remove categories +- **additional_categories** (Array): Set all additional categories +- **add_additional_categories** (Array): Add additional categories +- **remove_additional_categories** (Array): Remove additional categories +- **donation_urls** (Array): Set all donation links +- **add_donation_urls** (Array): Add donation links +- **remove_donation_urls** (Array): Remove donation links +- **issues_url** (string): An optional link to submit bugs or issues +- **source_url** (string): An optional link to the source code +- **wiki_url** (string): An optional link to the wiki page +- **discord_url** (string): An optional invite link to the discord + +### Responses +#### 204 Expected response to a valid request +#### 400 Request was invalid +#### 401 Incorrect token scopes + +--- + +**Get a list of random projects** + +- Production server, Staging server + +### Parameters +#### Query Parameters +- **count** (integer): <= 100, The number of random projects to return + +### Responses +#### 200 +Expected response to a valid request +Return fields same as Get a project response +#### 400 Request was invalid + +--- + +**Create a project** + +- Production server, Staging server + +### Authorizations +- TokenAuth PROJECT_CREATE + +### Request Body +New project (multipart form data) +- **data** (object): Contains slug, title, description, categories, client_side, server_side, body, status, requested_status, additional_categories, issues_url, source_url, wiki_url, discord_url, donation_urls, license_id, license_url, project_type, initial_versions +- **icon** (string): Project icon file (binary, image formats) + +### Responses +#### 200 Expected response to a valid request +#### 400 Request was invalid +#### 401 Incorrect token scopes + +--- + +**Delete project's icon** + +- Production server, Staging server + +### Authorizations +- TokenAuth PROJECT_WRITE + +### Parameters +#### Path Parameters +- **id|slug** (string): The ID or slug of the project + +### Responses +#### 204 Expected response to a valid request +#### 400 Request was invalid +#### 401 Incorrect token scopes + +--- + +**Change project's icon** + +- Production server, Staging server + +The new icon may be up to 256KiB in size. + +### Authorizations +- TokenAuth PROJECT_WRITE + +### Parameters +#### Path Parameters +- **id|slug** (string): The ID or slug of the project + +#### Query Parameters +- **ext** (string): Image extension (png, jpg, jpeg, bmp, gif, webp, svg, svgz, rgb) + +### Responses +#### 204 Expected response to a valid request +#### 400 Request was invalid +#### 401 Incorrect token scopes + +--- + +**Check project slug/ID validity** + +- Production server, Staging server + +### Parameters +#### Path Parameters +- **id|slug** (string): The ID or slug of the project + +### Responses +#### 200 Expected response to a valid request (returns project ID) +#### 404 Not found + +--- + +**Add a gallery image** + +- Production server, Staging server + +Modrinth allows you to upload files of up to 5MiB to a project's gallery. + +### Authorizations +- TokenAuth PROJECT_WRITE + +### Parameters +#### Path Parameters +- **id|slug** (string): The ID or slug of the project +#### Query Parameters +- **ext** (string): Image extension +- **featured** (boolean): Whether an image is featured +- **title** (string): Title of the image +- **description** (string): Description of the image +- **ordering** (integer): Ordering of the image + +### Responses +#### 204 Expected response +#### 400 Request was invalid +#### 401 Incorrect token scopes +#### 404 Not found + +--- + +**Delete a gallery image** + +- Production server, Staging server + +### Authorizations +- TokenAuth PROJECT_WRITE + +### Parameters +#### Path Parameters +- **id|slug** (string): The ID or slug of the project +#### Query Parameters +- **url** (string): URL link of the image to delete + +### Responses +#### 204 Expected response +#### 400 Request was invalid +#### 401 Incorrect token scopes + +--- + +**Modify a gallery image** + +- Production server, Staging server + +### Authorizations +- TokenAuth PROJECT_WRITE + +### Parameters +#### Path Parameters +- **id|slug** (string): The ID or slug of the project +#### Query Parameters +- **url** (string): URL link of the image to modify +- **featured** (boolean): Whether the image is featured +- **title** (string): New title of the image +- **description** (string): New description of the image +- **ordering** (integer): New ordering of the image + +### Responses +#### 204 Expected response +#### 401 Incorrect token scopes +#### 404 Not found + +--- + +**Get all of a project's dependencies** + +- Production server, Staging server + +### Parameters +#### Path Parameters +- **id|slug** (string): The ID or slug of the project + +### Responses +#### 200 Expected response to a valid request +Returns projects and versions that the project depends upon +#### 404 Not found + +--- + +**Follow a project** + +- Production server, Staging server + +### Authorizations +- TokenAuth USER_WRITE + +### Parameters +#### Path Parameters +- **id|slug** (string): The ID or slug of the project + +### Responses +#### 204 Expected response +#### 400 Request was invalid +#### 401 Incorrect token scopes + +--- + +**Unfollow a project** + +- Production server, Staging server + +### Authorizations +- TokenAuth USER_WRITE + +### Parameters +#### Path Parameters +- **id|slug** (string): The ID or slug of the project + +### Responses +#### 204 Expected response +#### 400 Request was invalid +#### 401 Incorrect token scopes + +--- + +**Schedule a project** + +- Production server, Staging server + +### Authorizations +- TokenAuth PROJECT_WRITE + +### Parameters +#### Path Parameters +- **id|slug** (string): The ID or slug of the project + +### Request Body +- **time** (string): ISO-8601 format date/time +- **requested_status** (string): The requested status when scheduling + +### Responses +#### 204 Expected response +#### 400 Request was invalid +#### 401 Incorrect token scopes + +--- + +## Versions + +### Overview + +Versions contain download links to files with additional metadata. + +--- + +**List project's versions** + +- Production server, Staging server + +### Parameters +#### Path Parameters +- **id|slug** (string): The ID or slug of the project +#### Query Parameters +- **loaders** (string): The types of loaders to filter for +- **game_versions** (string): The game versions to filter for +- **featured** (boolean): Filter for featured or non-featured versions +- **include_changelog** (boolean): Whether to include changelog field + +### Responses +#### 200 Expected response to a valid request +Returns array of version objects +- **name** (string): The name of this version +- **version_number** (string): The version number +- **changelog** (string): The changelog for this version +- **dependencies** (Array): A list of specific versions this version depends on +- **game_versions** (Array): A list of supported Minecraft versions +- **version_type** (string): The release channel (release, beta, alpha) +- **loaders** (Array): The mod loaders this version supports +- **featured** (boolean): Whether the version is featured +- **status** (string): The status (listed, archived, draft, unlisted, scheduled, unknown) +- **requested_status** (string): The requested status +- **id** (string): The version ID +- **project_id** (string): The project ID +- **author_id** (string): The author ID +- **date_published** (string): ISO-8601 date +- **downloads** (integer): Number of downloads +- **files** (Array): A list of files available for download +#### 404 Not found + +--- + +**Get a version** + +- Production server, Staging server + +### Parameters +#### Path Parameters +- **id** (string): The ID of the version + +### Responses +#### 200 Expected response to a valid request +Returns the full version object (same fields as List project's versions) +#### 404 Not found + +--- + +**Delete a version** + +- Production server, Staging server + +### Authorizations +- TokenAuth VERSION_DELETE + +### Parameters +#### Path Parameters +- **id** (string): The ID of the version + +### Responses +#### 204 Expected response +#### 401 Incorrect token scopes +#### 404 Not found + +--- + +**Modify a version** + +- Production server, Staging server + +### Authorizations +- TokenAuth VERSION_WRITE + +### Parameters +#### Path Parameters +- **id** (string): The ID of the version + +### Request Body +Modified version fields +- **name** (string): The name of this version +- **version_number** (string): The version number +- **changelog** (string): The changelog +- **dependencies** (Array): A list of dependencies +- **game_versions** (Array): Supported Minecraft versions +- **version_type** (string): Release channel +- **loaders** (Array): Mod loaders +- **featured** (boolean): Whether featured +- **status** (string): Status +- **requested_status** (string): Requested status +- **primary_file** (Array): Hash format and hash of new primary file +- **file_types** (Array): A list of file types to edit + +### Responses +#### 204 Expected response +#### 401 Incorrect token scopes +#### 404 Not found + +--- + +**Get a version given a version number or ID** + +- Production server, Staging server + +### Parameters +#### Path Parameters +- **id|slug** (string): The ID or slug of the project +- **id|number** (string): The version ID or version number + +### Responses +#### 200 Expected response to a valid request +#### 404 Not found + +--- + +**Create a version** + +- Production server, Staging server + +Accepts .mrpack, .jar, .zip, and .litemod files. Multipart request with data and file fields. + +### Authorizations +- TokenAuth VERSION_CREATE + +### Request Body +- **data** (object): Version metadata (name, version_number, changelog, dependencies, game_versions, version_type, loaders, featured, status, requested_status, project_id, file_parts, primary_file) + +### Responses +#### 200 Expected response (returns created version) +#### 400 Request was invalid +#### 401 Incorrect token scopes + +--- + +**Schedule a version** + +- Production server, Staging server + +### Authorizations +- TokenAuth VERSION_WRITE + +### Parameters +#### Path Parameters +- **id** (string): The ID of the version + +### Request Body +- **time** (string): ISO-8601 format date/time +- **requested_status** (string): The requested status + +### Responses +#### 204 Expected response +#### 400 Request was invalid +#### 401 Incorrect token scopes + +--- + +**Get multiple versions** + +- Production server, Staging server + +### Parameters +#### Query Parameters +- **ids** (string): The IDs of the versions + +### Responses +#### 200 Expected response to a valid request + +--- + +**Add files to version** + +- Production server, Staging server + +### Authorizations +- TokenAuth VERSION_WRITE + +### Parameters +#### Path Parameters +- **id** (string): The ID of the version + +### Request Body +Multipart with file data + +### Responses +#### 204 Expected response +#### 401 Incorrect token scopes +#### 404 Not found + +--- + +## Version-files + +### Overview + +Versions can contain multiple files, and these routes help manage those files. + +--- + +**Get version from hash** + +- Production server, Staging server + +### Parameters +#### Path Parameters +- **hash** (string): The hash of the file in hexadecimal +#### Query Parameters +- **algorithm** (string): The algorithm (sha1 or sha512), default: sha1 +- **multiple** (boolean): Whether to return multiple results + +### Responses +#### 200 Expected response to a valid request +Returns the version object +#### 404 Not found + +--- + +**Delete a file from its hash** + +- Production server, Staging server + +### Authorizations +- TokenAuth VERSION_WRITE + +### Parameters +#### Path Parameters +- **hash** (string): The hash of the file +#### Query Parameters +- **algorithm** (string): The algorithm (sha1 or sha512), default: sha1 +- **version_id** (string): Version ID to delete from (if multiple files share the hash) + +### Responses +#### 204 Expected response +#### 401 Incorrect token scopes +#### 404 Not found + +--- + +**Latest version of a project from a hash, loader(s), and game version(s)** + +- Production server, Staging server + +### Parameters +#### Path Parameters +- **hash** (string): The hash of the file +#### Query Parameters +- **algorithm** (string): The algorithm, default: sha1 + +### Request Body +- **loaders** (Array): Loaders to filter +- **game_versions** (Array): Game versions to filter + +### Responses +#### 200 Expected response (returns version) +#### 400 Request was invalid +#### 404 Not found + +--- + +**Get versions from hashes** + +- Production server, Staging server + +Same as /version_file/{hash} except it accepts multiple hashes. + +### Request Body +- **hashes** (Array): Array of hashes +- **algorithm** (string): The algorithm used + +### Responses +#### 200 Expected response (returns map from hashes to versions) +#### 400 Request was invalid + +--- + +**Latest versions of multiple project from hashes, loader(s), and game version(s)** + +- Production server, Staging server + +Same as /version_file/{hash}/update except it accepts multiple hashes. + +### Request Body +- **hashes** (Array): Array of hashes +- **algorithm** (string): The algorithm +- **loaders** (Array): Loaders +- **game_versions** (Array): Game versions + +### Responses +#### 200 Expected response (returns map from hashes to versions) +#### 400 Request was invalid + +--- + +## Users + +### Overview + +Users can create projects, join teams, access notifications, manage settings, and follow projects. Admins and moderators have more advanced permissions such as reviewing new projects. + +--- + +**Get a user** + +- Production server, Staging server + +### Parameters +#### Path Parameters +- **id|username** (string): The ID or username of the user + +### Responses +#### 200 Expected response to a valid request +- **username** (string): The user's username +- **name** (string): The user's display name +- **email** (string): The user's email (only if requesting own account, requires USER_READ_EMAIL scope) +- **bio** (string): A description of the user +- **payout_data** (object): Payout status data (only your own) +- **id** (string): The user's ID +- **avatar_url** (string): The user's avatar URL +- **created** (string): When the user was created +- **role** (string): The user's role (admin, moderator, developer) +- **badges** (integer): Badges applicable to this user +- **auth_providers** (Array): Authentication providers (only if requesting own account) +- **email_verified** (boolean): Whether email is verified +- **has_password** (boolean): Whether password is set +- **has_totp** (boolean): Whether TOTP 2FA is connected +#### 404 Not found + +--- + +**Modify a user** + +- Production server, Staging server + +### Authorizations +- TokenAuth USER_WRITE + +### Parameters +#### Path Parameters +- **id|username** (string): The ID or username of the user + +### Request Body +Modified user fields (username, name, email, bio, payout_data) + +### Responses +#### 204 Expected response +#### 401 Incorrect token scopes +#### 404 Not found + +--- + +**Get user from authorization header** + +- Production server, Staging server + +### Authorizations +- TokenAuth USER_READ + +### Responses +#### 200 Expected response (returns the authenticated user) +#### 401 Incorrect token scopes + +--- + +**Get multiple users** + +- Production server, Staging server + +### Parameters +#### Query Parameters +- **ids** (string): The IDs of the users + +### Responses +#### 200 Expected response to a valid request + +--- + +**Remove user's avatar** + +- Production server, Staging server + +### Authorizations +- TokenAuth USER_WRITE + +### Parameters +#### Path Parameters +- **id|username** (string): The ID or username of the user + +### Responses +#### 204 Expected response +#### 400 Request was invalid +#### 404 Not found + +--- + +**Change user's avatar** + +- Production server, Staging server + +The new avatar may be up to 2MiB in size. + +### Authorizations +- TokenAuth USER_WRITE + +### Parameters +#### Path Parameters +- **id|username** (string): The ID or username of the user + +### Responses +#### 204 Expected response +#### 400 Request was invalid +#### 404 Not found + +--- + +**Get user's projects** + +- Production server, Staging server + +### Parameters +#### Path Parameters +- **id|username** (string): The ID or username of the user + +### Responses +#### 200 Returns array of projects +#### 404 Not found + +--- + +**Get user's followed projects** + +- Production server, Staging server + +### Authorizations +- TokenAuth USER_READ + +### Parameters +#### Path Parameters +- **id|username** (string): The ID or username of the user + +### Responses +#### 200 Returns array of projects +#### 401 Incorrect token scopes +#### 404 Not found + +--- + +**Get user's payout history** + +- Production server, Staging server + +### Authorizations +- TokenAuth PAYOUTS_READ + +### Parameters +#### Path Parameters +- **id|username** (string): The ID or username of the user + +### Responses +#### 200 Expected response +- **all_time** (string): All-time balance in USD +- **last_month** (string): Amount made in previous 30 days +- **payouts** (Array): History of past transactions +#### 401 Incorrect token scopes +#### 404 Not found + +--- + +**Withdraw payout balance to PayPal or Venmo** + +- Production server, Staging server + +### Authorizations +- TokenAuth PAYOUTS_WRITE + +### Parameters +#### Path Parameters +- **id|username** (string): The ID or username of the user +#### Query Parameters +- **amount** (integer): Amount to withdraw + +### Responses +#### 204 Expected response +#### 401 Incorrect token scopes +#### 404 Not found + +--- + +## Notifications + +### Overview + +Notifications are sent to users for various reasons, including for project updates, team invites, and moderation purposes. + +--- + +**Get user's notifications** + +- Production server, Staging server + +### Authorizations +- TokenAuth NOTIFICATION_READ + +### Parameters +#### Path Parameters +- **id|username** (string): The ID or username of the user + +### Responses +#### 200 Expected response +- **id** (string): The notification ID +- **user_id** (string): The user who received the notification +- **type** (string): The type (project_update, team_invite, status_change, moderator_message) +- **title** (string): The title +- **text** (string): The body text +- **link** (string): A link to the related project or version +- **read** (boolean): Whether read +- **created** (string): When created +- **actions** (Array): A list of actions that can be performed +#### 401 Incorrect token scopes +#### 404 Not found + +--- + +**Get notification from ID** + +- Production server, Staging server + +### Authorizations +- TokenAuth NOTIFICATION_READ + +### Parameters +#### Path Parameters +- **id** (string): The ID of the notification + +### Responses +#### 200 Expected response +#### 401 Incorrect token scopes +#### 404 Not found + +--- + +**Delete notification** + +- Production server, Staging server + +### Authorizations +- TokenAuth NOTIFICATION_WRITE + +### Parameters +#### Path Parameters +- **id** (string): The ID of the notification + +### Responses +#### 204 Expected response +#### 401 Incorrect token scopes +#### 404 Not found + +--- + +**Mark notification as read** + +- Production server, Staging server + +### Authorizations +- TokenAuth NOTIFICATION_WRITE + +### Parameters +#### Path Parameters +- **id** (string): The ID of the notification + +### Responses +#### 204 Expected response +#### 401 Incorrect token scopes +#### 404 Not found + +--- + +**Get multiple notifications** + +- Production server, Staging server + +### Authorizations +- TokenAuth NOTIFICATION_READ + +### Parameters +#### Query Parameters +- **ids** (string): The IDs of the notifications + +### Responses +#### 200 Expected response +#### 401 Incorrect token scopes +#### 404 Not found + +--- + +**Delete multiple notifications** + +- Production server, Staging server + +### Authorizations +- TokenAuth NOTIFICATION_WRITE + +### Parameters +#### Query Parameters +- **ids** (string): The IDs of the notifications + +### Responses +#### 204 Expected response +#### 401 Incorrect token scopes +#### 404 Not found + +--- + +**Mark multiple notifications as read** + +- Production server, Staging server + +### Authorizations +- TokenAuth NOTIFICATION_WRITE + +### Parameters +#### Query Parameters +- **ids** (string): The IDs of the notifications + +### Responses +#### 204 Expected response +#### 401 Incorrect token scopes +#### 404 Not found + +--- + +## Threads + +### Overview + +Threads are a way of communicating between users and moderators, for the purposes of project reviews and reports. + +--- + +**Get your open reports** + +- Production server, Staging server + +### Authorizations +- TokenAuth REPORT_READ + +### Parameters +#### Query Parameters +- **count** (integer): Number of reports to return + +### Responses +#### 200 Expected response +- **report_type** (string): The type of the report +- **item_id** (string): The ID of the item being reported +- **item_type** (string): The type (project, user, version) +- **body** (string): Extended explanation +- **id** (string): The report ID +- **reporter** (string): The reporter's user ID +- **created** (string): When created +- **closed** (boolean): Whether resolved +- **thread_id** (string): The moderation thread ID +#### 401 Incorrect token scopes +#### 404 Not found + +--- + +**Report a project, user, or version** + +- Production server, Staging server + +### Authorizations +- TokenAuth REPORT_CREATE + +### Request Body +- **report_type** (string): The type of report +- **item_id** (string): The ID of the item being reported +- **item_type** (string): The type (project, user, version) +- **body** (string): Extended explanation + +### Responses +#### 200 Returns the created report +#### 400 Request was invalid +#### 401 Incorrect token scopes + +--- + +**Get report from ID** + +- Production server, Staging server + +### Authorizations +- TokenAuth REPORT_READ + +### Parameters +#### Path Parameters +- **id** (string): The ID of the report + +### Responses +#### 200 Expected response +#### 401 Incorrect token scopes +#### 404 Not found + +--- + +**Modify a report** + +- Production server, Staging server + +### Authorizations +- TokenAuth REPORT_WRITE + +### Parameters +#### Path Parameters +- **id** (string): The ID of the report + +### Request Body +- **body** (string): The contents of the report +- **closed** (boolean): Whether the thread should be closed + +### Responses +#### 204 Expected response +#### 400 Request was invalid +#### 401 Incorrect token scopes +#### 404 Not found + +--- + +**Get multiple reports** + +- Production server, Staging server + +### Authorizations +- TokenAuth REPORT_READ + +### Parameters +#### Query Parameters +- **ids** (string): The IDs of the reports + +### Responses +#### 200 Expected response +#### 401 Incorrect token scopes +#### 404 Not found + +--- + +**Get a thread** + +- Production server, Staging server + +### Authorizations +- TokenAuth THREAD_READ + +### Parameters +#### Path Parameters +- **id** (string): The ID of the thread + +### Responses +#### 200 Expected response +- **id** (string): Thread ID +- **type** (string): project, report, direct_message +- **project_id** (string): Associated project ID +- **report_id** (string): Associated report ID +- **messages** (Array): Thread messages +- **members** (Array): Thread members (user objects) +#### 404 Not found + +--- + +**Send a text message to a thread** + +- Production server, Staging server + +### Authorizations +- TokenAuth THREAD_WRITE + +### Parameters +#### Path Parameters +- **id** (string): The ID of the thread + +### Request Body +- **type** (string): Message type (status_change, text, thread_closure, deleted) +- **body** (string): The actual message text +- **private** (boolean): Whether only visible to moderators +- **replying_to** (string): The ID of the message being replied to +- **old_status** (string): Old status (for status_change type) +- **new_status** (string): New status (for status_change type) + +### Responses +#### 200 Returns the updated thread +#### 400 Request was invalid +#### 404 Not found + +--- + +**Get multiple threads** + +- Production server, Staging server + +### Authorizations +- TokenAuth THREAD_READ + +### Parameters +#### Query Parameters +- **ids** (string): The IDs of the threads + +### Responses +#### 200 Expected response +#### 404 Not found + +--- + +**Delete a thread message** + +- Production server, Staging server + +### Authorizations +- TokenAuth THREAD_WRITE + +### Parameters +#### Path Parameters +- **id** (string): The ID of the message + +### Responses +#### 204 Expected response +#### 401 Incorrect token scopes +#### 404 Not found + +--- + +## Teams + +### Overview + +Through teams, user permissions limit how team members can modify projects. + +--- + +**Get a project's team members** + +- Production server, Staging server + +### Parameters +#### Path Parameters +- **id|slug** (string): The ID or slug of the project + +### Responses +#### 200 Returns an array of team members +- **team_id** (string): The team ID +- **user** (object): The user object with full details +- **role** (string): The user's role on the team +- **permissions** (integer): Permissions in bitfield format +- **accepted** (boolean): Whether the user accepted the invitation +- **payouts_split** (integer): Payout split proportion +- **ordering** (integer): Team member ordering +#### 404 Not found + +--- + +**Get a team's members** + +- Production server, Staging server + +### Authorizations +- TokenAuth PROJECT_READ + +### Parameters +#### Path Parameters +- **id** (string): The ID of the team + +### Responses +#### 200 Returns array of team members +#### 404 Not found + +--- + +**Add a user to a team** + +- Production server, Staging server + +### Authorizations +- TokenAuth PROJECT_WRITE + +### Parameters +#### Path Parameters +- **id** (string): The ID of the team + +### Request Body +- **user_id** (string): The ID of the user to add (usernames cannot be used here) + +### Responses +#### 204 Expected response +#### 401 Incorrect token scopes +#### 404 Not found + +--- + +**Get the members of multiple teams** + +- Production server, Staging server + +### Parameters +#### Query Parameters +- **ids** (string): The IDs of the teams + +### Responses +#### 200 Expected response + +--- + +**Join a team** + +- Production server, Staging server + +### Authorizations +- TokenAuth PROJECT_WRITE + +### Parameters +#### Path Parameters +- **id** (string): The ID of the team + +### Responses +#### 204 Expected response +#### 401 Incorrect token scopes +#### 404 Not found + +--- + +**Remove a member from a team** + +- Production server, Staging server + +### Authorizations +- TokenAuth PROJECT_WRITE + +### Parameters +#### Path Parameters +- **id** (string): The ID of the team +- **id|username** (string): The ID or username of the user + +### Responses +#### 204 Expected response +#### 401 Incorrect token scopes +#### 404 Not found + +--- + +**Modify a team member's information** + +- Production server, Staging server + +### Authorizations +- TokenAuth PROJECT_WRITE + +### Parameters +#### Path Parameters +- **id** (string): The ID of the team +- **id|username** (string): The ID or username of the user + +### Request Body +- **role** (string): The role (e.g. Contributor) +- **permissions** (integer): Permissions in bitfield format +- **payouts_split** (integer): Payout split proportion +- **ordering** (integer): Team member ordering + +### Responses +#### 204 Expected response +#### 401 Incorrect token scopes +#### 404 Not found + +--- + +**Transfer team's ownership to another user** + +- Production server, Staging server + +### Authorizations +- TokenAuth PROJECT_WRITE + +### Parameters +#### Path Parameters +- **id** (string): The ID of the team + +### Request Body +- **user_id** (string): The new owner's ID + +### Responses +#### 204 Expected response +#### 401 Incorrect token scopes +#### 404 Not found + +--- + +## Tags + +### Overview + +Tags are common and reusable lists of metadata types such as categories or versions. Some can be applied to projects and/or versions. + +--- + +**Get a list of categories** + +- Production server, Staging server + +Gets an array of categories, their icons, and applicable project types. + +### Responses +#### 200 Expected response +- **icon** (string): The SVG icon of a category +- **name** (string): The name of the category +- **project_type** (string): The project type this category is applicable to +- **header** (string): The header under which the category should go + +--- + +**Get a list of loaders** + +- Production server, Staging server + +Gets an array of loaders, their icons, and supported project types. + +### Responses +#### 200 Expected response +- **icon** (string): The SVG icon of a loader +- **name** (string): The name of the loader +- **supported_project_types** (Array): Project types applicable to this loader + +--- + +**Get a list of game versions** + +- Production server, Staging server + +Gets an array of game versions and information about them. + +### Responses +#### 200 Expected response +- **version** (string): The name/number of the game version +- **version_type** (string): The type (release, snapshot, alpha, beta) +- **date** (string): The date of the game version release +- **major** (boolean): Whether this is a major version + +--- + +**Get a list of licenses** + +- Production server, Staging server + +Deprecated - simply use SPDX IDs. + +### Responses +#### 200 Expected response +- **short** (string): The short identifier of the license +- **name** (string): The full name of the license + +--- + +**Get the text and title of a license** + +- Production server, Staging server + +### Parameters +#### Path Parameters +- **id** (string): The license ID (e.g. LGPL-3.0-or-later) + +### Responses +#### 200 Expected response +- **title** (string): License title +- **body** (string): Full license text +#### 400 Request was invalid + +--- + +**Get a list of donation platforms** + +- Production server, Staging server + +### Responses +#### 200 Expected response +- **short** (string): The short identifier (e.g. bmac) +- **name** (string): The full name (e.g. Buy Me a Coffee) + +--- + +**Get a list of report types** + +- Production server, Staging server + +### Responses +#### 200 Expected response +Returns array: ["spam", "copyright", "inappropriate", "malicious", "name-squatting", "other"] + +--- + +**Get a list of project types** + +- Production server, Staging server + +### Responses +#### 200 Expected response +Returns array: ["mod", "modpack", "resourcepack", "shader"] + +--- + +**Get a list of side types** + +- Production server, Staging server + +### Responses +#### 200 Expected response +Returns array: ["required", "optional", "unsupported", "unknown"] + +--- + +## Misc + +--- + +**Forge Updates JSON file** + +- Production server, Staging server + +If you're a Forge mod developer, your Modrinth mods have an automatically generated updates.json using the Forge Update Checker. + +Insert the URL into the [[mods]] section of your mods.toml: + +` +[[mods]] +updateJSONURL = "https://api.modrinth.com/updates/{slug|ID}/forge_updates.json" +` + +### Parameters +#### Path Parameters +- **id|slug** (string): The ID or slug of the project +#### Query Parameters +- **neoforge** (string): Whether to include NeoForge versions (only, include, or omitted) + +### Responses +#### 200 Expected response +- **homepage** (string): A link to the mod page +- **promos** (object): Recommended and latest versions for each Minecraft release +#### 400 Invalid request + +--- + +**Various statistics about this Modrinth instance** + +- Production server, Staging server + +### Responses +#### 200 Expected response +- **projects** (integer): Number of projects on Modrinth +- **versions** (integer): Number of versions on Modrinth +- **files** (integer): Number of version files on Modrinth +- **authors** (integer): Number of authors (users with projects)