Skip to content

Commit 8538d87

Browse files
authored
modrinth: add version-from-modrinth-projects subcommand (#606)
1 parent 37cb9e7 commit 8538d87

File tree

10 files changed

+1962
-4
lines changed

10 files changed

+1962
-4
lines changed

README.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1100,6 +1100,40 @@ share code or pack file
11001100
11011101
```
11021102

1103+
### version-from-modrinth-projects
1104+
1105+
```
1106+
Usage: mc-image-helper version-from-modrinth-projects
1107+
[--api-base-url=<baseUrl>] [--projects=[loader:]id|slug[:version][,|<nl>
1108+
[loader:]id|slug[:version]...]...]...
1109+
[[--connection-pool-pending-acquire-timeout=DURATION]
1110+
[--tls-handshake-timeout=DURATION]
1111+
[--connection-pool-max-idle-timeout=DURATION]
1112+
[--http-response-timeout=DURATION]]
1113+
Finds a compatible Minecraft version across given Modrinth projects
1114+
--api-base-url=<baseUrl>
1115+
Default: https://api.modrinth.com
1116+
--connection-pool-max-idle-timeout=DURATION
1117+
1118+
--connection-pool-pending-acquire-timeout=DURATION
1119+
1120+
--http-response-timeout=DURATION
1121+
The response timeout to apply to HTTP operations. Parsed from ISO-8601
1122+
format. Default: PT30S
1123+
--projects=[loader:]id|slug[:version][,|<nl>[loader:]id|slug[:
1124+
version]...]...
1125+
Project ID or Slug. Can be <project ID>|<slug>, <loader>:<project
1126+
ID>|<slug>, <loader>:<project ID>|<slug>:<version ID|version
1127+
number|release type>, '@'<filename with ref per line (supports #
1128+
comments)>
1129+
Examples: fabric-api, fabric:fabric-api, fabric:fabric-api:0.76.1+1.
1130+
19.2, datapack:terralith, @/path/to/modrinth-mods.txt
1131+
Valid release types: release, beta, alpha
1132+
Valid loaders: fabric, forge, paper, datapack, etc.
1133+
--tls-handshake-timeout=DURATION
1134+
Default: PT30S
1135+
```
1136+
11031137
### yaml-path
11041138

11051139
```

src/main/java/me/itzg/helpers/McImageHelper.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
import me.itzg.helpers.github.GithubCommands;
3030
import me.itzg.helpers.modrinth.InstallModrinthModpackCommand;
3131
import me.itzg.helpers.modrinth.ModrinthCommand;
32+
import me.itzg.helpers.modrinth.VersionFromModrinthProjectsCommand;
3233
import me.itzg.helpers.mvn.MavenDownloadCommand;
3334
import me.itzg.helpers.paper.InstallPaperCommand;
3435
import me.itzg.helpers.patch.PatchCommand;
@@ -93,8 +94,9 @@
9394
SyncAndInterpolate.class,
9495
TestLoggingCommand.class,
9596
TomlPathCommand.class,
96-
YamlPathCommand.class,
9797
VanillaTweaksCommand.class,
98+
VersionFromModrinthProjectsCommand.class,
99+
YamlPathCommand.class
98100
}
99101
)
100102
@Slf4j
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
package me.itzg.helpers.modrinth;
2+
3+
import static me.itzg.helpers.McImageHelper.SPLIT_COMMA_NL;
4+
import static me.itzg.helpers.McImageHelper.SPLIT_SYNOPSIS_COMMA_NL;
5+
6+
import java.util.HashMap;
7+
import java.util.List;
8+
import java.util.Map;
9+
import java.util.concurrent.Callable;
10+
import me.itzg.helpers.errors.GenericException;
11+
import me.itzg.helpers.http.SharedFetchArgs;
12+
import me.itzg.helpers.modrinth.model.Project;
13+
import picocli.CommandLine.ArgGroup;
14+
import picocli.CommandLine.Command;
15+
import picocli.CommandLine.ExitCode;
16+
import picocli.CommandLine.Option;
17+
import reactor.core.publisher.Flux;
18+
19+
@Command(name = "version-from-modrinth-projects", description = "Finds a compatible Minecraft version across given Modrinth projects")
20+
public class VersionFromModrinthProjectsCommand implements Callable<Integer> {
21+
22+
@Option(
23+
names = "--projects",
24+
description = "Project ID or Slug. Can be <project ID>|<slug>,"
25+
+ " <loader>:<project ID>|<slug>,"
26+
+ " <loader>:<project ID>|<slug>:<version ID|version number|release type>,"
27+
+ " '@'<filename with ref per line (supports # comments)>"
28+
+ "%nExamples: fabric-api, fabric:fabric-api, fabric:fabric-api:0.76.1+1.19.2,"
29+
+ " datapack:terralith, @/path/to/modrinth-mods.txt"
30+
+ "%nValid release types: release, beta, alpha"
31+
+ "%nValid loaders: fabric, forge, paper, datapack, etc.",
32+
split = SPLIT_COMMA_NL,
33+
splitSynopsisLabel = SPLIT_SYNOPSIS_COMMA_NL,
34+
paramLabel = "[loader:]id|slug[:version]",
35+
// at least one is required
36+
arity = "1..*"
37+
)
38+
List<String> projects;
39+
40+
@Option(names = "--api-base-url", defaultValue = "${env:MODRINTH_API_BASE_URL:-https://api.modrinth.com}",
41+
description = "Default: ${DEFAULT-VALUE}"
42+
)
43+
String baseUrl;
44+
45+
@ArgGroup(exclusive = false)
46+
SharedFetchArgs sharedFetchArgs = new SharedFetchArgs();
47+
48+
@Override
49+
public Integer call() throws Exception {
50+
try (ModrinthApiClient modrinthApiClient = new ModrinthApiClient(baseUrl, "modrinth", sharedFetchArgs.options())) {
51+
final String version = versionFromProjects(modrinthApiClient, projects);
52+
53+
if (version != null) {
54+
System.out.println(version);
55+
return ExitCode.OK;
56+
}
57+
else {
58+
System.err.println("Unable to find a compatible Minecraft version across given projects");
59+
return ExitCode.SOFTWARE;
60+
}
61+
}
62+
}
63+
64+
static String versionFromProjects(ModrinthApiClient modrinthApiClient, List<String> projectRefs) {
65+
final List<List<String>> allGameVersions = Flux.fromStream(
66+
// extract just the id/slug from refs
67+
projectRefs.stream()
68+
.map(ProjectRef::parse)
69+
.map(ProjectRef::getIdOrSlug)
70+
)
71+
.flatMap(modrinthApiClient::getProject)
72+
.map(Project::getGameVersions)
73+
.collectList()
74+
.block();
75+
76+
if (allGameVersions != null) {
77+
return processGameVersions(allGameVersions);
78+
}
79+
else {
80+
throw new GenericException("Unable to retrieve game versions for projects " + projectRefs);
81+
}
82+
}
83+
84+
static String processGameVersions(List<List<String>> allGameVersions) {
85+
final Map<String, Integer> gameVersionCounts = new HashMap<>();
86+
87+
final int projectCount = allGameVersions.size();
88+
89+
final int[] positions = new int[projectCount];
90+
for (int i = 0; i < projectCount; i++) {
91+
positions[i] = allGameVersions.get(i).size();
92+
}
93+
94+
while (!finished(positions)) {
95+
for (int i = 0; i < projectCount; i++) {
96+
final String version = allGameVersions.get(i).get(--positions[i]);
97+
final Integer result = gameVersionCounts.compute(version, (k, count) -> count == null ? 1 : count + 1);
98+
if (result == projectCount) {
99+
return version;
100+
}
101+
}
102+
}
103+
104+
return null;
105+
}
106+
107+
static private boolean finished(int[] positions) {
108+
for (final int position : positions) {
109+
// since we pre-increment the positions
110+
if (position <= 0) {
111+
return true;
112+
}
113+
}
114+
return false;
115+
}
116+
}

src/main/java/me/itzg/helpers/modrinth/model/Project.java

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,11 @@
22

33
import com.fasterxml.jackson.databind.PropertyNamingStrategies;
44
import com.fasterxml.jackson.databind.annotation.JsonNaming;
5-
import lombok.Data;
6-
75
import java.util.List;
6+
import lombok.Data;
87

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

2524
List<String> versions;
25+
26+
List<String> gameVersions;
27+
28+
List<String> loaders;
2629
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
/**
2+
* Model classes supporting <a href="https://docs.modrinth.com/api/">Modrinth Labrinth API</a>
3+
*/
4+
package me.itzg.helpers.modrinth.model;
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
package me.itzg.helpers.modrinth;
2+
3+
import static com.github.tomakehurst.wiremock.client.WireMock.*;
4+
import static org.assertj.core.api.Assertions.assertThat;
5+
import static org.junit.jupiter.params.provider.Arguments.argumentSet;
6+
7+
import com.github.stefanbirkner.systemlambda.SystemLambda;
8+
import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo;
9+
import com.github.tomakehurst.wiremock.junit5.WireMockTest;
10+
import java.util.Arrays;
11+
import java.util.List;
12+
import org.junit.jupiter.api.Test;
13+
import org.junit.jupiter.params.ParameterizedTest;
14+
import org.junit.jupiter.params.provider.Arguments;
15+
import org.junit.jupiter.params.provider.FieldSource;
16+
import picocli.CommandLine;
17+
import picocli.CommandLine.ExitCode;
18+
19+
@WireMockTest
20+
class VersionFromModrinthProjectsCommandTest {
21+
22+
@ParameterizedTest
23+
@FieldSource("processGameVersionsArgs")
24+
void processGameVersions(List<List<String>> versions, String expected) {
25+
final String result = VersionFromModrinthProjectsCommand.processGameVersions(versions);
26+
27+
if (expected != null) {
28+
assertThat(result)
29+
.isNotNull()
30+
.isEqualTo(expected);
31+
}
32+
else {
33+
assertThat(result)
34+
.isNull();
35+
}
36+
}
37+
38+
@SuppressWarnings("unused") // will be fixed https://youtrack.jetbrains.com/issue/IDEA-358214/Support-JUnit-5-FieldSource-annotation
39+
static List<Arguments> processGameVersionsArgs = Arrays.asList(
40+
argumentSet("matches", Arrays.asList(
41+
Arrays.asList("1.21.6", "1.21.7", "1.21.8"),
42+
Arrays.asList("1.21.6", "1.21.7", "1.21.8"),
43+
Arrays.asList("1.21.6", "1.21.7", "1.21.8"),
44+
Arrays.asList("1.21.6", "1.21.7", "1.21.8")
45+
), "1.21.8"
46+
),
47+
argumentSet("justOneOff", Arrays.asList(
48+
Arrays.asList("1.21.6", "1.21.7", "1.21.8"),
49+
Arrays.asList("1.21.6", "1.21.7", "1.21.8"),
50+
Arrays.asList("1.21.6", "1.21.7"),
51+
Arrays.asList("1.21.6", "1.21.7", "1.21.8")
52+
), "1.21.7"
53+
),
54+
argumentSet("mismatch", Arrays.asList(
55+
Arrays.asList("1.21.6", "1.21.7", "1.21.8"),
56+
Arrays.asList("1.21.6", "1.21.7", "1.21.8"),
57+
Arrays.asList("1.21.4", "1.21.5"),
58+
Arrays.asList("1.21.6", "1.21.7", "1.21.8")
59+
), null
60+
)
61+
);
62+
63+
@Test
64+
void testCommand(WireMockRuntimeInfo wmInfo) throws Exception {
65+
66+
stubGetProjects("viaversion", "viabackwards", "griefprevention", "discordsrv");
67+
68+
final String out = SystemLambda.tapSystemOut(() -> {
69+
final int exitCode = new CommandLine(new VersionFromModrinthProjectsCommand())
70+
.execute(
71+
"--api-base-url", wmInfo.getHttpBaseUrl(),
72+
"--projects", "viaversion,viabackwards,griefprevention,discordsrv"
73+
);
74+
75+
assertThat(exitCode)
76+
.isEqualTo(ExitCode.OK);
77+
});
78+
79+
assertThat(out).isEqualToNormalizingNewlines("1.21.7\n");
80+
}
81+
82+
@Test
83+
void testCommandWithProjectQualifiers(WireMockRuntimeInfo wmInfo) throws Exception {
84+
85+
stubGetProjects("viaversion", "viabackwards", "griefprevention", "discordsrv");
86+
87+
final String out = SystemLambda.tapSystemOut(() -> {
88+
final int exitCode = new CommandLine(new VersionFromModrinthProjectsCommand())
89+
.execute(
90+
"--api-base-url", wmInfo.getHttpBaseUrl(),
91+
"--projects", "paper:viaversion,viabackwards,griefprevention:ue7jAjJ5,discordsrv"
92+
);
93+
94+
assertThat(exitCode)
95+
.isEqualTo(ExitCode.OK);
96+
});
97+
98+
assertThat(out).isEqualToNormalizingNewlines("1.21.7\n");
99+
}
100+
101+
private void stubGetProjects(String... projects) {
102+
for (final String project : projects) {
103+
stubFor(get(urlPathEqualTo("/v2/project/" + project))
104+
.willReturn(aResponse()
105+
.withHeader("Content-Type", "application/json")
106+
.withBodyFile("modrinth/project-" + project + ".json")
107+
)
108+
);
109+
}
110+
}
111+
}

0 commit comments

Comments
 (0)