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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1100,6 +1100,40 @@ share code or pack file

```

### version-from-modrinth-projects

```
Usage: mc-image-helper version-from-modrinth-projects
[--api-base-url=<baseUrl>] [--projects=[loader:]id|slug[:version][,|<nl>
[loader:]id|slug[:version]...]...]...
[[--connection-pool-pending-acquire-timeout=DURATION]
[--tls-handshake-timeout=DURATION]
[--connection-pool-max-idle-timeout=DURATION]
[--http-response-timeout=DURATION]]
Finds a compatible Minecraft version across given Modrinth projects
--api-base-url=<baseUrl>
Default: https://api.modrinth.com
--connection-pool-max-idle-timeout=DURATION

--connection-pool-pending-acquire-timeout=DURATION

--http-response-timeout=DURATION
The response timeout to apply to HTTP operations. Parsed from ISO-8601
format. Default: PT30S
--projects=[loader:]id|slug[:version][,|<nl>[loader:]id|slug[:
version]...]...
Project ID or Slug. Can be <project ID>|<slug>, <loader>:<project
ID>|<slug>, <loader>:<project ID>|<slug>:<version ID|version
number|release type>, '@'<filename with ref per line (supports #
comments)>
Examples: fabric-api, fabric:fabric-api, fabric:fabric-api:0.76.1+1.
19.2, datapack:terralith, @/path/to/modrinth-mods.txt
Valid release types: release, beta, alpha
Valid loaders: fabric, forge, paper, datapack, etc.
--tls-handshake-timeout=DURATION
Default: PT30S
```

### yaml-path

```
Expand Down
4 changes: 3 additions & 1 deletion src/main/java/me/itzg/helpers/McImageHelper.java
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
import me.itzg.helpers.github.GithubCommands;
import me.itzg.helpers.modrinth.InstallModrinthModpackCommand;
import me.itzg.helpers.modrinth.ModrinthCommand;
import me.itzg.helpers.modrinth.VersionFromModrinthProjectsCommand;
import me.itzg.helpers.mvn.MavenDownloadCommand;
import me.itzg.helpers.paper.InstallPaperCommand;
import me.itzg.helpers.patch.PatchCommand;
Expand Down Expand Up @@ -93,8 +94,9 @@
SyncAndInterpolate.class,
TestLoggingCommand.class,
TomlPathCommand.class,
YamlPathCommand.class,
VanillaTweaksCommand.class,
VersionFromModrinthProjectsCommand.class,
YamlPathCommand.class
}
)
@Slf4j
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
package me.itzg.helpers.modrinth;

import static me.itzg.helpers.McImageHelper.SPLIT_COMMA_NL;
import static me.itzg.helpers.McImageHelper.SPLIT_SYNOPSIS_COMMA_NL;

import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.Callable;
import me.itzg.helpers.errors.GenericException;
import me.itzg.helpers.http.SharedFetchArgs;
import me.itzg.helpers.modrinth.model.Project;
import picocli.CommandLine.ArgGroup;
import picocli.CommandLine.Command;
import picocli.CommandLine.ExitCode;
import picocli.CommandLine.Option;
import reactor.core.publisher.Flux;

@Command(name = "version-from-modrinth-projects", description = "Finds a compatible Minecraft version across given Modrinth projects")
public class VersionFromModrinthProjectsCommand implements Callable<Integer> {

@Option(
names = "--projects",
description = "Project ID or Slug. Can be <project ID>|<slug>,"
+ " <loader>:<project ID>|<slug>,"
+ " <loader>:<project ID>|<slug>:<version ID|version number|release type>,"
+ " '@'<filename with ref per line (supports # comments)>"
+ "%nExamples: fabric-api, fabric:fabric-api, fabric:fabric-api:0.76.1+1.19.2,"
+ " datapack:terralith, @/path/to/modrinth-mods.txt"
+ "%nValid release types: release, beta, alpha"
+ "%nValid loaders: fabric, forge, paper, datapack, etc.",
split = SPLIT_COMMA_NL,
splitSynopsisLabel = SPLIT_SYNOPSIS_COMMA_NL,
paramLabel = "[loader:]id|slug[:version]",
// at least one is required
arity = "1..*"
)
List<String> projects;

@Option(names = "--api-base-url", defaultValue = "${env:MODRINTH_API_BASE_URL:-https://api.modrinth.com}",
description = "Default: ${DEFAULT-VALUE}"
)
String baseUrl;

@ArgGroup(exclusive = false)
SharedFetchArgs sharedFetchArgs = new SharedFetchArgs();

@Override
public Integer call() throws Exception {
try (ModrinthApiClient modrinthApiClient = new ModrinthApiClient(baseUrl, "modrinth", sharedFetchArgs.options())) {
final String version = versionFromProjects(modrinthApiClient, projects);

if (version != null) {
System.out.println(version);
return ExitCode.OK;
}
else {
System.err.println("Unable to find a compatible Minecraft version across given projects");
return ExitCode.SOFTWARE;
}
}
}

static String versionFromProjects(ModrinthApiClient modrinthApiClient, List<String> projectRefs) {
final List<List<String>> allGameVersions = Flux.fromStream(
// extract just the id/slug from refs
projectRefs.stream()
.map(ProjectRef::parse)
.map(ProjectRef::getIdOrSlug)
)
.flatMap(modrinthApiClient::getProject)
.map(Project::getGameVersions)
.collectList()
.block();

if (allGameVersions != null) {
return processGameVersions(allGameVersions);
}
else {
throw new GenericException("Unable to retrieve game versions for projects " + projectRefs);
}
}

static String processGameVersions(List<List<String>> allGameVersions) {
final Map<String, Integer> gameVersionCounts = new HashMap<>();

final int projectCount = allGameVersions.size();

final int[] positions = new int[projectCount];
for (int i = 0; i < projectCount; i++) {
positions[i] = allGameVersions.get(i).size();
}

while (!finished(positions)) {
for (int i = 0; i < projectCount; i++) {
final String version = allGameVersions.get(i).get(--positions[i]);
final Integer result = gameVersionCounts.compute(version, (k, count) -> count == null ? 1 : count + 1);
if (result == projectCount) {
return version;
}
}
}

return null;
}

static private boolean finished(int[] positions) {
for (final int position : positions) {
// since we pre-increment the positions
if (position <= 0) {
return true;
}
}
return false;
}
}
9 changes: 6 additions & 3 deletions src/main/java/me/itzg/helpers/modrinth/model/Project.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,11 @@

import com.fasterxml.jackson.databind.PropertyNamingStrategies;
import com.fasterxml.jackson.databind.annotation.JsonNaming;
import lombok.Data;

import java.util.List;
import lombok.Data;

/**
* <a href="https://docs.modrinth.com/api-spec/#tag/project_model">Spec</a>
* <a href="https://docs.modrinth.com/api/operations/getproject/#200">Spec</a>
*/
@Data
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
Expand All @@ -23,4 +22,8 @@ public class Project {
ServerSide serverSide;

List<String> versions;

List<String> gameVersions;

List<String> loaders;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
/**
* Model classes supporting <a href="https://docs.modrinth.com/api/">Modrinth Labrinth API</a>
*/
package me.itzg.helpers.modrinth.model;
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
package me.itzg.helpers.modrinth;

import static com.github.tomakehurst.wiremock.client.WireMock.*;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.params.provider.Arguments.argumentSet;

import com.github.stefanbirkner.systemlambda.SystemLambda;
import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo;
import com.github.tomakehurst.wiremock.junit5.WireMockTest;
import java.util.Arrays;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.FieldSource;
import picocli.CommandLine;
import picocli.CommandLine.ExitCode;

@WireMockTest
class VersionFromModrinthProjectsCommandTest {

@ParameterizedTest
@FieldSource("processGameVersionsArgs")
void processGameVersions(List<List<String>> versions, String expected) {
final String result = VersionFromModrinthProjectsCommand.processGameVersions(versions);

if (expected != null) {
assertThat(result)
.isNotNull()
.isEqualTo(expected);
}
else {
assertThat(result)
.isNull();
}
}

@SuppressWarnings("unused") // will be fixed https://youtrack.jetbrains.com/issue/IDEA-358214/Support-JUnit-5-FieldSource-annotation
static List<Arguments> processGameVersionsArgs = Arrays.asList(
argumentSet("matches", Arrays.asList(
Arrays.asList("1.21.6", "1.21.7", "1.21.8"),
Arrays.asList("1.21.6", "1.21.7", "1.21.8"),
Arrays.asList("1.21.6", "1.21.7", "1.21.8"),
Arrays.asList("1.21.6", "1.21.7", "1.21.8")
), "1.21.8"
),
argumentSet("justOneOff", Arrays.asList(
Arrays.asList("1.21.6", "1.21.7", "1.21.8"),
Arrays.asList("1.21.6", "1.21.7", "1.21.8"),
Arrays.asList("1.21.6", "1.21.7"),
Arrays.asList("1.21.6", "1.21.7", "1.21.8")
), "1.21.7"
),
argumentSet("mismatch", Arrays.asList(
Arrays.asList("1.21.6", "1.21.7", "1.21.8"),
Arrays.asList("1.21.6", "1.21.7", "1.21.8"),
Arrays.asList("1.21.4", "1.21.5"),
Arrays.asList("1.21.6", "1.21.7", "1.21.8")
), null
)
);

@Test
void testCommand(WireMockRuntimeInfo wmInfo) throws Exception {

stubGetProjects("viaversion", "viabackwards", "griefprevention", "discordsrv");

final String out = SystemLambda.tapSystemOut(() -> {
final int exitCode = new CommandLine(new VersionFromModrinthProjectsCommand())
.execute(
"--api-base-url", wmInfo.getHttpBaseUrl(),
"--projects", "viaversion,viabackwards,griefprevention,discordsrv"
);

assertThat(exitCode)
.isEqualTo(ExitCode.OK);
});

assertThat(out).isEqualToNormalizingNewlines("1.21.7\n");
}

@Test
void testCommandWithProjectQualifiers(WireMockRuntimeInfo wmInfo) throws Exception {

stubGetProjects("viaversion", "viabackwards", "griefprevention", "discordsrv");

final String out = SystemLambda.tapSystemOut(() -> {
final int exitCode = new CommandLine(new VersionFromModrinthProjectsCommand())
.execute(
"--api-base-url", wmInfo.getHttpBaseUrl(),
"--projects", "paper:viaversion,viabackwards,griefprevention:ue7jAjJ5,discordsrv"
);

assertThat(exitCode)
.isEqualTo(ExitCode.OK);
});

assertThat(out).isEqualToNormalizingNewlines("1.21.7\n");
}

private void stubGetProjects(String... projects) {
for (final String project : projects) {
stubFor(get(urlPathEqualTo("/v2/project/" + project))
.willReturn(aResponse()
.withHeader("Content-Type", "application/json")
.withBodyFile("modrinth/project-" + project + ".json")
)
);
}
}
}
Loading