diff --git a/annot/src/main/java/com/predic8/membrane/annot/SpringConfigurationXSDGeneratingAnnotationProcessor.java b/annot/src/main/java/com/predic8/membrane/annot/SpringConfigurationXSDGeneratingAnnotationProcessor.java index 44a1075b5d..55a0bc1cfc 100644 --- a/annot/src/main/java/com/predic8/membrane/annot/SpringConfigurationXSDGeneratingAnnotationProcessor.java +++ b/annot/src/main/java/com/predic8/membrane/annot/SpringConfigurationXSDGeneratingAnnotationProcessor.java @@ -565,6 +565,8 @@ public void process(Model m) throws IOException { return; // we will be called again to handle the newly generated class. if (new BeanClassGenerator(processingEnv).writeJava(m)) return; // we will be called again to handle the newly generated class. + if (new IncludeListClassGenerator(processingEnv).writeJava(m)) + return; // we will be called again to handle the newly generated class. new Schemas(processingEnv).writeXSD(m); new KubernetesBootstrapper(processingEnv).boot(m); new JsonSchemaGenerator(processingEnv).write(m); diff --git a/annot/src/main/java/com/predic8/membrane/annot/beanregistry/BeanCollector.java b/annot/src/main/java/com/predic8/membrane/annot/beanregistry/BeanCollector.java index f04135aab6..80632ac068 100644 --- a/annot/src/main/java/com/predic8/membrane/annot/beanregistry/BeanCollector.java +++ b/annot/src/main/java/com/predic8/membrane/annot/beanregistry/BeanCollector.java @@ -16,12 +16,14 @@ import com.predic8.membrane.annot.Grammar; import com.predic8.membrane.annot.yaml.GenericYamlParser; -import com.predic8.membrane.annot.yaml.WatchAction; import java.io.IOException; import java.io.InputStream; +import java.nio.file.Path; import java.util.List; +import static com.predic8.membrane.annot.yaml.WatchAction.ADDED; + /** * This is the definition side of a {@link BeanRegistryImplementation}. You can start the bean registry * and send it a series of change events. @@ -29,9 +31,13 @@ public interface BeanCollector { default List parseYamlBeanDefinitions(InputStream yamls, Grammar grammar) throws IOException { - List bds = GenericYamlParser.parseMembraneResources(yamls, grammar); + return parseYamlBeanDefinitions(yamls, grammar, null); + } + + default List parseYamlBeanDefinitions(InputStream yamls, Grammar grammar, Path rootSourceFile) throws IOException { + List bds = GenericYamlParser.parseMembraneResources(yamls, grammar, rootSourceFile); for (BeanDefinition bd : bds) { - handle(new BeanDefinitionChanged(WatchAction.ADDED, bd), false); + handle(new BeanDefinitionChanged(ADDED, bd), false); } return bds; } diff --git a/annot/src/main/java/com/predic8/membrane/annot/generator/IncludeListClassGenerator.java b/annot/src/main/java/com/predic8/membrane/annot/generator/IncludeListClassGenerator.java new file mode 100644 index 0000000000..97f84a9d14 --- /dev/null +++ b/annot/src/main/java/com/predic8/membrane/annot/generator/IncludeListClassGenerator.java @@ -0,0 +1,87 @@ +/* Copyright 2026 predic8 GmbH, www.predic8.com + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. */ + +package com.predic8.membrane.annot.generator; + +import javax.annotation.processing.ProcessingEnvironment; + +public class IncludeListClassGenerator extends ClassGenerator { + + public IncludeListClassGenerator(ProcessingEnvironment processingEnv) { + super(processingEnv); + } + + @Override + protected String getClassName() { + return "IncludeList"; + } + + @Override + protected String getClassImpl() { + return """ + import com.predic8.membrane.annot.MCChildElement; + import com.predic8.membrane.annot.MCElement; + + import java.util.ArrayList; + import java.util.List; + + /** + * @description

+ * Includes additional YAML configuration files before parsing the current file's own configuration. + *

+ *

+ * Include entries are resolved in the order they are listed. If an entry points to a directory, + * all matching *.apis.yaml / *.apis.yml files in that directory are included. + * For directory includes, no ordering is applied. Includes are resolved recursively and cyclic + * include chains are rejected. + *

+ *

+ * Paths used inside included configuration content (for example OpenAPI file locations or other + * referenced resources) are resolved against the base path of the main configuration file. + *

+ * @yaml

+                 * include:
+                 *   - "."
+                 *   - apis/demo.apis.yaml
+                 *   - other/apis
+                 * 
+ */ + @MCElement(name = "include", topLevel = true, noEnvelope = true, component = false) + public class IncludeList { + + List includes = new ArrayList<>(); + + /** + * @description

+ * Declares include entries as a list of strings. + *

+ *

+ * Each string is a path to either a YAML file or a directory. Relative paths are resolved + * against the directory of the including file. Absolute paths are also supported. + *

+ */ + @MCChildElement(allowForeign = true) + public void setInclude(List include) { + this.includes = include; + } + + public List getInclude() { + return includes; + } + + } + """; + } + +} diff --git a/annot/src/main/java/com/predic8/membrane/annot/yaml/GenericYamlParser.java b/annot/src/main/java/com/predic8/membrane/annot/yaml/GenericYamlParser.java index d06f307871..f297321fb1 100644 --- a/annot/src/main/java/com/predic8/membrane/annot/yaml/GenericYamlParser.java +++ b/annot/src/main/java/com/predic8/membrane/annot/yaml/GenericYamlParser.java @@ -13,7 +13,6 @@ limitations under the License. */ package com.predic8.membrane.annot.yaml; -import com.fasterxml.jackson.core.JsonLocation; import com.fasterxml.jackson.core.JsonParseException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; @@ -32,10 +31,16 @@ import java.io.IOException; import java.io.InputStream; import java.lang.reflect.*; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayDeque; import java.util.ArrayList; +import java.util.Deque; import java.util.Iterator; import java.util.List; import java.util.Objects; +import java.util.HashSet; +import java.util.Set; import static com.predic8.membrane.annot.yaml.McYamlIntrospector.*; import static com.predic8.membrane.annot.yaml.MethodSetter.getCollectionElementType; @@ -44,6 +49,7 @@ import static com.predic8.membrane.annot.yaml.YamlParsingUtils.*; import static java.lang.reflect.Modifier.isAbstract; import static java.nio.charset.StandardCharsets.UTF_8; +import static java.nio.file.Files.readString; import static java.util.List.of; import static java.util.Locale.ROOT; import static java.util.UUID.randomUUID; @@ -58,82 +64,191 @@ public class GenericYamlParser { /** * Parses one or more YAML documents into bean definitions. - *

- * The input string may contain multiple YAML documents separated by '---'. Each non-empty - * document is validated against the schema provided by {@link Grammar} and then - * turned into a {@link BeanDefinition}. Validation errors are mapped back to line/column - * numbers using {@link JsonLocationMap} to produce helpful error messages. - *

* - * @param grammar provides schema location and Java type resolution - * @param yaml the raw YAML content (may contain multi-document stream) + * @param grammar provides schema location and Java type resolution + * @param yaml the raw YAML content (may contain multi-document stream) + * @param rootSourceFile optional path to the root YAML file; used to resolve relative includes * @throws IOException if schema loading or validation fails */ - public GenericYamlParser(Grammar grammar, String yaml) throws IOException { - JsonLocationMap jsonLocationMap = new JsonLocationMap(); - - var idx = 0; - for (JsonNode jsonNode : jsonLocationMap.parseWithLocations(yaml)) { - if (jsonNode == null || jsonNode.isNull() || jsonNode.isEmpty()) { - log.debug(GenericYamlParser.EMPTY_DOCUMENT_WARNING); - continue; - } + public GenericYamlParser(Grammar grammar, String yaml, Path rootSourceFile) throws IOException { + beanDefs.addAll(parseYamlFile(grammar, yaml, IncludeContext.root(rootSourceFile), new int[]{0}, new HashSet<>())); + } - // Deactivated temporarily to get better error messages - //validateAgainstSchema(grammar, jsonNode, jsonLocationMap); + private static List parseYamlFile(Grammar grammar, String yaml, IncludeContext includeContext, int[] beanIndex, Set componentIds) throws IOException { + Snippets snippets = collectSnippets(grammar, yaml, new JsonLocationMap()); + List defs = new ArrayList<>(); + defs.addAll(handleIncludes(grammar, includeContext, beanIndex, componentIds, snippets.includes())); + defs.addAll(handleConfigSnippets(grammar, beanIndex, componentIds, snippets.configSnippets())); + return defs; + } + private static List handleConfigSnippets(Grammar grammar, int[] beanIndex, Set componentIds, List configSnippets) { + List defs = new ArrayList<>(); + for (JsonNode jsonNode : configSnippets) { var pc = new ParsingContext<>("", null, grammar, jsonNode, "$", null); + String beanType = getBeanType(pc, jsonNode); - if ("components".equals(getBeanType(pc, jsonNode))) { - beanDefs.addAll(extractComponentBeanDefinitions(pc.addPath(".components"), jsonNode.get("components"))); + if ("components".equals(beanType)) { + defs.addAll(extractComponentBeanDefinitions(pc.addPath(".components"), jsonNode.get("components"), componentIds)); } - beanDefs.add(new BeanDefinition( - getBeanType(pc, jsonNode), - "bean-" + idx++, + defs.add(new BeanDefinition( + beanType, + "bean-" + beanIndex[0]++, "default", randomUUID().toString(), jsonNode)); } + return defs; + } + + private static List handleIncludes(Grammar grammar, IncludeContext includeContext, int[] beanIndex, Set componentIds, List includes) throws IOException { + List defs = new ArrayList<>(); + for (IncludeSnippet includeSnippet : includes) { + for (IncludeEntry includeEntry : extractIncludeEntries(includeSnippet)) { + defs.addAll(parseIncludedPath( + grammar, + includeContext.resolveIncludePath(includeEntry.path()), + includeContext, + beanIndex, + componentIds, + includeEntry.parsingContext())); + } + } + return defs; + } + + private static Snippets collectSnippets(Grammar grammar, String yaml, JsonLocationMap jsonLocationMap) throws IOException { + List includes = new ArrayList<>(); + List config = new ArrayList<>(); + for (JsonNode jsonNode : jsonLocationMap.parseWithLocations(yaml)) { + if (jsonNode == null || jsonNode.isNull() || jsonNode.isEmpty()) { + log.debug(EMPTY_DOCUMENT_WARNING); + continue; + } + + if (isIncludeDocument(jsonNode)) { + includes.add(new IncludeSnippet( + jsonNode.get("include"), + new ParsingContext<>("", null, grammar, jsonNode, "$", "include") + )); + continue; + } + config.add(jsonNode); + } + return new Snippets(includes, config); + } + + private static List parseIncludedPath(Grammar grammar, Path includePath, IncludeContext includeContext, int[] beanIndex, Set componentIds, ParsingContext includePc) throws IOException { + if (!Files.exists(includePath)) + throw new ConfigurationParsingException("Included path '%s' does not exist.".formatted(includePath), null, includePc); + + if (Files.isDirectory(includePath)) { + List res = new ArrayList<>(); + try (var files = Files.list(includePath)) { + for (Path file : files.filter(Files::isRegularFile).filter(GenericYamlParser::isApisYaml).sorted().toList()) { + res.addAll(parseIncludedFile(grammar, file, includeContext, beanIndex, componentIds, includePc)); + } + } + return res; + } + + if (!Files.isRegularFile(includePath)) + throw new ConfigurationParsingException("Included path '%s' is neither a regular file nor a directory.".formatted(includePath), null, includePc); + + return parseIncludedFile(grammar, includePath, includeContext, beanIndex, componentIds, includePc); } - private static void validateAgainstSchema(Grammar grammar, JsonNode jsonNode, JsonLocationMap jsonLocationMap) throws IOException { - // Validate YAML against JSON schema + private static List parseIncludedFile(Grammar grammar, Path includeFile, IncludeContext includeContext, int[] beanIndex, Set componentIds, ParsingContext includePc) throws IOException { + Path normalizedFile = normalizePath(includeFile); + if (includeContext.includeStack().contains(normalizedFile)) + throw new ConfigurationParsingException("Cyclic include detected: " + formatIncludeCycle(includeContext.includeStack(), normalizedFile), null, includePc); + + includeContext.includeStack().addLast(normalizedFile); try { - validate(grammar, jsonNode); - } catch (YamlSchemaValidationException e) { - JsonLocation location = jsonLocationMap.getLocationMap().get( - e.getErrors().getFirst().getInstanceNode()); - throw new IOException("Invalid YAML: %s at line %d, column %d.".formatted( - e.getErrors().getFirst().getMessage(), - location.getLineNr(), - location.getColumnNr()), e); + String includedYaml; + try { + includedYaml = readString(normalizedFile, UTF_8); + } catch (IOException e) { + throw new ConfigurationParsingException("Could not read included file '%s'.".formatted(normalizedFile), e, includePc); + } + + try { + return parseYamlFile(grammar, includedYaml, includeContext.withSourceFile(normalizedFile), beanIndex, componentIds); + } catch (JsonParseException e) { + throw new ConfigurationParsingException( + "Invalid YAML in included file '%s' (at line %d, column %d)." + .formatted(normalizedFile, e.getLocation().getLineNr(), e.getLocation().getColumnNr()), + e, + includePc); + } + } finally { + includeContext.includeStack().removeLast(); + } + } + + private static boolean isIncludeDocument(JsonNode jsonNode) { + return jsonNode.isObject() && jsonNode.size() == 1 && jsonNode.has("include"); + } + + private static List extractIncludeEntries(IncludeSnippet includeSnippet) { + JsonNode includeNode = includeSnippet.node(); + ParsingContext includePc = includeSnippet.parsingContext(); + if (includeNode == null || includeNode.isNull()) + return of(); + + if (!includeNode.isArray()) + throw new ConfigurationParsingException("The 'include' value must be an array of strings.", null, includePc); + + List includes = new ArrayList<>(); + ParsingContext includeArrayPc = includePc.addPath(".include"); + for (int i = 0; i < includeNode.size(); i++) { + JsonNode item = includeNode.get(i); + if (!item.isTextual()) + throw new ConfigurationParsingException("The 'include' array must only contain strings.", null, includeArrayPc.key(String.valueOf(i))); + includes.add(new IncludeEntry(item.asText(), includeArrayPc.key(String.valueOf(i)))); + } + return includes; + } + + private static boolean isApisYaml(Path file) { + String name = file.getFileName().toString().toLowerCase(ROOT); + return name.endsWith(".apis.yaml") || name.endsWith(".apis.yml"); + } + + private static Path normalizePath(Path path) { + if (path == null) + return null; + return path.toAbsolutePath().normalize(); + } + + private static String formatIncludeCycle(Deque includeStack, Path repeatedPath) { + List cycle = new ArrayList<>(); + for (Path path : includeStack) { + cycle.add(path.toString()); } + cycle.add(repeatedPath.toString()); + return String.join(" -> ", cycle); } /** - * Entry point used by the runtime to consume a YAML stream. - *
    - *
  • Reads the entire stream as UTF-8.
  • - *
  • Splits multi-document YAML ("---" separators).
  • - *
  • Validates each document against the JSON Schema provided by {@code grammar}.
  • - *
  • Emits helpful line/column locations for malformed multi-document input.
  • - *
+ * Parses one or more YAML documents into bean definitions. * - * @param resource the input stream to parse. The method takes care of closing the stream. - * @param grammar the grammar to use for type resolution and schema location + * @param resource the input stream to parse. The method takes care of closing the stream. + * @param grammar the grammar to use for type resolution and schema location + * @param rootSourceFile optional path to the root YAML file * @return list of parsed bean definitions */ - public static List parseMembraneResources(@NotNull InputStream resource, Grammar grammar) throws IOException { + public static List parseMembraneResources(@NotNull InputStream resource, Grammar grammar, Path rootSourceFile) throws IOException { try (resource) { - return parseToBeanDefinitions(resource, grammar); + return parseToBeanDefinitions(resource, grammar, rootSourceFile); } catch (JsonParseException e) { throw new IOException("Invalid YAML: multiple configurations must be separated by '---' (at line %d, column %d).".formatted(e.getLocation().getLineNr(), e.getLocation().getColumnNr()), e); } } - private static List parseToBeanDefinitions(@NotNull InputStream resource, Grammar grammar) throws IOException { - return new GenericYamlParser(grammar, new String(resource.readAllBytes(), UTF_8)) + private static List parseToBeanDefinitions(@NotNull InputStream resource, Grammar grammar, Path rootSourceFile) throws IOException { + return new GenericYamlParser(grammar, new String(resource.readAllBytes(), UTF_8), rootSourceFile) .getBeanDefinitions(); } @@ -258,7 +373,7 @@ private static T handleNoEnvelopeList(ParsingContext pc, Class clazz, return configObj; } - private static List extractComponentBeanDefinitions(ParsingContext pc, JsonNode componentsNode) { + private static List extractComponentBeanDefinitions(ParsingContext pc, JsonNode componentsNode, Set componentIds) { if (componentsNode == null || componentsNode.isNull()) return of(); @@ -271,6 +386,10 @@ private static List extractComponentBeanDefinitions(ParsingConte while (ids.hasNext()) { String id = ids.next(); JsonNode def = componentsNode.get(id); + String componentRef = "#/components/" + id; + + if (!componentIds.add(componentRef)) + throw new ConfigurationParsingException("Duplicate component id '%s'. Component ids must be unique across all included files.".formatted(componentRef), null, pc.addPath("." + id)); // Each component definition must have exactly one key (the component type) ensureSingleKey(pc.addPath("." + id), def); @@ -282,7 +401,7 @@ private static List extractComponentBeanDefinitions(ParsingConte res.add(new BeanDefinition( componentKind, - "#/components/" + id, + componentRef, "default", randomUUID().toString(), wrapped @@ -463,4 +582,33 @@ private static Object coerceScalarListItem(JsonNode node, Class elemType) { } return convertScalarOrSpel(node, elemType); } -} \ No newline at end of file + + private record Snippets(List includes, List configSnippets) {} + + private record IncludeSnippet(JsonNode node, ParsingContext parsingContext) {} + + private record IncludeEntry(String path, ParsingContext parsingContext) {} + + private record IncludeContext(Path sourceFile, Path basePath, Deque includeStack) { + + static IncludeContext root(Path sourceFile) { + return new IncludeContext(normalizePath(sourceFile), null, new ArrayDeque<>()); + } + + IncludeContext withSourceFile(Path sourceFile) { + return new IncludeContext(sourceFile, basePath, includeStack); + } + + Path resolveIncludePath(String includeEntry) { + Path includePath = Path.of(includeEntry); + if (includePath.isAbsolute()) + return normalizePath(includePath); + + Path baseDir = basePath != null ? basePath + : sourceFile != null && sourceFile.getParent() != null + ? sourceFile.getParent() + : normalizePath(Path.of(".")); + return normalizePath(baseDir.resolve(includePath)); + } + } +} diff --git a/core/src/main/java/com/predic8/membrane/core/cli/RouterCLI.java b/core/src/main/java/com/predic8/membrane/core/cli/RouterCLI.java index f6604e4297..eb0274b1f3 100644 --- a/core/src/main/java/com/predic8/membrane/core/cli/RouterCLI.java +++ b/core/src/main/java/com/predic8/membrane/core/cli/RouterCLI.java @@ -31,6 +31,7 @@ import java.io.*; import java.nio.charset.*; +import java.nio.file.Path; import java.security.*; import java.util.*; @@ -226,7 +227,7 @@ private static Router initRouterByYAML(String location) throws Exception { router.setRegistry(reg); reg.register("router", router); - getConfigDefinition(reg.parseYamlBeanDefinitions(router.getResolverMap().resolve(location), grammar)) + getConfigDefinition(reg.parseYamlBeanDefinitions(router.getResolverMap().resolve(location), grammar, Path.of(pathFromFileURI(location)))) .ifPresent(configBd -> router.applyConfiguration((Configuration) reg.resolve(configBd.getName()))); reg.finishStaticConfiguration(); @@ -422,4 +423,4 @@ private static String prefix(String dir) { private static void logStartupMessage() { log.info("{}{} {} up and running!{}", BRIGHT_CYAN(), PRODUCT_NAME, VERSION, RESET()); } -} \ No newline at end of file +} diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/headerfilter/HeaderFilterInterceptor.java b/core/src/main/java/com/predic8/membrane/core/interceptor/headerfilter/HeaderFilterInterceptor.java index 2f247b053f..180dfcb3af 100644 --- a/core/src/main/java/com/predic8/membrane/core/interceptor/headerfilter/HeaderFilterInterceptor.java +++ b/core/src/main/java/com/predic8/membrane/core/interceptor/headerfilter/HeaderFilterInterceptor.java @@ -58,7 +58,7 @@ public enum Action {KEEP, REMOVE} /** * @description Contains a Java regex for including message headers. */ - @MCElement(name = "include", collapsed = true,mixed = true) + @MCElement(name = "include", collapsed = true, mixed = true, component = false, id = "headerFilter-include") public static class Include extends HeaderFilterRule { public Include() { super(KEEP); @@ -68,7 +68,7 @@ public Include() { /** * @description Contains a Java regex for excluding message headers. */ - @MCElement(name = "exclude", collapsed = true,mixed = true ) + @MCElement(name = "exclude", collapsed = true, mixed = true, component = false, id = "headerFilter-exclude") public static class Exclude extends HeaderFilterRule { public Exclude() { super(REMOVE); diff --git a/core/src/main/java/com/predic8/membrane/core/transport/http/UnableToTunnelException.java b/core/src/main/java/com/predic8/membrane/core/transport/http/UnableToTunnelException.java index 2471f39ed2..d1b36f9201 100644 --- a/core/src/main/java/com/predic8/membrane/core/transport/http/UnableToTunnelException.java +++ b/core/src/main/java/com/predic8/membrane/core/transport/http/UnableToTunnelException.java @@ -1,3 +1,17 @@ +/* Copyright 2026 predic8 GmbH, www.predic8.com + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. */ + package com.predic8.membrane.core.transport.http; /** diff --git a/core/src/test/java/com/predic8/membrane/core/config/spring/k8s/EnvelopeTest.java b/core/src/test/java/com/predic8/membrane/core/config/spring/k8s/EnvelopeTest.java index d7a8bdb28c..8422e76e78 100644 --- a/core/src/test/java/com/predic8/membrane/core/config/spring/k8s/EnvelopeTest.java +++ b/core/src/test/java/com/predic8/membrane/core/config/spring/k8s/EnvelopeTest.java @@ -209,7 +209,7 @@ void missingKindDefaultsToApi() { private static List parseEnvelopes(String yaml, BeanRegistryImplementation registry) { GrammarAutoGenerated generator = new GrammarAutoGenerated(); try { - return new GenericYamlParser(generator, yaml) + return new GenericYamlParser(generator, yaml, null) .getBeanDefinitions().stream().map( bd -> (Envelope) readMembraneObject(bd.getKind(), generator, diff --git a/core/src/test/java/com/predic8/membrane/core/kubernetes/GenericYamlParserIncludeListTest.java b/core/src/test/java/com/predic8/membrane/core/kubernetes/GenericYamlParserIncludeListTest.java new file mode 100644 index 0000000000..a434dab337 --- /dev/null +++ b/core/src/test/java/com/predic8/membrane/core/kubernetes/GenericYamlParserIncludeListTest.java @@ -0,0 +1,211 @@ +/* Copyright 2026 predic8 GmbH, www.predic8.com + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. */ + +package com.predic8.membrane.core.kubernetes; + +import com.fasterxml.jackson.databind.JsonNode; +import com.predic8.membrane.annot.Grammar; +import com.predic8.membrane.annot.beanregistry.BeanDefinition; +import com.predic8.membrane.annot.yaml.ConfigurationParsingException; +import com.predic8.membrane.annot.yaml.GenericYamlParser; +import com.predic8.membrane.core.config.spring.GrammarAutoGenerated; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static java.nio.file.Files.readString; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class GenericYamlParserIncludeListTest { + + private static final Grammar K8S_HELPER = new GrammarAutoGenerated(); + + @TempDir + Path tempDir; + + @Test + void includes_files_and_directories_in_include_order() throws Exception { + write("bundle/20-second.apis.yaml", """ + api: + port: 2020 + """); + write("bundle/10-first.apis.yml", """ + api: + port: 2010 + """); + write("bundle/ignored.yaml", """ + api: + port: 9999 + """); + write("bundle/notes.txt", "not yaml"); + + assertEquals( + List.of(2001, 2010, 2020, 3001, 9000), + extractPorts(parseDefinitions(""" + include: + - %s + - %s + - %s + --- + api: + port: 9000 + """.formatted( + yamlPath(write("includes/01-first.apis.yaml", """ + api: + port: 2001 + """)), + yamlPath(tempDir.resolve("bundle")), + yamlPath(write("includes/99-last.apis.yml", """ + api: + port: 3001 + """)) + ))) + ); + } + + @Test + void resolves_relative_includes_from_including_file() throws Exception { + write("shared/dependency.apis.yaml", """ + api: + port: 1050 + """); + + assertEquals( + List.of(1050, 1100, 1200), + extractPorts(parseDefinitions(""" + include: + - %s + --- + api: + port: 1200 + """.formatted(yamlPath(write("nested/middle.apis.yaml", """ + include: + - ../shared/dependency.apis.yaml + --- + api: + port: 1100 + """))))) + ); + } + + @Test + void resolves_relative_includes_from_root_file_path() throws Exception { + write("root/includes/dependency.apis.yaml", """ + api: + port: 1250 + """); + Path rootFile = write("root/apis.yaml", """ + include: + - includes/dependency.apis.yaml + --- + api: + port: 1300 + """); + + assertEquals( + List.of(1250, 1300), + extractPorts(parseDefinitions(readString(rootFile), rootFile)) + ); + } + + @Test + void rejects_cyclic_include_chains() throws Exception { + write("cycle/b.apis.yaml", """ + include: + - a.apis.yaml + """); + + ConfigurationParsingException ex = assertThrows( + ConfigurationParsingException.class, () -> parseDefinitions(""" + include: + - %s + """.formatted(yamlPath(write("cycle/a.apis.yaml", """ + include: + - b.apis.yaml + """)))) + ); + + assertTrue(ex.getMessage().contains("Cyclic include detected"), ex.getMessage()); + assertTrue(ex.getMessage().contains("a.apis.yaml"), ex.getMessage()); + assertTrue(ex.getMessage().contains("b.apis.yaml"), ex.getMessage()); + } + + @Test + void rejects_duplicate_component_ids_across_includes() throws Exception { + Path first = write("components/one.apis.yaml", """ + components: + auth: + basicAuthentication: + fileUserDataProvider: + htpasswdPath: /etc/one.htpasswd + """); + Path second = write("components/two.apis.yaml", """ + components: + auth: + basicAuthentication: + fileUserDataProvider: + htpasswdPath: /etc/two.htpasswd + """); + + ConfigurationParsingException ex = assertThrows( + ConfigurationParsingException.class, () -> parseDefinitions(""" + include: + - %s + - %s + """.formatted(yamlPath(first), yamlPath(second))) + ); + + assertTrue(ex.getMessage().contains("Duplicate component id '#/components/auth'"), ex.getMessage()); + } + + private List parseDefinitions(String yaml) throws IOException { + return new GenericYamlParser(K8S_HELPER, yaml, null).getBeanDefinitions(); + } + + private List parseDefinitions(String yaml, Path rootSourceFile) throws IOException { + return new GenericYamlParser(K8S_HELPER, yaml, rootSourceFile).getBeanDefinitions(); + } + + private List extractPorts(List definitions) { + return definitions.stream() + .map(this::extractPort) + .toList(); + } + + private int extractPort(BeanDefinition definition) { + JsonNode apiNode = definition.getNode().get("api"); + assertNotNull(apiNode, "Expected 'api' object in bean definition: " + definition); + JsonNode portNode = apiNode.get("port"); + assertNotNull(portNode, "Expected 'port' inside api definition: " + definition); + return portNode.asInt(); + } + + private Path write(String relativePath, String content) throws IOException { + Path file = tempDir.resolve(relativePath); + Files.createDirectories(file.getParent()); + Files.writeString(file, content.stripIndent()); + return file; + } + + private static String yamlPath(Path path) { + return "'" + path.toAbsolutePath().normalize().toString().replace("'", "''") + "'"; + } +} diff --git a/distribution/examples/configuration/includes/README.md b/distribution/examples/configuration/includes/README.md new file mode 100644 index 0000000000..031ceae66e --- /dev/null +++ b/distribution/examples/configuration/includes/README.md @@ -0,0 +1,33 @@ +# Include List Example + +This example shows how to split one Membrane configuration with `include`. + +What is covered: + +- include a single file (`includes/file.apis.yaml`) +- nested include (`includes/nested/nested.apis.yaml`) +- include a directory (`includes/directory`). Only `*.apis.yaml` and `*.apis.yml` are loaded + +Important path rule: + +- Paths used inside included YAML files (for example OpenAPI `location`, template `src`, or other file-based references) are resolved relative to the main config file you start (`apis.yaml`), not relative to the included file. + +Start: + +```bash +cd examples/configuration/includes +./membrane.sh +``` + +Try: + +```bash +curl http://localhost:2000/root +curl http://localhost:2000/from-file +curl http://localhost:2000/nested +curl http://localhost:2000/from-directory-a +curl http://localhost:2000/from-directory-b +curl -i http://localhost:2000/ignored +``` + +`/ignored` returns `404` because `ignored.yaml` does not match the include pattern. diff --git a/distribution/examples/configuration/includes/apis.yaml b/distribution/examples/configuration/includes/apis.yaml new file mode 100644 index 0000000000..defc9d9751 --- /dev/null +++ b/distribution/examples/configuration/includes/apis.yaml @@ -0,0 +1,24 @@ +# yaml-language-server: $schema=https://www.membrane-api.io/v7.1.2.json + +# Demonstrates include list support: +# - include a single file +# - include a directory with *.apis.yaml / *.apis.yml files +# - nested include from an included file +include: + - includes/file.apis.yaml + - includes/directory + +--- +api: + port: 2000 + path: + uri: /root + flow: + - template: + contentType: application/json + src: | + { + "source": "root" + } + - return: + status: 200 diff --git a/distribution/examples/configuration/includes/includes/directory/10-first.apis.yml b/distribution/examples/configuration/includes/includes/directory/10-first.apis.yml new file mode 100644 index 0000000000..8cc6e9beda --- /dev/null +++ b/distribution/examples/configuration/includes/includes/directory/10-first.apis.yml @@ -0,0 +1,13 @@ +api: + port: 2000 + path: + uri: /from-directory-a + flow: + - template: + contentType: application/json + src: | + { + "source": "from-directory-a" + } + - return: + status: 200 diff --git a/distribution/examples/configuration/includes/includes/directory/20-second.apis.yaml b/distribution/examples/configuration/includes/includes/directory/20-second.apis.yaml new file mode 100644 index 0000000000..74b5ccad41 --- /dev/null +++ b/distribution/examples/configuration/includes/includes/directory/20-second.apis.yaml @@ -0,0 +1,13 @@ +api: + port: 2000 + path: + uri: /from-directory-b + flow: + - template: + contentType: application/json + src: | + { + "source": "from-directory-b" + } + - return: + status: 200 diff --git a/distribution/examples/configuration/includes/includes/directory/ignored.yaml b/distribution/examples/configuration/includes/includes/directory/ignored.yaml new file mode 100644 index 0000000000..dc55f444b2 --- /dev/null +++ b/distribution/examples/configuration/includes/includes/directory/ignored.yaml @@ -0,0 +1,13 @@ +api: + port: 2000 + path: + uri: /ignored + flow: + - template: + contentType: application/json + src: | + { + "source": "ignored" + } + - return: + status: 200 diff --git a/distribution/examples/configuration/includes/includes/file.apis.yaml b/distribution/examples/configuration/includes/includes/file.apis.yaml new file mode 100644 index 0000000000..cdc8c280a5 --- /dev/null +++ b/distribution/examples/configuration/includes/includes/file.apis.yaml @@ -0,0 +1,17 @@ +include: + - nested/nested.apis.yaml + +--- +api: + port: 2000 + path: + uri: /from-file + flow: + - template: + contentType: application/json + src: | + { + "source": "from-file" + } + - return: + status: 200 diff --git a/distribution/examples/configuration/includes/includes/nested/nested.apis.yaml b/distribution/examples/configuration/includes/includes/nested/nested.apis.yaml new file mode 100644 index 0000000000..64de283419 --- /dev/null +++ b/distribution/examples/configuration/includes/includes/nested/nested.apis.yaml @@ -0,0 +1,13 @@ +api: + port: 2000 + path: + uri: /nested + flow: + - template: + contentType: application/json + src: | + { + "source": "nested" + } + - return: + status: 200 diff --git a/distribution/examples/configuration/includes/membrane.cmd b/distribution/examples/configuration/includes/membrane.cmd new file mode 100644 index 0000000000..8d2d64e9cf --- /dev/null +++ b/distribution/examples/configuration/includes/membrane.cmd @@ -0,0 +1,24 @@ +@echo off +setlocal EnableExtensions + +set "SCRIPT_DIR=%~dp0" +if "%SCRIPT_DIR:~-1%"=="\" set "SCRIPT_DIR=%SCRIPT_DIR:~0,-1%" + +set "dir=%SCRIPT_DIR%" + +:search_up +if exist "%dir%\LICENSE.txt" if exist "%dir%\scripts\run-membrane.cmd" goto found +for %%A in ("%dir%\..") do set "next=%%~fA" +if /I "%next%"=="%dir%" goto notfound +set "dir=%next%" +goto search_up + +:found +set "MEMBRANE_HOME=%dir%" +set "MEMBRANE_CALLER_DIR=%SCRIPT_DIR%" +call "%MEMBRANE_HOME%\scripts\run-membrane.cmd" %* +exit /b %ERRORLEVEL% + +:notfound +>&2 echo Could not locate Membrane root. Ensure directory structure is correct. +exit /b 1 diff --git a/distribution/examples/configuration/includes/membrane.sh b/distribution/examples/configuration/includes/membrane.sh new file mode 100755 index 0000000000..195dae51ec --- /dev/null +++ b/distribution/examples/configuration/includes/membrane.sh @@ -0,0 +1,21 @@ +#!/bin/sh +# Default: ./proxies.xml (next to this script); fallback -> $MEMBRANE_HOME/conf/proxies.xml +# JAVA_OPTS: relative -D paths are auto-resolved against $MEMBRANE_HOME (absolute/URI unchanged). +# Examples: +# export JAVA_OPTS='-Dlog4j.configurationFile=examples/logging/access/log4j2_access.xml' +# export JAVA_OPTS='-Dlog4j.configurationFile=/abs/path/log4j2.xml' + +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd -P) + +dir="$SCRIPT_DIR" +while [ "$dir" != "/" ]; do + if [ -f "$dir/LICENSE.txt" ] && [ -f "$dir/scripts/run-membrane.sh" ]; then + export MEMBRANE_HOME="$dir" + export MEMBRANE_CALLER_DIR="$SCRIPT_DIR" + exec sh "$dir/scripts/run-membrane.sh" "$@" + fi + dir=$(dirname "$dir") +done + +echo "Could not locate Membrane root. Ensure directory structure is correct." >&2 +exit 1 \ No newline at end of file diff --git a/distribution/src/test/java/com/predic8/membrane/examples/withoutinternet/test/ConfigurationIncludesExampleTest.java b/distribution/src/test/java/com/predic8/membrane/examples/withoutinternet/test/ConfigurationIncludesExampleTest.java new file mode 100644 index 0000000000..d90a843656 --- /dev/null +++ b/distribution/src/test/java/com/predic8/membrane/examples/withoutinternet/test/ConfigurationIncludesExampleTest.java @@ -0,0 +1,68 @@ +/* Copyright 2026 predic8 GmbH, www.predic8.com + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. */ + +package com.predic8.membrane.examples.withoutinternet.test; + +import com.predic8.membrane.examples.util.AbstractSampleMembraneStartStopTestcase; +import org.junit.jupiter.api.Test; + +import static io.restassured.RestAssured.when; +import static org.hamcrest.Matchers.equalTo; + +public class ConfigurationIncludesExampleTest extends AbstractSampleMembraneStartStopTestcase { + + @Override + protected String getExampleDirName() { + return "configuration/includes"; + } + + @Test + void includesFromFileAndDirectoryAreLoaded() { + // @formatter:off + when().get("http://localhost:2000/root") + .then() + .statusCode(200) + .body("source", equalTo("root")); + + when().get("http://localhost:2000/from-file") + .then() + .statusCode(200) + .body("source", equalTo("from-file")); + + when().get("http://localhost:2000/nested") + .then() + .statusCode(200) + .body("source", equalTo("nested")); + + when().get("http://localhost:2000/from-directory-a") + .then() + .statusCode(200) + .body("source", equalTo("from-directory-a")); + + when().get("http://localhost:2000/from-directory-b") + .then() + .statusCode(200) + .body("source", equalTo("from-directory-b")); + // @formatter:on + } + + @Test + void includeDirectoryIgnoresNonApisYamlFiles() { + // @formatter:off + when().get("http://localhost:2000/ignored") + .then() + .statusCode(404); + // @formatter:on + } +}