Skip to content

Commit f93ef3b

Browse files
committed
feat: If --server is not supplied, infer it from the openapi specs
1 parent 3527e41 commit f93ef3b

10 files changed

Lines changed: 574 additions & 26 deletions

File tree

src/main/java/dev/dochia/cli/core/args/ApiArguments.java

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,26 @@
11
package dev.dochia.cli.core.args;
22

33
import dev.dochia.cli.core.util.CommonUtils;
4+
import dev.dochia.cli.core.util.OpenApiServerExtractor;
5+
import io.github.ludovicianul.prettylogger.PrettyLogger;
6+
import io.github.ludovicianul.prettylogger.PrettyLoggerFactory;
7+
import io.swagger.v3.oas.models.OpenAPI;
48
import jakarta.inject.Singleton;
59
import lombok.Getter;
610
import lombok.Setter;
711
import org.eclipse.microprofile.config.inject.ConfigProperty;
812
import picocli.CommandLine;
913

14+
import java.util.List;
15+
1016
/**
1117
* Holds all arguments related to API details.
1218
*/
1319
@Getter
1420
@Singleton
1521
public class ApiArguments {
22+
private final PrettyLogger log = PrettyLoggerFactory.getLogger(this.getClass());
23+
1624
@CommandLine.Option(
1725
names = {"--max-requests-per-minute"}, paramLabel = "<max>",
1826
description = "Maximum number of requests per minute; this is useful when APIs have rate limiting implemented. Default: @|bold,underline ${DEFAULT-VALUE}|@",
@@ -75,16 +83,35 @@ public void validateRequired(CommandLine.Model.CommandSpec spec) {
7583
if (this.contract == null) {
7684
throw new CommandLine.ParameterException(
7785
spec.commandLine(), "Missing required option --contract=<contract>");
78-
} else if (this.server == null) {
79-
throw new CommandLine.ParameterException(
80-
spec.commandLine(), "Missing required option --server=<server>");
8186
}
8287
}
8388

84-
public void validateValidServer(CommandLine.Model.CommandSpec spec) {
85-
if (!CommonUtils.isValidURL(server)) {
86-
throw new CommandLine.ParameterException(
87-
spec.commandLine(),
89+
public void validateValidServer(CommandLine.Model.CommandSpec spec, OpenAPI openAPI) {
90+
String serverFromInput = this.server;
91+
92+
if (openAPI != null) {
93+
List<String> servers = OpenApiServerExtractor.getServerUrls(openAPI);
94+
log.debug("--server not provided. Loaded from OpenAPI: {}", servers);
95+
servers.stream().findFirst().ifPresent(theServer -> this.server = theServer);
96+
}
97+
98+
if (this.server != null && serverFromInput != null) {
99+
if (this.server.contains("{")) {
100+
this.server = this.server.replaceAll("\\{[^}]*+}", serverFromInput);
101+
}
102+
103+
if (!this.server.startsWith("http")) {
104+
this.server = serverFromInput + this.server;
105+
}
106+
}
107+
108+
if (this.server == null) {
109+
throw new CommandLine.ParameterException(spec.commandLine(),
110+
"Missing required option --server=<server>");
111+
}
112+
113+
if (!CommonUtils.isValidURL(this.server)) {
114+
throw new CommandLine.ParameterException(spec.commandLine(),
88115
"You must provide a valid <server> URL which must start with http or https");
89116
}
90117
}

src/main/java/dev/dochia/cli/core/command/RandomCommand.java

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,6 @@ public void run() {
9494

9595
// these are all throwing a ParameterException in case a mandatory argument is not provided
9696
apiArguments.validateRequired(spec);
97-
apiArguments.validateValidServer(spec);
9897

9998
testCommand.filterArguments.customFilter("RandomPlaybook");
10099
testCommand.filesArguments = filesArguments;

src/main/java/dev/dochia/cli/core/command/TestCommand.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,7 @@ private void doLogic() throws IOException {
203203
this.prepareRun();
204204
OpenAPI openAPI = this.createOpenAPI();
205205
this.checkOpenAPI(openAPI);
206+
apiArguments.validateValidServer(spec, openAPI);
206207
// reporting path is initialized only if OpenAPI spec is successfully parsed
207208
testCaseListener.initReportingPath();
208209
this.printConfiguration(openAPI);
@@ -322,7 +323,6 @@ void prepareRun() throws IOException {
322323
ConsoleUtils.initTerminalWidth(spec);
323324
reportingArguments.processLogData();
324325
apiArguments.validateRequired(spec);
325-
apiArguments.validateValidServer(spec);
326326
filesArguments.loadConfig();
327327
}
328328

src/main/java/dev/dochia/cli/core/report/TestCaseListener.java

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -412,15 +412,19 @@ public void writeHelperFiles() {
412412
* Additionally, prints execution details using the associated logger.
413413
*/
414414
public void endSession() {
415-
markPreviousPathAsDone();
416-
reportingArguments.enableAdditionalLoggingIfSummary();
417-
testReportsGenerator.writeSummary(testCaseSummaryDetails, executionStatisticsListener);
418-
testReportsGenerator.writeHelperFiles();
419-
ConsoleUtils.emptyLine();
420-
testReportsGenerator.writeErrorsByReason(testCaseSummaryDetails);
421-
testReportsGenerator.writePerformanceReport(testCaseExecutionDetails);
422-
testReportsGenerator.printExecutionDetails(executionStatisticsListener);
423-
writeRecordedErrorsIfPresent();
415+
try {
416+
markPreviousPathAsDone();
417+
reportingArguments.enableAdditionalLoggingIfSummary();
418+
testReportsGenerator.writeSummary(testCaseSummaryDetails, executionStatisticsListener);
419+
testReportsGenerator.writeHelperFiles();
420+
ConsoleUtils.emptyLine();
421+
testReportsGenerator.writeErrorsByReason(testCaseSummaryDetails);
422+
testReportsGenerator.writePerformanceReport(testCaseExecutionDetails);
423+
testReportsGenerator.printExecutionDetails(executionStatisticsListener);
424+
writeRecordedErrorsIfPresent();
425+
} catch (Exception e) {
426+
logger.error("Error while ending sessions {}", e.getMessage());
427+
}
424428
}
425429

426430
/**
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
package dev.dochia.cli.core.util;
2+
3+
import io.swagger.v3.oas.models.OpenAPI;
4+
import io.swagger.v3.oas.models.servers.Server;
5+
import io.swagger.v3.oas.models.servers.ServerVariable;
6+
import io.swagger.v3.oas.models.servers.ServerVariables;
7+
8+
import java.util.ArrayList;
9+
import java.util.HashMap;
10+
import java.util.List;
11+
import java.util.Map;
12+
13+
/**
14+
* Extracts all possible server URLs from an OpenAPI specification.
15+
* Handles server variables by generating all combinations.
16+
*/
17+
public class OpenApiServerExtractor {
18+
19+
private OpenApiServerExtractor() {
20+
//ntd
21+
}
22+
23+
/**
24+
* Extracts all possible server URLs from an OpenAPI specification.
25+
* Handles server variables by generating all combinations.
26+
*
27+
* @param openAPI the parsed OpenAPI object
28+
* @return list of all possible server URLs
29+
*/
30+
public static List<String> getServerUrls(OpenAPI openAPI) {
31+
List<String> serverUrls = new ArrayList<>();
32+
33+
if (openAPI.getServers() == null || openAPI.getServers().isEmpty()) {
34+
return serverUrls;
35+
}
36+
37+
for (Server server : openAPI.getServers()) {
38+
String urlTemplate = server.getUrl();
39+
ServerVariables variables = server.getVariables();
40+
41+
if (variables == null || variables.isEmpty()) {
42+
serverUrls.add(urlTemplate);
43+
} else {
44+
List<String> expandedUrls = expandServerUrl(urlTemplate, variables);
45+
serverUrls.addAll(expandedUrls);
46+
}
47+
}
48+
49+
return serverUrls;
50+
}
51+
52+
/**
53+
* Expands a server URL template with all possible variable combinations.
54+
*
55+
* @param urlTemplate the URL template with variables (e.g., "https://{environment}.example.com")
56+
* @param variables the server variables definition
57+
* @return list of all expanded URLs
58+
*/
59+
private static List<String> expandServerUrl(String urlTemplate, ServerVariables variables) {
60+
List<Map<String, String>> allCombinations = generateVariableCombinations(variables);
61+
List<String> expandedUrls = new ArrayList<>();
62+
63+
for (Map<String, String> combination : allCombinations) {
64+
String expandedUrl = urlTemplate;
65+
for (Map.Entry<String, String> entry : combination.entrySet()) {
66+
expandedUrl = expandedUrl.replace("{" + entry.getKey() + "}", entry.getValue());
67+
}
68+
expandedUrls.add(expandedUrl);
69+
}
70+
71+
return expandedUrls;
72+
}
73+
74+
/**
75+
* Generates all possible combinations of server variable values.
76+
*
77+
* @param variables the server variables
78+
* @return list of all possible variable value combinations
79+
*/
80+
private static List<Map<String, String>> generateVariableCombinations(ServerVariables variables) {
81+
List<Map<String, String>> combinations = new ArrayList<>();
82+
combinations.add(new HashMap<>());
83+
84+
for (Map.Entry<String, ServerVariable> entry : variables.entrySet()) {
85+
String varName = entry.getKey();
86+
List<String> possibleValues = getPossibleValues(entry);
87+
88+
// If no values found, skip this variable
89+
if (possibleValues.isEmpty()) {
90+
continue;
91+
}
92+
93+
// Create new combinations for each possible value
94+
List<Map<String, String>> newCombinations = new ArrayList<>();
95+
for (Map<String, String> existingCombo : combinations) {
96+
for (String value : possibleValues) {
97+
Map<String, String> newCombo = new HashMap<>(existingCombo);
98+
newCombo.put(varName, value);
99+
newCombinations.add(newCombo);
100+
}
101+
}
102+
combinations = newCombinations;
103+
}
104+
105+
return combinations;
106+
}
107+
108+
private static List<String> getPossibleValues(Map.Entry<String, ServerVariable> entry) {
109+
ServerVariable variable = entry.getValue();
110+
111+
List<String> possibleValues = new ArrayList<>();
112+
113+
// Add default value first
114+
if (variable.getDefault() != null) {
115+
possibleValues.add(variable.getDefault());
116+
}
117+
118+
// Add enum values if they exist
119+
if (variable.getEnum() != null && !variable.getEnum().isEmpty()) {
120+
for (String enumValue : variable.getEnum()) {
121+
if (!possibleValues.contains(enumValue)) {
122+
possibleValues.add(enumValue);
123+
}
124+
}
125+
}
126+
return possibleValues;
127+
}
128+
}

src/test/java/dev/dochia/cli/core/args/ApiArgumentsTest.java

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
package dev.dochia.cli.core.args;
22

33
import io.quarkus.test.junit.QuarkusTest;
4+
import io.swagger.v3.oas.models.OpenAPI;
45
import org.assertj.core.api.Assertions;
56
import org.junit.jupiter.api.Test;
67
import org.mockito.Mockito;
78
import org.springframework.test.util.ReflectionTestUtils;
89
import picocli.CommandLine;
910

11+
import java.util.Collections;
12+
1013
@QuarkusTest
1114
class ApiArgumentsTest {
1215

@@ -38,7 +41,7 @@ void shouldThrowExceptionWhenServerNotSupplied() {
3841
Mockito.when(spec.commandLine()).thenReturn(Mockito.mock(CommandLine.class));
3942
ApiArguments apiArguments = new ApiArguments();
4043
ReflectionTestUtils.setField(apiArguments, "contract", "contract");
41-
Assertions.assertThatThrownBy(() -> apiArguments.validateRequired(spec))
44+
Assertions.assertThatThrownBy(() -> apiArguments.validateValidServer(spec, null))
4245
.isInstanceOf(CommandLine.ParameterException.class).hasMessageContaining("server");
4346
}
4447

@@ -50,4 +53,71 @@ void shouldThrowExceptionWhenContractNotSupplied() {
5053
Assertions.assertThatThrownBy(() -> apiArguments.validateRequired(spec))
5154
.isInstanceOf(CommandLine.ParameterException.class).hasMessageContaining("contract");
5255
}
56+
57+
@Test
58+
void shouldThrowExceptionWhenServerAndOpenApiNull() {
59+
CommandLine.Model.CommandSpec spec = Mockito.mock(CommandLine.Model.CommandSpec.class);
60+
Mockito.when(spec.commandLine()).thenReturn(Mockito.mock(CommandLine.class));
61+
ApiArguments args = new ApiArguments();
62+
args.setServer(null);
63+
64+
Assertions.assertThatThrownBy(() -> args.validateValidServer(spec, null))
65+
.isInstanceOf(CommandLine.ParameterException.class)
66+
.hasMessageContaining("server");
67+
}
68+
69+
@Test
70+
void shouldSetServerFromOpenApiWhenServerIsNull() {
71+
CommandLine.Model.CommandSpec spec = Mockito.mock(CommandLine.Model.CommandSpec.class);
72+
Mockito.when(spec.commandLine()).thenReturn(Mockito.mock(CommandLine.class));
73+
ApiArguments args = new ApiArguments();
74+
args.setServer(null);
75+
76+
OpenAPI openAPI = new OpenAPI();
77+
openAPI.setServers(Collections.singletonList(
78+
new io.swagger.v3.oas.models.servers.Server().url("http://fromopenapi.com")
79+
));
80+
81+
args.validateValidServer(spec, openAPI);
82+
Assertions.assertThat(args.getServer()).isEqualTo("http://fromopenapi.com");
83+
}
84+
85+
@Test
86+
void shouldReplaceServerPlaceholder() {
87+
CommandLine.Model.CommandSpec spec = Mockito.mock(CommandLine.Model.CommandSpec.class);
88+
Mockito.when(spec.commandLine()).thenReturn(Mockito.mock(CommandLine.class));
89+
ApiArguments args = new ApiArguments();
90+
args.setServer("http://api.com");
91+
92+
OpenAPI openAPI = new OpenAPI();
93+
openAPI.setServers(Collections.singletonList(
94+
new io.swagger.v3.oas.models.servers.Server().url("{apiRoot}/v2")
95+
));
96+
args.validateValidServer(spec, openAPI);
97+
// The placeholder should be replaced
98+
Assertions.assertThat(args.getServer()).isEqualTo("http://api.com/v2");
99+
}
100+
101+
@Test
102+
void shouldThrowExceptionWhenServerIsInvalidUrl() {
103+
CommandLine.Model.CommandSpec spec = Mockito.mock(CommandLine.Model.CommandSpec.class);
104+
Mockito.when(spec.commandLine()).thenReturn(Mockito.mock(CommandLine.class));
105+
ApiArguments args = new ApiArguments();
106+
args.setServer("ftsp://invalid-url");
107+
108+
Assertions.assertThatThrownBy(() -> args.validateValidServer(spec, null))
109+
.isInstanceOf(CommandLine.ParameterException.class)
110+
.hasMessageContaining("valid <server> URL");
111+
}
112+
113+
@Test
114+
void shouldPassWhenServerIsValidUrl() {
115+
CommandLine.Model.CommandSpec spec = Mockito.mock(CommandLine.Model.CommandSpec.class);
116+
Mockito.when(spec.commandLine()).thenReturn(Mockito.mock(CommandLine.class));
117+
ApiArguments args = new ApiArguments();
118+
args.setServer("http://valid-url.com");
119+
120+
Assertions.assertThatCode(() -> args.validateValidServer(spec, null))
121+
.doesNotThrowAnyException();
122+
}
53123
}

src/test/java/dev/dochia/cli/core/command/RandomCommandTest.java

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,6 @@
11
package dev.dochia.cli.core.command;
22

3-
import dev.dochia.cli.core.args.ApiArguments;
4-
import dev.dochia.cli.core.args.FilterArguments;
5-
import dev.dochia.cli.core.args.MatchArguments;
6-
import dev.dochia.cli.core.args.ProcessingArguments;
7-
import dev.dochia.cli.core.args.StopArguments;
3+
import dev.dochia.cli.core.args.*;
84
import dev.dochia.cli.core.http.HttpMethod;
95
import io.quarkus.test.junit.QuarkusTest;
106
import jakarta.inject.Inject;
@@ -69,6 +65,7 @@ void shouldThrowExceptionWhenServerNotValid() {
6965
apiArguments.setContract("contract");
7066
apiArguments.setServer("server");
7167
randomCommand.apiArguments = apiArguments;
72-
Assertions.assertThatThrownBy(() -> randomCommand.run()).isInstanceOf(CommandLine.ParameterException.class);
68+
Assertions.assertThatThrownBy(() -> randomCommand.apiArguments.validateValidServer(randomCommand.spec, null))
69+
.isInstanceOf(CommandLine.ParameterException.class);
7370
}
7471
}

src/test/java/dev/dochia/cli/core/command/TestCommandTest.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,8 @@ void shouldThrowExceptionWhenServerNotValid() {
205205
Mockito.when(spec.commandLine()).thenReturn(Mockito.mock(CommandLine.class));
206206
ReflectionTestUtils.setField(testCommand, "spec", spec);
207207
ReflectionTestUtils.setField(apiArguments, "server", "server");
208+
ReflectionTestUtils.setField(apiArguments, "contract", null);
209+
208210
Assertions.assertThatThrownBy(() -> testCommand.run()).isInstanceOf(CommandLine.ParameterException.class);
209211
}
210212
}

0 commit comments

Comments
 (0)