Skip to content

Commit 069abfa

Browse files
joseegarciaclaude
andcommitted
Support loading specifications from a remote URL (Apicurio / HTTP)
`specFile.filePath` may now be an http/https/ftp/file URL; the spec is fetched from that URL at generation time instead of the filesystem/classpath. This enables consuming specs published in an Apicurio Registry (artifacts served over HTTP) and any URL-addressable source, for both the OpenAPI and AsyncAPI generators. - PathUtil.isRemoteUri detects URL-scheme paths. - SchemaUtil.readFile fetches remote specs directly (shared main-spec reader for both generators). - New RemoteFileLocation resolves external $refs relative to a remote spec's URL. - AsyncApiGenerator / BaseAsyncApiHandler resolve remote spec locations. - ApiTool.nodeFromFile handles URL references. - OpenApiGenerator uses the URL parent as the $ref base when the spec is remote. Adds PathUtilTest and a SchemaUtil URL-loading test (file:// URL, network-free). Documents the Apicurio/URL usage in the README. Bumps version 6.5.1 -> 6.6.0 (new feature). Closes joseegman-idoneea#7 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 4261296 commit 069abfa

13 files changed

Lines changed: 175 additions & 9 deletions

File tree

README.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -834,3 +834,31 @@ from a local JAR containing `contracts/event-api.yml` in its resources:
834834
</dependencies>
835835
</plugin>
836836
```
837+
838+
## Loading specifications from a remote URL (Apicurio Registry, HTTP)
839+
840+
`filePath` also accepts a remote URL (`http`, `https`, `ftp` or `file` scheme). When it does,
841+
the specification is downloaded at generation time instead of being read from the filesystem or
842+
the classpath. This works for both OpenAPI and AsyncAPI specs.
843+
844+
This is the mechanism to consume a spec published in an **Apicurio Registry**, whose artifacts are
845+
served over HTTP. Point `filePath` at the artifact's content endpoint, for example:
846+
847+
```xml
848+
<specFile>
849+
<filePath>https://my-apicurio-host/apis/registry/v2/groups/default/artifacts/my-api</filePath>
850+
<apiPackage>com.sngular.apigenerator.openapi.api</apiPackage>
851+
<modelPackage>com.sngular.apigenerator.openapi.model</modelPackage>
852+
</specFile>
853+
```
854+
855+
Gradle:
856+
857+
```groovy
858+
filePath = 'https://my-apicurio-host/apis/registry/v2/groups/default/artifacts/my-api'
859+
```
860+
861+
Notes:
862+
- Only publicly reachable URLs are supported for now; authenticated registries (tokens/headers)
863+
are not yet handled.
864+
- External `$ref`s are resolved relative to the spec's URL when the spec itself is remote.

multiapi-engine/pom.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
<groupId>com.sngular</groupId>
66
<artifactId>multiapi-engine</artifactId>
7-
<version>6.5.1</version>
7+
<version>6.6.0</version>
88
<packaging>jar</packaging>
99

1010
<properties>

multiapi-engine/src/main/java/com/sngular/api/generator/plugin/asyncapi/AsyncApiGenerator.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
import com.sngular.api.generator.plugin.common.files.ClasspathFileLocation;
2727
import com.sngular.api.generator.plugin.common.files.DirectoryFileLocation;
2828
import com.sngular.api.generator.plugin.common.files.FileLocation;
29+
import com.sngular.api.generator.plugin.common.files.RemoteFileLocation;
2930
import com.sngular.api.generator.plugin.common.tools.PathUtil;
3031
import lombok.extern.slf4j.Slf4j;
3132
import org.apache.commons.lang3.tuple.ImmutablePair;
@@ -85,6 +86,15 @@ public final void processFileSpec(final List<SpecFile> specsListFile) {
8586

8687
private static Pair<InputStream, FileLocation> resolveYmlLocation(final String ymlFilePath) throws FileNotFoundException {
8788
log.debug("Resolving YAML file location:{}", ymlFilePath);
89+
if (PathUtil.isRemoteUri(ymlFilePath)) {
90+
log.debug("Loading spec from remote URL");
91+
try {
92+
final URI uri = URI.create(ymlFilePath);
93+
return new ImmutablePair<>(uri.toURL().openStream(), new RemoteFileLocation(uri.resolve(".")));
94+
} catch (final IOException e) {
95+
throw new FileNotFoundException("Could not open remote YAML file: " + ymlFilePath);
96+
}
97+
}
8898
final InputStream classPathInput = AsyncApiGenerator.class.getClassLoader().getResourceAsStream(ymlFilePath);
8999
final InputStream ymlFile;
90100
final FileLocation ymlParentPath;

multiapi-engine/src/main/java/com/sngular/api/generator/plugin/asyncapi/handler/BaseAsyncApiHandler.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@
2121
import com.sngular.api.generator.plugin.asyncapi.parameter.SpecFile;
2222
import com.sngular.api.generator.plugin.asyncapi.template.TemplateFactory;
2323
import com.sngular.api.generator.plugin.common.files.ClasspathFileLocation;
24+
import com.sngular.api.generator.plugin.common.files.RemoteFileLocation;
25+
import com.sngular.api.generator.plugin.common.tools.PathUtil;
2426
import com.sngular.api.generator.plugin.common.files.DirectoryFileLocation;
2527
import com.sngular.api.generator.plugin.common.files.FileLocation;
2628
import com.sngular.api.generator.plugin.common.model.CommonSpecFile;
@@ -108,6 +110,9 @@ protected BaseAsyncApiHandler(
108110
}
109111

110112
protected static FileLocation resolveYmlLocation(final String ymlFilePath) throws IOException, URISyntaxException {
113+
if (PathUtil.isRemoteUri(ymlFilePath)) {
114+
return new RemoteFileLocation(URI.create(ymlFilePath).resolve("."));
115+
}
111116
final var classPathInput = BaseAsyncApiHandler.class.getClassLoader().getResource(ymlFilePath);
112117
if (Objects.nonNull(classPathInput)) {
113118
return new ClasspathFileLocation(getParentUri(classPathInput.toURI()));
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
/*
2+
* This Source Code Form is subject to the terms of the Mozilla Public
3+
* * License, v. 2.0. If a copy of the MPL was not distributed with this
4+
* * file, You can obtain one at https://mozilla.org/MPL/2.0/.
5+
*/
6+
7+
package com.sngular.api.generator.plugin.common.files;
8+
9+
import java.io.IOException;
10+
import java.io.InputStream;
11+
import java.net.URI;
12+
import java.net.URL;
13+
14+
/**
15+
* {@link FileLocation} for specifications loaded from a remote URL (e.g. an Apicurio Registry
16+
* artifact served over HTTP). Relative sibling files (external {@code $ref}s) are resolved
17+
* against the spec's base URI and fetched over the same protocol.
18+
*/
19+
public class RemoteFileLocation implements FileLocation {
20+
21+
private final URI baseUri;
22+
23+
public RemoteFileLocation(final URI baseUri) {
24+
this.baseUri = baseUri;
25+
}
26+
27+
@Override
28+
public InputStream getFileAtLocation(final String filename) throws IOException {
29+
final URL target = baseUri.resolve(filename).toURL();
30+
return target.openStream();
31+
}
32+
33+
@Override
34+
public URI path() {
35+
return baseUri;
36+
}
37+
}

multiapi-engine/src/main/java/com/sngular/api/generator/plugin/common/tools/ApiTool.java

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -453,8 +453,10 @@ public static boolean hasComponents(final JsonNode node) {
453453

454454
public static JsonNode nodeFromFile(final FileLocation ymlParent, final String filePath, final FactoryTypeEnum factoryTypeEnum) throws IOException {
455455
final InputStream file;
456-
// Check if path is absolute first
457-
if (PathUtil.isAbsolutePath(filePath)) {
456+
// Remote references (http/https/ftp/file URLs) are fetched directly from their URL.
457+
if (PathUtil.isRemoteUri(filePath)) {
458+
file = new java.net.URL(filePath).openStream();
459+
} else if (PathUtil.isAbsolutePath(filePath)) {
458460
// For absolute paths, open directly
459461
file = new FileInputStream(filePath);
460462
} else if (filePath.startsWith(PACKAGE_SEPARATOR_STR) || filePath.matches("^\\w.*$")) {

multiapi-engine/src/main/java/com/sngular/api/generator/plugin/common/tools/PathUtil.java

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,4 +37,17 @@ public static boolean isAbsolutePath(final String filePath) {
3737
return false;
3838
}
3939
}
40+
41+
/**
42+
* Checks whether a spec path points to a remote/URL location that must be fetched via a
43+
* {@link java.net.URL} stream rather than the filesystem or classpath. Enables loading
44+
* specifications from HTTP(S) endpoints such as an Apicurio Registry artifact.
45+
*
46+
* @param filePath the spec path to check
47+
* @return true if the path uses an http, https, ftp or file scheme
48+
*/
49+
public static boolean isRemoteUri(final String filePath) {
50+
return StringUtils.isNotEmpty(filePath)
51+
&& filePath.matches("^(?i)(https?|ftp|file)://.*");
52+
}
4053
}

multiapi-engine/src/main/java/com/sngular/api/generator/plugin/common/tools/SchemaUtil.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,12 @@ private static String readFile(final URI rootFilePath, final String filePath) th
137137
throw new IllegalArgumentException("File Path cannot be empty");
138138
}
139139

140+
// Remote specifications (http/https/ftp/file URLs, e.g. an Apicurio Registry artifact) are
141+
// fetched directly from their URL, bypassing classpath and filesystem resolution.
142+
if (PathUtil.isRemoteUri(filePath)) {
143+
return readFromUrl(new URL(filePath));
144+
}
145+
140146
// Normalize the incoming filePath: remove leading './' and replace backslashes with forward slashes
141147
final String cleaned = cleanUpPath(filePath).replace('\\', '/');
142148

@@ -161,6 +167,10 @@ private static String readFile(final URI rootFilePath, final String filePath) th
161167
}
162168
}
163169
}
170+
return readFromUrl(fileURL);
171+
}
172+
173+
private static String readFromUrl(final URL fileURL) {
164174
final var sb = new StringBuilder();
165175
try (BufferedReader reader = new BufferedReader(new InputStreamReader(fileURL.openStream()))) {
166176
String inputLine;

multiapi-engine/src/main/java/com/sngular/api/generator/plugin/openapi/OpenApiGenerator.java

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import java.io.File;
1010
import java.io.IOException;
11+
import java.net.URI;
1112
import java.nio.file.Path;
1213
import java.util.ArrayList;
1314
import java.util.HashSet;
@@ -23,6 +24,7 @@
2324
import com.sngular.api.generator.plugin.common.tools.ApiTool;
2425
import com.sngular.api.generator.plugin.common.tools.MapperContentUtil;
2526
import com.sngular.api.generator.plugin.common.tools.MapperUtil;
27+
import com.sngular.api.generator.plugin.common.tools.PathUtil;
2628
import com.sngular.api.generator.plugin.exception.GeneratorTemplateException;
2729
import com.sngular.api.generator.plugin.openapi.exception.DuplicateModelClassException;
2830
import com.sngular.api.generator.plugin.openapi.model.AuthObject;
@@ -99,7 +101,10 @@ private void processFile(final SpecFile specFile) {
99101

100102
final JsonNode openAPI = OpenApiUtil.getPojoFromSpecFile(baseDir, specFile);
101103
OpenApiUtil.mergeWebhooksIntoPaths(openAPI);
102-
OpenApiUtil.solvePathRefs(openAPI, baseDir.resolve(specFile.getFilePath()).getParent().toUri());
104+
final URI specBaseUri = PathUtil.isRemoteUri(specFile.getFilePath())
105+
? URI.create(specFile.getFilePath()).resolve(".")
106+
: baseDir.resolve(specFile.getFilePath()).getParent().toUri();
107+
OpenApiUtil.solvePathRefs(openAPI, specBaseUri);
103108
final String clientPackage = specFile.getClientPackage();
104109

105110
if (specFile.isCallMode()) {
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/*
2+
* This Source Code Form is subject to the terms of the Mozilla Public
3+
* * License, v. 2.0. If a copy of the MPL was not distributed with this
4+
* * file, You can obtain one at https://mozilla.org/MPL/2.0/.
5+
*/
6+
7+
package com.sngular.api.generator.plugin.common.tools;
8+
9+
import static org.junit.jupiter.api.Assertions.assertFalse;
10+
import static org.junit.jupiter.api.Assertions.assertTrue;
11+
12+
import org.junit.jupiter.api.Test;
13+
14+
class PathUtilTest {
15+
16+
@Test
17+
void isRemoteUriDetectsUrlSchemes() {
18+
assertTrue(PathUtil.isRemoteUri("http://registry/apis/registry/v2/groups/g/artifacts/a"));
19+
assertTrue(PathUtil.isRemoteUri("https://example.com/openapi.yml"));
20+
assertTrue(PathUtil.isRemoteUri("HTTPS://EXAMPLE.COM/openapi.yml"));
21+
assertTrue(PathUtil.isRemoteUri("ftp://host/spec.yml"));
22+
assertTrue(PathUtil.isRemoteUri("file:///tmp/spec.yml"));
23+
}
24+
25+
@Test
26+
void isRemoteUriRejectsLocalPaths() {
27+
assertFalse(PathUtil.isRemoteUri("./src/main/resources/api/api.yml"));
28+
assertFalse(PathUtil.isRemoteUri("/absolute/path/api.yml"));
29+
assertFalse(PathUtil.isRemoteUri("contracts/event-api.yml"));
30+
assertFalse(PathUtil.isRemoteUri("C:\\api\\api.yml"));
31+
assertFalse(PathUtil.isRemoteUri(""));
32+
assertFalse(PathUtil.isRemoteUri(null));
33+
}
34+
}

0 commit comments

Comments
 (0)