diff --git a/docs/customization.md b/docs/customization.md index e43a72893471..d1559872d17d 100644 --- a/docs/customization.md +++ b/docs/customization.md @@ -72,6 +72,53 @@ The `templateType` option will default to `SupportingFiles`, so the option for ` Excluding `SupportingFiles`, each of the above options may result in multiple files. API related types create a file per API. Model related types create a file for each model. +### Directory-based file discovery with `filesDir` + +If you have many additional template files, listing each one individually under `files` can become unwieldy. The `filesDir` option lets you point to a directory whose structure determines template types automatically: + +```yaml +templateDir: my_custom_templates +filesDir: my_extra_templates/ +``` + +Where `my_extra_templates/` is organized like this: + +``` +my_extra_templates/ + README.md # -> SupportingFiles (root = default) + LICENSE.mustache # -> SupportingFiles, output as "LICENSE" + api/ + custom_api.mustache # -> templateType: API + model/ + validators.mustache # -> templateType: Model + apiDocs/ + api_readme.mustache # -> templateType: APIDocs + modelDocs/ + model_readme.mustache # -> templateType: ModelDocs + apiTests/ + api_test.mustache # -> templateType: APITests + modelTests/ + model_test.mustache # -> templateType: ModelTests + supportingFiles/ + build.gradle.mustache # -> SupportingFiles + scripts/ + check.sh # -> SupportingFiles, folder: "scripts" + custom_output_dir/ + deploy.sh # -> SupportingFiles, folder: "custom_output_dir" +``` + +The rules are: + +* **Root files** (directly inside `filesDir`) are treated as `SupportingFiles` with no output folder. +* **Recognized subdirectory names** (case-insensitive: `api`, `model`, `apiDocs`, `modelDocs`, `apiTests`, `modelTests`, `supportingFiles`) map files to the corresponding `templateType`. +* **Unrecognized subdirectory names** (e.g. `custom_output_dir/`) are treated as `SupportingFiles` with the directory path used as the output `folder`. +* **`.mustache` suffixes** are stripped from the destination filename (e.g. `build.gradle.mustache` outputs as `build.gradle`). Non-mustache files are copied as-is. +* **Nested directories under `supportingFiles/`** preserve their path as the output folder (e.g. `supportingFiles/scripts/check.sh` outputs to `scripts/check.sh`). + +You can use `filesDir` together with `files`. When both are specified, explicit `files` entries take precedence over auto-discovered ones if there is a template path conflict. + +`filesDir` follows symbolic links. Be careful not to create symlink cycles, as no infinite loop protection is provided. + Note that user-defined templates will merge with built-in template definitions. If a supporting file with the sample template file path exists, it will be replaced with the user-defined template, otherwise the user-defined template will be added to the list of template files to compile. If the generator's built-in template is `model_docs.mustache` and you define `model-docs.mustache`, this will result in duplicated model docs (if `destinationFilename` differs) or undefined behavior as whichever template compiles last will overwrite the previous model docs (if `destinationFilename` matches the extension or suffix in the generator's code). ## Custom Generator (and Template) diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/config/DynamicSettings.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/config/DynamicSettings.java index 975728ba636f..79ca0fca2a47 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/config/DynamicSettings.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/config/DynamicSettings.java @@ -10,9 +10,11 @@ import org.openapitools.codegen.api.TemplateDefinition; import org.openapitools.codegen.api.TemplateFileType; +import java.io.IOException; import java.lang.reflect.Field; +import java.nio.file.*; +import java.nio.file.attribute.BasicFileAttributes; import java.util.*; -import java.util.stream.Collectors; /** * Represents a serialization helper of {@link org.openapitools.codegen.config.GeneratorSettings} and {@link org.openapitools.codegen.config.WorkflowSettings}. When used to deserialize any available Jackson binding input, @@ -39,31 +41,151 @@ public class DynamicSettings { private WorkflowSettings workflowSettings; /** - * Gets the list of template files allowing user redefinition and addition of templating files + * Gets the list of template files allowing user redefinition and addition of templating files. + * This includes files from both the {@code files} map and the {@code filesDir} directory. + * When both are specified, explicit {@code files} entries take precedence over auto-discovered + * ones from {@code filesDir} if there is a template path conflict. * * @return A list of template files */ public List getFiles() { - if (files == null) return new ArrayList<>(); - - return files.entrySet().stream().map(kvp -> { - TemplateDefinition file = kvp.getValue(); - String templateFile = kvp.getKey(); - String destination = file.getDestinationFilename(); - if (TemplateFileType.SupportingFiles.equals(file.getTemplateType()) && StringUtils.isBlank(destination)) { - // this special case allows definitions such as LICENSE:{} - destination = templateFile; + // Collect explicitly defined files + Map result = new LinkedHashMap<>(); + + if (files != null) { + for (Map.Entry kvp : files.entrySet()) { + TemplateDefinition file = kvp.getValue(); + String templateFile = kvp.getKey(); + String destination = file.getDestinationFilename(); + if (TemplateFileType.SupportingFiles.equals(file.getTemplateType()) && StringUtils.isBlank(destination)) { + // this special case allows definitions such as LICENSE:{} + destination = templateFile; + } + TemplateDefinition definition = new TemplateDefinition(templateFile, file.getFolder(), destination); + definition.setTemplateType(file.getTemplateType()); + result.put(templateFile, definition); + } + } + + // Discover files from filesDir, if specified + if (StringUtils.isNotBlank(filesDir)) { + List discovered = discoverFilesFromDirectory(filesDir); + for (TemplateDefinition def : discovered) { + // Explicit files entries take precedence + result.putIfAbsent(def.getTemplateFile(), def); } - TemplateDefinition definition = new TemplateDefinition(templateFile, file.getFolder(), destination); - definition.setTemplateType(file.getTemplateType()); - return definition; - }).collect(Collectors.toList()); + } + + return new ArrayList<>(result.values()); } @SuppressWarnings("MismatchedQueryAndUpdateOfCollection") @JsonProperty("files") private Map files; + @JsonProperty("filesDir") + private String filesDir; + + // Maps subdirectory names (lowercase) to TemplateFileType for auto-discovery + private static final Map DIRECTORY_TYPE_MAPPING; + static { + Map m = new HashMap<>(); + m.put("api", TemplateFileType.API); + m.put("model", TemplateFileType.Model); + m.put("apidocs", TemplateFileType.APIDocs); + m.put("modeldocs", TemplateFileType.ModelDocs); + m.put("apitests", TemplateFileType.APITests); + m.put("modeltests", TemplateFileType.ModelTests); + m.put("supportingfiles", TemplateFileType.SupportingFiles); + DIRECTORY_TYPE_MAPPING = Collections.unmodifiableMap(m); + } + + /** + * Scans a directory and discovers template files, mapping subdirectory names to template types. + *
    + *
  • Files at the root of the directory default to {@link TemplateFileType#SupportingFiles}
  • + *
  • Files under subdirectories named after a {@link TemplateFileType} (case-insensitive) are + * assigned that type (e.g. {@code api/}, {@code model/}, {@code apiDocs/})
  • + *
  • Files under unrecognized subdirectory names are treated as {@link TemplateFileType#SupportingFiles} + * with the subdirectory path used as the output folder
  • + *
  • For API/Model types, only immediate children of the type directory are included
  • + *
  • For SupportingFiles (including root and unrecognized dirs), nesting is preserved as the output folder
  • + *
+ * + * @param dirPath path to the directory to scan + * @return list of discovered template definitions + */ + static List discoverFilesFromDirectory(String dirPath) { + List result = new ArrayList<>(); + Path baseDir = Paths.get(dirPath).normalize(); + + if (!Files.isDirectory(baseDir)) { + return result; + } + + try { + Files.walkFileTree(baseDir, EnumSet.of(FileVisitOption.FOLLOW_LINKS), Integer.MAX_VALUE, new SimpleFileVisitor() { + @Override + public FileVisitResult visitFile(Path filePath, BasicFileAttributes attrs) { + if (!Files.isRegularFile(filePath)) { + return FileVisitResult.CONTINUE; + } + + Path relative = baseDir.relativize(filePath); + String templateFile = relative.toString().replace('\\', '/'); + String fileName = filePath.getFileName().toString(); + + // Determine the destination filename: strip .mustache suffix if present + String destinationFilename = fileName.endsWith(".mustache") + ? fileName.substring(0, fileName.length() - ".mustache".length()) + : fileName; + + // Determine template type and folder from directory structure + TemplateFileType templateType = TemplateFileType.SupportingFiles; + String folder = ""; + + if (relative.getNameCount() == 1) { + // File at root of filesDir -> SupportingFiles, no folder + templateType = TemplateFileType.SupportingFiles; + } else { + // Has at least one parent directory + String topDir = relative.getName(0).toString(); + String topDirLower = topDir.toLowerCase(Locale.ROOT); + TemplateFileType mappedType = DIRECTORY_TYPE_MAPPING.get(topDirLower); + + if (mappedType != null) { + templateType = mappedType; + + if (mappedType == TemplateFileType.SupportingFiles) { + // For supportingFiles subdir, preserve nested structure as folder + if (relative.getNameCount() > 2) { + Path folderPath = relative.subpath(1, relative.getNameCount() - 1); + folder = folderPath.toString().replace('\\', '/'); + } + } + // For API/Model/etc types, folder is left empty (generator handles placement) + } else { + // Unrecognized subdirectory -> SupportingFiles with the full relative dir as folder + templateType = TemplateFileType.SupportingFiles; + Path folderPath = relative.subpath(0, relative.getNameCount() - 1); + folder = folderPath.toString().replace('\\', '/'); + } + } + + TemplateDefinition definition = new TemplateDefinition(templateFile, folder, destinationFilename); + definition.setTemplateType(templateType); + result.add(definition); + + return FileVisitResult.CONTINUE; + } + }); + } catch (IOException e) { + throw new RuntimeException("Failed to scan filesDir: " + dirPath, e); + } + + return result; + } + /** * Gets the {@link org.openapitools.codegen.config.GeneratorSettings} included in the config object. * diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/config/DynamicSettingsTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/config/DynamicSettingsTest.java index 32e68170933e..e98e09b536dd 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/config/DynamicSettingsTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/config/DynamicSettingsTest.java @@ -9,6 +9,9 @@ import org.testng.annotations.Test; import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.List; import java.util.Map; import java.util.StringJoiner; @@ -246,4 +249,254 @@ public void testFullConfigWithFilesMap() throws JsonProcessingException { assertEquals(mapped.get("LICENSE").getDestinationFilename(), "LICENSE"); assertEquals(mapped.get("LICENSE").getTemplateType(), TemplateFileType.SupportingFiles); } + + @Test + public void testFilesDirWithNestedStructure() throws IOException, JsonProcessingException { + // Create a temp directory structure: + // filesDir/ + // README.md -> SupportingFiles, folder="" + // LICENSE.mustache -> SupportingFiles, folder="", dest="LICENSE" + // api/ + // custom_api.mustache -> API, dest="custom_api" + // model/ + // validators.mustache -> Model, dest="validators" + // apiDocs/ + // api_readme.mustache -> APIDocs + // modelDocs/ + // model_readme.mustache -> ModelDocs + // apiTests/ + // api_test.mustache -> APITests + // modelTests/ + // model_test.mustache -> ModelTests + // supportingFiles/ + // build.gradle.mustache -> SupportingFiles, folder="" + // scripts/ + // check.sh -> SupportingFiles, folder="scripts" + // custom_scripts/ + // deploy.sh -> SupportingFiles, folder="custom_scripts" (unrecognized dir) + + Path tempDir = Files.createTempDirectory("filesDir_test"); + try { + // Root files + Files.writeString(tempDir.resolve("README.md"), "readme"); + Files.writeString(tempDir.resolve("LICENSE.mustache"), "license template"); + + // api/ + Files.createDirectories(tempDir.resolve("api")); + Files.writeString(tempDir.resolve("api/custom_api.mustache"), "api template"); + + // model/ + Files.createDirectories(tempDir.resolve("model")); + Files.writeString(tempDir.resolve("model/validators.mustache"), "model template"); + + // apiDocs/ + Files.createDirectories(tempDir.resolve("apiDocs")); + Files.writeString(tempDir.resolve("apiDocs/api_readme.mustache"), "api docs"); + + // modelDocs/ + Files.createDirectories(tempDir.resolve("modelDocs")); + Files.writeString(tempDir.resolve("modelDocs/model_readme.mustache"), "model docs"); + + // apiTests/ + Files.createDirectories(tempDir.resolve("apiTests")); + Files.writeString(tempDir.resolve("apiTests/api_test.mustache"), "api test"); + + // modelTests/ + Files.createDirectories(tempDir.resolve("modelTests")); + Files.writeString(tempDir.resolve("modelTests/model_test.mustache"), "model test"); + + // supportingFiles/ + Files.createDirectories(tempDir.resolve("supportingFiles/scripts")); + Files.writeString(tempDir.resolve("supportingFiles/build.gradle.mustache"), "build file"); + Files.writeString(tempDir.resolve("supportingFiles/scripts/check.sh"), "check script"); + + // custom_scripts/ (unrecognized dir name) + Files.createDirectories(tempDir.resolve("custom_scripts")); + Files.writeString(tempDir.resolve("custom_scripts/deploy.sh"), "deploy script"); + + // Parse config with filesDir + ObjectMapper mapper = Yaml.mapper(); + mapper.registerModule(new GuavaModule()); + + String spec = new StringJoiner(System.lineSeparator(), "", "") + .add("generatorName: java") + .add("filesDir: '" + tempDir.toString().replace('\\', '/') + "'") + .toString(); + + DynamicSettings dynamicSettings = mapper.readValue(spec, DynamicSettings.class); + List files = dynamicSettings.getFiles(); + assertNotNull(files); + + Map mapped = files.stream() + .collect(Collectors.toMap(TemplateDefinition::getTemplateFile, Function.identity(), (a, b) -> a, TreeMap::new)); + + // Root files -> SupportingFiles + assertTrue(mapped.containsKey("README.md"), "Should discover README.md at root"); + assertEquals(mapped.get("README.md").getTemplateType(), TemplateFileType.SupportingFiles); + assertEquals(mapped.get("README.md").getFolder(), ""); + assertEquals(mapped.get("README.md").getDestinationFilename(), "README.md"); + + assertTrue(mapped.containsKey("LICENSE.mustache"), "Should discover LICENSE.mustache at root"); + assertEquals(mapped.get("LICENSE.mustache").getTemplateType(), TemplateFileType.SupportingFiles); + assertEquals(mapped.get("LICENSE.mustache").getDestinationFilename(), "LICENSE"); + + // api/ -> API + assertTrue(mapped.containsKey("api/custom_api.mustache"), "Should discover api/custom_api.mustache"); + assertEquals(mapped.get("api/custom_api.mustache").getTemplateType(), TemplateFileType.API); + assertEquals(mapped.get("api/custom_api.mustache").getDestinationFilename(), "custom_api"); + assertEquals(mapped.get("api/custom_api.mustache").getFolder(), ""); + + // model/ -> Model + assertTrue(mapped.containsKey("model/validators.mustache"), "Should discover model/validators.mustache"); + assertEquals(mapped.get("model/validators.mustache").getTemplateType(), TemplateFileType.Model); + + // apiDocs/ -> APIDocs + assertTrue(mapped.containsKey("apiDocs/api_readme.mustache")); + assertEquals(mapped.get("apiDocs/api_readme.mustache").getTemplateType(), TemplateFileType.APIDocs); + + // modelDocs/ -> ModelDocs + assertTrue(mapped.containsKey("modelDocs/model_readme.mustache")); + assertEquals(mapped.get("modelDocs/model_readme.mustache").getTemplateType(), TemplateFileType.ModelDocs); + + // apiTests/ -> APITests + assertTrue(mapped.containsKey("apiTests/api_test.mustache")); + assertEquals(mapped.get("apiTests/api_test.mustache").getTemplateType(), TemplateFileType.APITests); + + // modelTests/ -> ModelTests + assertTrue(mapped.containsKey("modelTests/model_test.mustache")); + assertEquals(mapped.get("modelTests/model_test.mustache").getTemplateType(), TemplateFileType.ModelTests); + + // supportingFiles/ -> SupportingFiles + assertTrue(mapped.containsKey("supportingFiles/build.gradle.mustache")); + assertEquals(mapped.get("supportingFiles/build.gradle.mustache").getTemplateType(), TemplateFileType.SupportingFiles); + assertEquals(mapped.get("supportingFiles/build.gradle.mustache").getFolder(), ""); + assertEquals(mapped.get("supportingFiles/build.gradle.mustache").getDestinationFilename(), "build.gradle"); + + // supportingFiles/scripts/ -> SupportingFiles with folder="scripts" + assertTrue(mapped.containsKey("supportingFiles/scripts/check.sh")); + assertEquals(mapped.get("supportingFiles/scripts/check.sh").getTemplateType(), TemplateFileType.SupportingFiles); + assertEquals(mapped.get("supportingFiles/scripts/check.sh").getFolder(), "scripts"); + assertEquals(mapped.get("supportingFiles/scripts/check.sh").getDestinationFilename(), "check.sh"); + + // custom_scripts/ (unrecognized) -> SupportingFiles with folder="custom_scripts" + assertTrue(mapped.containsKey("custom_scripts/deploy.sh")); + assertEquals(mapped.get("custom_scripts/deploy.sh").getTemplateType(), TemplateFileType.SupportingFiles); + assertEquals(mapped.get("custom_scripts/deploy.sh").getFolder(), "custom_scripts"); + assertEquals(mapped.get("custom_scripts/deploy.sh").getDestinationFilename(), "deploy.sh"); + } finally { + // Cleanup temp directory + deleteRecursively(tempDir); + } + } + + @Test + public void testFilesDirMergedWithFiles() throws IOException, JsonProcessingException { + // When both files and filesDir are specified, explicit files entries should take precedence + + Path tempDir = Files.createTempDirectory("filesDir_merge_test"); + try { + Files.createDirectories(tempDir.resolve("api")); + Files.writeString(tempDir.resolve("api/custom_api.mustache"), "api from dir"); + Files.writeString(tempDir.resolve("README.md"), "readme from dir"); + + ObjectMapper mapper = Yaml.mapper(); + mapper.registerModule(new GuavaModule()); + + // Explicit files entry for api/custom_api.mustache with a custom destinationFilename + String spec = new StringJoiner(System.lineSeparator(), "", "") + .add("generatorName: java") + .add("filesDir: '" + tempDir.toString().replace('\\', '/') + "'") + .add("files:") + .add(" api/custom_api.mustache:") + .add(" templateType: API") + .add(" destinationFilename: CustomApi.java") + .toString(); + + DynamicSettings dynamicSettings = mapper.readValue(spec, DynamicSettings.class); + List files = dynamicSettings.getFiles(); + assertNotNull(files); + + Map mapped = files.stream() + .collect(Collectors.toMap(TemplateDefinition::getTemplateFile, Function.identity(), (a, b) -> a, TreeMap::new)); + + // Explicit entry should win over auto-discovered + assertEquals(mapped.get("api/custom_api.mustache").getDestinationFilename(), "CustomApi.java"); + assertEquals(mapped.get("api/custom_api.mustache").getTemplateType(), TemplateFileType.API); + + // Auto-discovered file that doesn't conflict should still be present + assertTrue(mapped.containsKey("README.md")); + assertEquals(mapped.get("README.md").getTemplateType(), TemplateFileType.SupportingFiles); + } finally { + deleteRecursively(tempDir); + } + } + + @Test + public void testFilesDirEmptyDirectory() throws IOException, JsonProcessingException { + Path tempDir = Files.createTempDirectory("filesDir_empty_test"); + try { + ObjectMapper mapper = Yaml.mapper(); + mapper.registerModule(new GuavaModule()); + + String spec = new StringJoiner(System.lineSeparator(), "", "") + .add("generatorName: java") + .add("filesDir: '" + tempDir.toString().replace('\\', '/') + "'") + .toString(); + + DynamicSettings dynamicSettings = mapper.readValue(spec, DynamicSettings.class); + List files = dynamicSettings.getFiles(); + assertNotNull(files); + assertEquals(files.size(), 0, "Empty directory should produce no template definitions"); + } finally { + deleteRecursively(tempDir); + } + } + + @Test + public void testFilesDirNonExistentDirectory() throws JsonProcessingException { + ObjectMapper mapper = Yaml.mapper(); + mapper.registerModule(new GuavaModule()); + + String spec = new StringJoiner(System.lineSeparator(), "", "") + .add("generatorName: java") + .add("filesDir: '/nonexistent/path/that/should/not/exist'") + .toString(); + + DynamicSettings dynamicSettings = mapper.readValue(spec, DynamicSettings.class); + List files = dynamicSettings.getFiles(); + assertNotNull(files); + assertEquals(files.size(), 0, "Non-existent directory should produce no template definitions"); + } + + @Test + public void testDiscoverFilesFromDirectoryCaseInsensitive() throws IOException { + // Verify that directory name matching is case-insensitive + Path tempDir = Files.createTempDirectory("filesDir_case_test"); + try { + Files.createDirectories(tempDir.resolve("API")); + Files.writeString(tempDir.resolve("API/upper.mustache"), "upper"); + Files.createDirectories(tempDir.resolve("Model")); + Files.writeString(tempDir.resolve("Model/mixed.mustache"), "mixed"); + + List discovered = DynamicSettings.discoverFilesFromDirectory(tempDir.toString()); + Map mapped = discovered.stream() + .collect(Collectors.toMap(TemplateDefinition::getTemplateFile, Function.identity())); + + assertEquals(mapped.get("API/upper.mustache").getTemplateType(), TemplateFileType.API); + assertEquals(mapped.get("Model/mixed.mustache").getTemplateType(), TemplateFileType.Model); + } finally { + deleteRecursively(tempDir); + } + } + + private static void deleteRecursively(Path path) throws IOException { + if (Files.isDirectory(path)) { + try (var entries = Files.list(path)) { + for (Path entry : entries.collect(Collectors.toList())) { + deleteRecursively(entry); + } + } + } + Files.deleteIfExists(path); + } }