resolveYmlLocation(final String ymlFilePath) throws FileNotFoundException {
log.debug("Resolving YAML file location:{}", ymlFilePath);
+ if (PathUtil.isRemoteUri(ymlFilePath)) {
+ log.debug("Loading spec from remote URL");
+ try {
+ final URI uri = URI.create(ymlFilePath);
+ final InputStream remoteStream = PathUtil.openUrlStream(uri.toURL());
+ return new ImmutablePair<>(remoteStream, new RemoteFileLocation(uri.resolve(".")));
+ } catch (final IOException e) {
+ throw new FileNotFoundException("Could not open remote YAML file: " + ymlFilePath);
+ }
+ }
final InputStream classPathInput = AsyncApiGenerator.class.getClassLoader().getResourceAsStream(ymlFilePath);
final InputStream ymlFile;
final FileLocation ymlParentPath;
diff --git a/multiapi-engine/src/main/java/com/sngular/api/generator/plugin/asyncapi/handler/BaseAsyncApiHandler.java b/multiapi-engine/src/main/java/com/sngular/api/generator/plugin/asyncapi/handler/BaseAsyncApiHandler.java
index b3d314b6..ff030d8f 100644
--- a/multiapi-engine/src/main/java/com/sngular/api/generator/plugin/asyncapi/handler/BaseAsyncApiHandler.java
+++ b/multiapi-engine/src/main/java/com/sngular/api/generator/plugin/asyncapi/handler/BaseAsyncApiHandler.java
@@ -21,6 +21,8 @@
import com.sngular.api.generator.plugin.asyncapi.parameter.SpecFile;
import com.sngular.api.generator.plugin.asyncapi.template.TemplateFactory;
import com.sngular.api.generator.plugin.common.files.ClasspathFileLocation;
+import com.sngular.api.generator.plugin.common.files.RemoteFileLocation;
+import com.sngular.api.generator.plugin.common.tools.PathUtil;
import com.sngular.api.generator.plugin.common.files.DirectoryFileLocation;
import com.sngular.api.generator.plugin.common.files.FileLocation;
import com.sngular.api.generator.plugin.common.model.CommonSpecFile;
@@ -108,6 +110,9 @@ protected BaseAsyncApiHandler(
}
protected static FileLocation resolveYmlLocation(final String ymlFilePath) throws IOException, URISyntaxException {
+ if (PathUtil.isRemoteUri(ymlFilePath)) {
+ return new RemoteFileLocation(URI.create(ymlFilePath).resolve("."));
+ }
final var classPathInput = BaseAsyncApiHandler.class.getClassLoader().getResource(ymlFilePath);
if (Objects.nonNull(classPathInput)) {
return new ClasspathFileLocation(getParentUri(classPathInput.toURI()));
diff --git a/multiapi-engine/src/main/java/com/sngular/api/generator/plugin/common/files/RemoteFileLocation.java b/multiapi-engine/src/main/java/com/sngular/api/generator/plugin/common/files/RemoteFileLocation.java
new file mode 100644
index 00000000..ad05e567
--- /dev/null
+++ b/multiapi-engine/src/main/java/com/sngular/api/generator/plugin/common/files/RemoteFileLocation.java
@@ -0,0 +1,39 @@
+/*
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * * file, You can obtain one at https://mozilla.org/MPL/2.0/.
+ */
+
+package com.sngular.api.generator.plugin.common.files;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URI;
+import java.net.URL;
+
+import com.sngular.api.generator.plugin.common.tools.PathUtil;
+
+/**
+ * {@link FileLocation} for specifications loaded from a remote URL (e.g. an Apicurio Registry
+ * artifact served over HTTP). Relative sibling files (external {@code $ref}s) are resolved
+ * against the spec's base URI and fetched over the same protocol.
+ */
+public class RemoteFileLocation implements FileLocation {
+
+ private final URI baseUri;
+
+ public RemoteFileLocation(final URI baseUri) {
+ this.baseUri = baseUri;
+ }
+
+ @Override
+ public InputStream getFileAtLocation(final String filename) throws IOException {
+ final URL target = baseUri.resolve(filename).toURL();
+ return PathUtil.openUrlStream(target);
+ }
+
+ @Override
+ public URI path() {
+ return baseUri;
+ }
+}
diff --git a/multiapi-engine/src/main/java/com/sngular/api/generator/plugin/common/tools/ApiTool.java b/multiapi-engine/src/main/java/com/sngular/api/generator/plugin/common/tools/ApiTool.java
index d6872a2e..4397ce00 100644
--- a/multiapi-engine/src/main/java/com/sngular/api/generator/plugin/common/tools/ApiTool.java
+++ b/multiapi-engine/src/main/java/com/sngular/api/generator/plugin/common/tools/ApiTool.java
@@ -5,6 +5,7 @@
import java.io.InputStream;
import java.math.BigDecimal;
import java.math.BigInteger;
+import java.net.URI;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
@@ -453,8 +454,10 @@ public static boolean hasComponents(final JsonNode node) {
public static JsonNode nodeFromFile(final FileLocation ymlParent, final String filePath, final FactoryTypeEnum factoryTypeEnum) throws IOException {
final InputStream file;
- // Check if path is absolute first
- if (PathUtil.isAbsolutePath(filePath)) {
+ // Remote references (http/https/ftp/file URLs) are fetched directly from their URL.
+ if (PathUtil.isRemoteUri(filePath)) {
+ file = PathUtil.openUrlStream(URI.create(filePath).toURL());
+ } else if (PathUtil.isAbsolutePath(filePath)) {
// For absolute paths, open directly
file = new FileInputStream(filePath);
} else if (filePath.startsWith(PACKAGE_SEPARATOR_STR) || filePath.matches("^\\w.*$")) {
diff --git a/multiapi-engine/src/main/java/com/sngular/api/generator/plugin/common/tools/PathUtil.java b/multiapi-engine/src/main/java/com/sngular/api/generator/plugin/common/tools/PathUtil.java
index 2c8eee6c..3e4395d8 100644
--- a/multiapi-engine/src/main/java/com/sngular/api/generator/plugin/common/tools/PathUtil.java
+++ b/multiapi-engine/src/main/java/com/sngular/api/generator/plugin/common/tools/PathUtil.java
@@ -6,8 +6,16 @@
package com.sngular.api.generator.plugin.common.tools;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URL;
+import java.net.URLConnection;
+import java.nio.charset.StandardCharsets;
import java.nio.file.InvalidPathException;
import java.nio.file.Paths;
+import java.util.Base64;
+import java.util.LinkedHashMap;
+import java.util.Map;
import org.apache.commons.lang3.StringUtils;
@@ -16,6 +24,12 @@
*/
public final class PathUtil {
+ /** Connection timeout (ms) applied when fetching a remote specification. */
+ public static final int REMOTE_CONNECT_TIMEOUT_MS = 15_000;
+
+ /** Read timeout (ms) applied when fetching a remote specification. */
+ public static final int REMOTE_READ_TIMEOUT_MS = 30_000;
+
private PathUtil() {
// Utility class
}
@@ -37,4 +51,92 @@ public static boolean isAbsolutePath(final String filePath) {
return false;
}
}
+
+ /**
+ * Checks whether a spec path points to a remote/URL location that must be fetched via a
+ * {@link java.net.URL} stream rather than the filesystem or classpath. Enables loading
+ * specifications from HTTP(S) endpoints such as an Apicurio Registry artifact.
+ *
+ * @param filePath the spec path to check
+ * @return true if the path uses an http, https or file scheme
+ */
+ public static boolean isRemoteUri(final String filePath) {
+ return StringUtils.isNotEmpty(filePath)
+ && filePath.matches("^(?i)(https?|file)://.*");
+ }
+
+ /**
+ * Opens a stream to a URL with bounded connect/read timeouts, so an unresponsive remote
+ * spec server (e.g. an Apicurio Registry) cannot hang the build indefinitely. Timeouts are
+ * harmless for non-network schemes such as {@code file:} and {@code jar:}. For {@code http}/
+ * {@code https} URLs any configured authentication headers (see {@link #remoteAuthHeaders})
+ * are applied.
+ *
+ * @param url the URL to open
+ * @return the input stream
+ * @throws IOException if the connection cannot be established or times out
+ */
+ public static InputStream openUrlStream(final URL url) throws IOException {
+ final URLConnection connection = url.openConnection();
+ connection.setConnectTimeout(REMOTE_CONNECT_TIMEOUT_MS);
+ connection.setReadTimeout(REMOTE_READ_TIMEOUT_MS);
+ final String protocol = url.getProtocol();
+ if ("http".equalsIgnoreCase(protocol) || "https".equalsIgnoreCase(protocol)) {
+ remoteAuthHeaders(url.getHost()).forEach(connection::setRequestProperty);
+ }
+ return connection.getInputStream();
+ }
+
+ /**
+ * Resolves the authentication headers to attach when fetching a remote spec, from system
+ * properties (preferred) or environment variables. Credentials are never read from the build
+ * files. Supported schemes (first match wins for {@code Authorization}):
+ *
+ * - Bearer token: {@code scs.multiapi.remote.token} / {@code SCS_MULTIAPI_REMOTE_TOKEN}
+ * - Basic auth: {@code scs.multiapi.remote.user}(+{@code .password}) /
+ * {@code SCS_MULTIAPI_REMOTE_USER}(+{@code _PASSWORD})
+ *
+ * plus an optional arbitrary header ({@code scs.multiapi.remote.header.name}/{@code .value} or
+ * {@code SCS_MULTIAPI_REMOTE_HEADER_NAME}/{@code _VALUE}), e.g. an {@code X-Registry-ApiKey}.
+ *
+ * When {@code scs.multiapi.remote.host} / {@code SCS_MULTIAPI_REMOTE_HOST} is set, credentials
+ * are only sent to that host, so a token is never leaked to a different host reached through an
+ * external {@code $ref} or a cross-host redirect.
+ *
+ * @param host the host of the URL being fetched
+ * @return the headers to apply (empty if none configured, or scoped out by host)
+ */
+ public static Map remoteAuthHeaders(final String host) {
+ final Map headers = new LinkedHashMap<>();
+
+ final String scopedHost = config("scs.multiapi.remote.host", "SCS_MULTIAPI_REMOTE_HOST");
+ if (StringUtils.isNotBlank(scopedHost) && !scopedHost.equalsIgnoreCase(host)) {
+ return headers;
+ }
+
+ final String token = config("scs.multiapi.remote.token", "SCS_MULTIAPI_REMOTE_TOKEN");
+ final String user = config("scs.multiapi.remote.user", "SCS_MULTIAPI_REMOTE_USER");
+ if (StringUtils.isNotBlank(token)) {
+ headers.put("Authorization", "Bearer " + token);
+ } else if (StringUtils.isNotBlank(user)) {
+ final String password = StringUtils.defaultString(
+ config("scs.multiapi.remote.password", "SCS_MULTIAPI_REMOTE_PASSWORD"));
+ final String credentials = user + ":" + password;
+ final String basic = Base64.getEncoder().encodeToString(credentials.getBytes(StandardCharsets.UTF_8));
+ headers.put("Authorization", "Basic " + basic);
+ }
+
+ final String headerName = config("scs.multiapi.remote.header.name", "SCS_MULTIAPI_REMOTE_HEADER_NAME");
+ final String headerValue = config("scs.multiapi.remote.header.value", "SCS_MULTIAPI_REMOTE_HEADER_VALUE");
+ if (StringUtils.isNotBlank(headerName) && StringUtils.isNotBlank(headerValue)) {
+ headers.put(headerName, headerValue);
+ }
+
+ return headers;
+ }
+
+ private static String config(final String systemProperty, final String environmentVariable) {
+ final String fromProperty = System.getProperty(systemProperty);
+ return StringUtils.isNotBlank(fromProperty) ? fromProperty : System.getenv(environmentVariable);
+ }
}
diff --git a/multiapi-engine/src/main/java/com/sngular/api/generator/plugin/common/tools/SchemaUtil.java b/multiapi-engine/src/main/java/com/sngular/api/generator/plugin/common/tools/SchemaUtil.java
index 95fd95ac..7447bf50 100644
--- a/multiapi-engine/src/main/java/com/sngular/api/generator/plugin/common/tools/SchemaUtil.java
+++ b/multiapi-engine/src/main/java/com/sngular/api/generator/plugin/common/tools/SchemaUtil.java
@@ -137,6 +137,12 @@ private static String readFile(final URI rootFilePath, final String filePath) th
throw new IllegalArgumentException("File Path cannot be empty");
}
+ // Remote specifications (http/https/ftp/file URLs, e.g. an Apicurio Registry artifact) are
+ // fetched directly from their URL, bypassing classpath and filesystem resolution.
+ if (PathUtil.isRemoteUri(filePath)) {
+ return readFromUrl(URI.create(filePath).toURL());
+ }
+
// Normalize the incoming filePath: remove leading './' and replace backslashes with forward slashes
final String cleaned = cleanUpPath(filePath).replace('\\', '/');
@@ -161,8 +167,12 @@ private static String readFile(final URI rootFilePath, final String filePath) th
}
}
}
+ return readFromUrl(fileURL);
+ }
+
+ private static String readFromUrl(final URL fileURL) {
final var sb = new StringBuilder();
- try (BufferedReader reader = new BufferedReader(new InputStreamReader(fileURL.openStream()))) {
+ try (BufferedReader reader = new BufferedReader(new InputStreamReader(PathUtil.openUrlStream(fileURL)))) {
String inputLine;
while ((inputLine = reader.readLine()) != null) {
sb.append(inputLine).append(System.lineSeparator());
diff --git a/multiapi-engine/src/main/java/com/sngular/api/generator/plugin/openapi/OpenApiGenerator.java b/multiapi-engine/src/main/java/com/sngular/api/generator/plugin/openapi/OpenApiGenerator.java
index 6add6dd0..fcbffce1 100644
--- a/multiapi-engine/src/main/java/com/sngular/api/generator/plugin/openapi/OpenApiGenerator.java
+++ b/multiapi-engine/src/main/java/com/sngular/api/generator/plugin/openapi/OpenApiGenerator.java
@@ -8,6 +8,7 @@
import java.io.File;
import java.io.IOException;
+import java.net.URI;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashSet;
@@ -23,6 +24,7 @@
import com.sngular.api.generator.plugin.common.tools.ApiTool;
import com.sngular.api.generator.plugin.common.tools.MapperContentUtil;
import com.sngular.api.generator.plugin.common.tools.MapperUtil;
+import com.sngular.api.generator.plugin.common.tools.PathUtil;
import com.sngular.api.generator.plugin.exception.GeneratorTemplateException;
import com.sngular.api.generator.plugin.openapi.exception.DuplicateModelClassException;
import com.sngular.api.generator.plugin.openapi.model.AuthObject;
@@ -99,7 +101,10 @@ private void processFile(final SpecFile specFile) {
final JsonNode openAPI = OpenApiUtil.getPojoFromSpecFile(baseDir, specFile);
OpenApiUtil.mergeWebhooksIntoPaths(openAPI);
- OpenApiUtil.solvePathRefs(openAPI, baseDir.resolve(specFile.getFilePath()).getParent().toUri());
+ final URI specBaseUri = PathUtil.isRemoteUri(specFile.getFilePath())
+ ? URI.create(specFile.getFilePath()).resolve(".")
+ : baseDir.resolve(specFile.getFilePath()).getParent().toUri();
+ OpenApiUtil.solvePathRefs(openAPI, specBaseUri);
final String clientPackage = specFile.getClientPackage();
if (specFile.isCallMode()) {
diff --git a/multiapi-engine/src/test/java/com/sngular/api/generator/plugin/common/tools/PathUtilTest.java b/multiapi-engine/src/test/java/com/sngular/api/generator/plugin/common/tools/PathUtilTest.java
new file mode 100644
index 00000000..b1adc2fe
--- /dev/null
+++ b/multiapi-engine/src/test/java/com/sngular/api/generator/plugin/common/tools/PathUtilTest.java
@@ -0,0 +1,89 @@
+/*
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * * file, You can obtain one at https://mozilla.org/MPL/2.0/.
+ */
+
+package com.sngular.api.generator.plugin.common.tools;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+import java.util.Map;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+
+class PathUtilTest {
+
+ private static final String[] AUTH_PROPS = {
+ "scs.multiapi.remote.host", "scs.multiapi.remote.token", "scs.multiapi.remote.user",
+ "scs.multiapi.remote.password", "scs.multiapi.remote.header.name", "scs.multiapi.remote.header.value"
+ };
+
+ @AfterEach
+ void clearAuthProps() {
+ for (final String prop : AUTH_PROPS) {
+ System.clearProperty(prop);
+ }
+ }
+
+ @Test
+ void isRemoteUriDetectsUrlSchemes() {
+ assertTrue(PathUtil.isRemoteUri("http://registry/apis/registry/v2/groups/g/artifacts/a"));
+ assertTrue(PathUtil.isRemoteUri("https://example.com/openapi.yml"));
+ assertTrue(PathUtil.isRemoteUri("HTTPS://EXAMPLE.COM/openapi.yml"));
+ assertTrue(PathUtil.isRemoteUri("file:///tmp/spec.yml"));
+ }
+
+ @Test
+ void isRemoteUriRejectsLocalPaths() {
+ assertFalse(PathUtil.isRemoteUri("ftp://host/spec.yml"));
+ assertFalse(PathUtil.isRemoteUri("./src/main/resources/api/api.yml"));
+ assertFalse(PathUtil.isRemoteUri("/absolute/path/api.yml"));
+ assertFalse(PathUtil.isRemoteUri("contracts/event-api.yml"));
+ assertFalse(PathUtil.isRemoteUri("C:\\api\\api.yml"));
+ assertFalse(PathUtil.isRemoteUri(""));
+ assertFalse(PathUtil.isRemoteUri(null));
+ }
+
+ @Test
+ void remoteAuthHeadersEmptyWhenUnconfigured() {
+ assertTrue(PathUtil.remoteAuthHeaders("registry.example.com").isEmpty());
+ }
+
+ @Test
+ void remoteAuthHeadersBearerToken() {
+ System.setProperty("scs.multiapi.remote.token", "abc123");
+ final Map headers = PathUtil.remoteAuthHeaders("registry.example.com");
+ assertEquals("Bearer abc123", headers.get("Authorization"));
+ }
+
+ @Test
+ void remoteAuthHeadersBasicAuth() {
+ System.setProperty("scs.multiapi.remote.user", "alice");
+ System.setProperty("scs.multiapi.remote.password", "s3cret");
+ final String expected = "Basic " + Base64.getEncoder().encodeToString("alice:s3cret".getBytes(StandardCharsets.UTF_8));
+ assertEquals(expected, PathUtil.remoteAuthHeaders("registry.example.com").get("Authorization"));
+ }
+
+ @Test
+ void remoteAuthHeadersArbitraryHeader() {
+ System.setProperty("scs.multiapi.remote.header.name", "X-Registry-ApiKey");
+ System.setProperty("scs.multiapi.remote.header.value", "key-42");
+ assertEquals("key-42", PathUtil.remoteAuthHeaders("registry.example.com").get("X-Registry-ApiKey"));
+ }
+
+ @Test
+ void remoteAuthHeadersScopedToConfiguredHost() {
+ System.setProperty("scs.multiapi.remote.token", "abc123");
+ System.setProperty("scs.multiapi.remote.host", "registry.example.com");
+ // Matching host receives the credentials.
+ assertEquals("Bearer abc123", PathUtil.remoteAuthHeaders("registry.example.com").get("Authorization"));
+ // A different host (e.g. reached via an external $ref) must NOT receive them.
+ assertTrue(PathUtil.remoteAuthHeaders("evil.example.org").isEmpty());
+ }
+}
diff --git a/multiapi-engine/src/test/java/com/sngular/api/generator/plugin/common/tools/SchemaUtilTest.java b/multiapi-engine/src/test/java/com/sngular/api/generator/plugin/common/tools/SchemaUtilTest.java
index b28d25c8..1656c199 100644
--- a/multiapi-engine/src/test/java/com/sngular/api/generator/plugin/common/tools/SchemaUtilTest.java
+++ b/multiapi-engine/src/test/java/com/sngular/api/generator/plugin/common/tools/SchemaUtilTest.java
@@ -42,4 +42,26 @@ void shouldResolveBackslashRelativePath() throws Exception {
try { Files.walk(tmp).map(Path::toFile).sorted((a,b)->-a.compareTo(b)).forEach(java.io.File::delete); } catch (Exception ignored) {}
}
}
+
+ @Test
+ void shouldLoadSpecFromUrl() throws Exception {
+ // Loading a spec from a URL is the mechanism used for remote sources (e.g. an Apicurio
+ // Registry artifact). A file:// URL exercises the same URL code path without network access.
+ final Path tmp = Files.createTempDirectory("schema-util-url-test");
+ try {
+ final Path openapiFile = tmp.resolve("openapi.yml");
+ Files.writeString(openapiFile, "components:\n schemas:\n Pet:\n type: object\n");
+
+ final String url = openapiFile.toUri().toString(); // file:///...
+ assertTrue(PathUtil.isRemoteUri(url), "A file:// path must be detected as a remote URI");
+
+ // rootFilePath is irrelevant for a remote URL; it is fetched directly.
+ final JsonNode node = SchemaUtil.getPojoFromRef(tmp.toUri(), url);
+
+ assertNotNull(node, "El nodo no debe ser nulo");
+ assertTrue(node.path("components").path("schemas").has("Pet"), "Debe contener el schema 'Pet'");
+ } finally {
+ try { Files.walk(tmp).map(Path::toFile).sorted((a, b) -> -a.compareTo(b)).forEach(java.io.File::delete); } catch (Exception ignored) {}
+ }
+ }
}
diff --git a/scs-multiapi-gradle-plugin/build.gradle b/scs-multiapi-gradle-plugin/build.gradle
index 82437b6d..92bca8f8 100644
--- a/scs-multiapi-gradle-plugin/build.gradle
+++ b/scs-multiapi-gradle-plugin/build.gradle
@@ -21,7 +21,7 @@ repositories {
}
group = 'com.sngular'
-version = '6.5.3'
+version = '6.6.0'
def SCSMultiApiPluginGroupId = group
def SCSMultiApiPluginVersion = version
@@ -31,7 +31,7 @@ dependencies {
shadow localGroovy()
shadow gradleApi()
- implementation 'com.sngular:multiapi-engine:6.5.3'
+ implementation 'com.sngular:multiapi-engine:6.6.0'
testImplementation 'org.assertj:assertj-core:3.24.2'
testImplementation 'com.puppycrawl.tools:checkstyle:10.12.3'
testImplementation 'org.junit.platform:junit-platform-launcher:1.9.2'
@@ -100,7 +100,7 @@ testing {
integrationTest(JvmTestSuite) {
dependencies {
- implementation 'com.sngular:scs-multiapi-gradle-plugin:6.5.3'
+ implementation 'com.sngular:scs-multiapi-gradle-plugin:6.6.0'
implementation 'org.assertj:assertj-core:3.24.2'
}
diff --git a/scs-multiapi-maven-plugin/pom.xml b/scs-multiapi-maven-plugin/pom.xml
index ce1f5329..b671b797 100644
--- a/scs-multiapi-maven-plugin/pom.xml
+++ b/scs-multiapi-maven-plugin/pom.xml
@@ -4,7 +4,7 @@
com.sngular
scs-multiapi-maven-plugin
- 6.5.3
+ 6.6.0
maven-plugin
AsyncApi - OpenApi Code Generator Maven Plugin
@@ -271,7 +271,7 @@
com.sngular
multiapi-engine
- 6.5.3
+ 6.6.0
org.apache.maven