From 28b551c5d81588ede2589e8a4e09bdb9b8d9b5a4 Mon Sep 17 00:00:00 2001 From: stijnpotters Date: Mon, 15 Jun 2026 12:07:42 +0200 Subject: [PATCH 01/10] Add /build to .gitignore to exclude build artifacts --- src/main/frontend/.gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/frontend/.gitignore b/src/main/frontend/.gitignore index c65a0878..fc091fc6 100644 --- a/src/main/frontend/.gitignore +++ b/src/main/frontend/.gitignore @@ -9,3 +9,5 @@ /.env /.env.production /.env.development + +/build From 32754768b3884ac21c812d925d37773fdb4a2e70 Mon Sep 17 00:00:00 2001 From: stijnpotters Date: Mon, 15 Jun 2026 12:52:28 +0200 Subject: [PATCH 02/10] Update development environment configuration and enhance security settings --- .../flow/common/config/SecurityChainConfigurer.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/main/java/org/frankframework/flow/common/config/SecurityChainConfigurer.java b/src/main/java/org/frankframework/flow/common/config/SecurityChainConfigurer.java index 96d1f8db..0ce53118 100644 --- a/src/main/java/org/frankframework/flow/common/config/SecurityChainConfigurer.java +++ b/src/main/java/org/frankframework/flow/common/config/SecurityChainConfigurer.java @@ -56,6 +56,11 @@ public SecurityFilterChain configureChain(IAuthenticator authenticator, HttpSecu http.formLogin(FormLoginConfigurer::disable); http.logout(LogoutConfigurer::disable); http.sessionManagement(management -> management.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)); + + if (csrfEnabled) { + http.addFilterAfter(new CsrfCookieFilter(), CsrfFilter.class); + } + return authenticator.configureHttpSecurity(http); } From a8fbfaa8d83e58b4413ed31c263bc9249e23e6b5 Mon Sep 17 00:00:00 2001 From: stijnpotters Date: Mon, 15 Jun 2026 13:24:06 +0200 Subject: [PATCH 03/10] Refactor API URL handling and improve CSRF configuration --- src/main/frontend/.gitignore | 2 -- .../flow/common/config/SecurityChainConfigurer.java | 5 ----- 2 files changed, 7 deletions(-) diff --git a/src/main/frontend/.gitignore b/src/main/frontend/.gitignore index fc091fc6..c65a0878 100644 --- a/src/main/frontend/.gitignore +++ b/src/main/frontend/.gitignore @@ -9,5 +9,3 @@ /.env /.env.production /.env.development - -/build diff --git a/src/main/java/org/frankframework/flow/common/config/SecurityChainConfigurer.java b/src/main/java/org/frankframework/flow/common/config/SecurityChainConfigurer.java index 0ce53118..96d1f8db 100644 --- a/src/main/java/org/frankframework/flow/common/config/SecurityChainConfigurer.java +++ b/src/main/java/org/frankframework/flow/common/config/SecurityChainConfigurer.java @@ -56,11 +56,6 @@ public SecurityFilterChain configureChain(IAuthenticator authenticator, HttpSecu http.formLogin(FormLoginConfigurer::disable); http.logout(LogoutConfigurer::disable); http.sessionManagement(management -> management.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)); - - if (csrfEnabled) { - http.addFilterAfter(new CsrfCookieFilter(), CsrfFilter.class); - } - return authenticator.configureHttpSecurity(http); } From d9eb3fa14fd1547d4022d84fefa36d60a9af51dc Mon Sep 17 00:00:00 2001 From: stijnpotters Date: Mon, 15 Jun 2026 15:20:23 +0200 Subject: [PATCH 04/10] Refactor adapter and config xml creation and configuration file handling to improve path normalization and response types --- .../file-structure/editor-file-structure.tsx | 17 +++++++++++ .../use-file-tree-context-menu.ts | 22 +++++++++++++-- .../file-structure/use-studio-context-menu.ts | 26 +++++++++++------ .../add-configuration-modal.tsx | 4 ++- .../frontend/app/services/adapter-service.ts | 4 +-- .../services/configuration-file-service.ts | 4 +-- src/main/frontend/app/utils/path-utils.ts | 4 +-- .../flow/adapter/AdapterController.java | 6 ++-- .../flow/adapter/AdapterService.java | 6 ++-- .../ConfigurationController.java | 4 +-- .../configuration/ConfigurationService.java | 28 +++++++++---------- .../flow/file/FileTreeService.java | 2 +- .../project/ConfigurationProjectService.java | 4 +-- .../ConfigurationControllerTest.java | 8 ++++-- .../ConfigurationServiceTest.java | 25 +++++++++++------ 15 files changed, 109 insertions(+), 55 deletions(-) diff --git a/src/main/frontend/app/components/file-structure/editor-file-structure.tsx b/src/main/frontend/app/components/file-structure/editor-file-structure.tsx index 9e900128..1711a2c4 100644 --- a/src/main/frontend/app/components/file-structure/editor-file-structure.tsx +++ b/src/main/frontend/app/components/file-structure/editor-file-structure.tsx @@ -114,9 +114,26 @@ export default function EditorFileStructure() { [getTab, removeTabAndSelectFallback], ) + const configurationsRootPath = useMemo(() => { + const paths = project?.filepaths + if (!paths?.length) return + + // each path → its directory segments (drop the filename) + const segments = paths.map((p) => p.replaceAll('\\', '/').split('/').slice(0, -1)) + + const common = segments.reduce((a, b) => { + let i = 0 + while (i < a.length && i < b.length && a[i] === b[i]) i++ + return a.slice(0, i) + }) + + return common.length > 0 ? common.join('/') : undefined + }, [project?.filepaths]) + const editorContextMenu = useFileTreeContextMenu({ projectName: project?.name, dataProvider, + configurationsRootPath, onAfterRename, onAfterDelete, }) diff --git a/src/main/frontend/app/components/file-structure/use-file-tree-context-menu.ts b/src/main/frontend/app/components/file-structure/use-file-tree-context-menu.ts index 842b15d2..cdac6a5b 100644 --- a/src/main/frontend/app/components/file-structure/use-file-tree-context-menu.ts +++ b/src/main/frontend/app/components/file-structure/use-file-tree-context-menu.ts @@ -1,13 +1,15 @@ import React, { useCallback, useRef, useState } from 'react' +import { useNavigate } from 'react-router' import type { TreeItemIndex } from 'react-complex-tree' import { createFile, deleteFile, renameFile } from '~/services/file-service' import { createFolderInProject } from '~/services/file-tree-service' -import { clearConfigurationFileCache } from '~/services/configuration-file-service' +import { clearConfigurationFileCache, createConfigurationFile } from '~/services/configuration-file-service' import useTabStore from '~/stores/tab-store' import useEditorTabStore from '~/stores/editor-tab-store' import { showErrorToast } from '~/components/toast' import { FILE_NAME_PATTERNS, FOLDER_OR_ADAPTER_NAME_PATTERNS } from '~/components/file-structure/name-input-dialog' import { logApiError } from '~/utils/logger' +import { openInEditor } from '~/actions/navigationActions' export interface ContextMenuState { position: { x: number; y: number } @@ -43,6 +45,7 @@ export interface DataProviderLike { interface UseFileTreeContextMenuOptions { projectName: string | undefined dataProvider: DataProviderLike | null + configurationsRootPath?: string onAfterRename?: (oldPath: string, newName: string) => void onAfterDelete?: (path: string) => void } @@ -70,9 +73,11 @@ function buildNewPath(oldPath: string, newName: string): string { export function useFileTreeContextMenu({ projectName, dataProvider, + configurationsRootPath, onAfterRename, onAfterDelete, }: UseFileTreeContextMenuOptions) { + const navigate = useNavigate() const [contextMenu, setContextMenu] = useState(null) const [nameDialog, setNameDialog] = useState(null) const [deleteTarget, setDeleteTarget] = useState(null) @@ -127,9 +132,20 @@ export function useFileTreeContextMenu({ return } + const filePath = `${parentPath}/${name}` + const isXml = name.toLowerCase().endsWith('.xml') + const configsRoot = configurationsRootPath?.replaceAll('\\', '/') + const normalizedParent = parentPath.replaceAll('\\', '/') + const isInsideConfigurations = !!configsRoot && normalizedParent.startsWith(configsRoot) + try { - await createFile(projectName, `${parentPath}/${name}`) + await (isXml && isInsideConfigurations + ? createConfigurationFile(projectName, filePath) + : createFile(projectName, filePath)) + await dataProvider.reloadDirectory(parentItemId) + + openInEditor(name, filePath, navigate) } catch (error) { logApiError('Failed to create file', error as Error) } @@ -138,7 +154,7 @@ export function useFileTreeContextMenu({ patterns: FILE_NAME_PATTERNS, }) }, - [projectName, dataProvider, closeContextMenu], + [projectName, dataProvider, configurationsRootPath, navigate, closeContextMenu], ) const handleNewFolder = useCallback( diff --git a/src/main/frontend/app/components/file-structure/use-studio-context-menu.ts b/src/main/frontend/app/components/file-structure/use-studio-context-menu.ts index 64273cd0..de9206de 100644 --- a/src/main/frontend/app/components/file-structure/use-studio-context-menu.ts +++ b/src/main/frontend/app/components/file-structure/use-studio-context-menu.ts @@ -1,4 +1,5 @@ import React, { useCallback, useRef, useState } from 'react' +import { useNavigate } from 'react-router' import type { TreeItemIndex } from 'react-complex-tree' import { deleteFile, renameFile } from '~/services/file-service' import { createFolderInProject } from '~/services/file-tree-service' @@ -12,6 +13,8 @@ import { FILE_NAME_PATTERNS, FOLDER_OR_ADAPTER_NAME_PATTERNS, } from '~/components/file-structure/name-input-dialog' +import { openInStudio } from '~/actions/navigationActions' +import { findAdaptersInXml } from '~/routes/editor/xml-utils' export type StudioItemType = 'root' | 'folder' | 'configuration' | 'adapter' | 'file' @@ -123,6 +126,7 @@ function getRenamePatterns(itemType: StudioItemType): Record { } export function useStudioContextMenu({ projectName, dataProvider }: UseStudioContextMenuOptions) { + const navigate = useNavigate() const [contextMenu, setContextMenu] = useState(null) const [nameDialog, setNameDialog] = useState(null) const [deleteTarget, setDeleteTarget] = useState(null) @@ -176,14 +180,12 @@ export function useStudioContextMenu({ projectName, dataProvider }: UseStudioCon onSubmit: async (name: string) => { const fileName = ensureXmlExtension(name) try { - const rootPath = dataProvider.getRootPath().replace(/[/\\]$/, '') const folderPath = menu.folderPath.replace(/[/\\]$/, '') - const relativePath = - folderPath === rootPath - ? fileName - : `${folderPath.slice(rootPath.length + 1).replaceAll('\\', '/')}/${fileName}` - await createConfigurationFile(projectName, relativePath) + const absoluteFilePath = `${folderPath}/${fileName}` + await createConfigurationFile(projectName, absoluteFilePath) await dataProvider.reloadDirectory('root') + + openInStudio(navigate, { adapterName: 'SampleAdapter', filepath: absoluteFilePath, adapterPosition: 0 }) } catch (error) { logApiError('Failed to create configuration', error as Error) } @@ -192,7 +194,7 @@ export function useStudioContextMenu({ projectName, dataProvider }: UseStudioCon patterns: CONFIGURATION_NAME_PATTERNS, }) }, - [projectName, dataProvider, closeContextMenu], + [projectName, dataProvider, navigate, closeContextMenu], ) const handleNewAdapter = useCallback( @@ -206,8 +208,14 @@ export function useStudioContextMenu({ projectName, dataProvider }: UseStudioCon submitLabel: 'Create', onSubmit: async (name: string) => { try { - await createAdapter(projectName, name, menu.path) + const { xmlContent } = await createAdapter(projectName, name, menu.path) await dataProvider.reloadDirectory('root') + + const adapterIndex = findAdaptersInXml(xmlContent).findIndex((adapter) => adapter.name === name) + + if (adapterIndex !== -1) { + openInStudio(navigate, { adapterName: name, filepath: menu.path, adapterPosition: adapterIndex }) + } } catch (error) { logApiError('Failed to create adapter', error as Error) } @@ -216,7 +224,7 @@ export function useStudioContextMenu({ projectName, dataProvider }: UseStudioCon patterns: FOLDER_OR_ADAPTER_NAME_PATTERNS, }) }, - [projectName, dataProvider, closeContextMenu], + [projectName, dataProvider, navigate, closeContextMenu], ) const handleNewFolder = useCallback( diff --git a/src/main/frontend/app/routes/configurations/add-configuration-modal.tsx b/src/main/frontend/app/routes/configurations/add-configuration-modal.tsx index 630bcc2f..43ba04df 100644 --- a/src/main/frontend/app/routes/configurations/add-configuration-modal.tsx +++ b/src/main/frontend/app/routes/configurations/add-configuration-modal.tsx @@ -52,7 +52,9 @@ export default function AddConfigurationModal({ configname = `${configname}.xml` } - await createConfigurationFile(currentConfiguration.name, `${rootLocationName}/${configname}`) + const folderPath = rootLocationName.replace(/[/\\]$/, '') + const absoluteFilePath = `${folderPath}/${configname}` + await createConfigurationFile(currentConfiguration.name, absoluteFilePath) const updatedProject = await fetchProject(currentConfiguration.name) setProject(updatedProject) onSuccess?.() diff --git a/src/main/frontend/app/services/adapter-service.ts b/src/main/frontend/app/services/adapter-service.ts index dc2129ea..ad271ff9 100644 --- a/src/main/frontend/app/services/adapter-service.ts +++ b/src/main/frontend/app/services/adapter-service.ts @@ -30,8 +30,8 @@ export async function createAdapter( projectName: string, adapterName: string, configurationPath: string, -): Promise { - await apiFetch(`/projects/${encodeURIComponent(projectName)}/adapters`, { +): Promise { + return apiFetch(`/projects/${encodeURIComponent(projectName)}/adapters`, { method: 'POST', body: JSON.stringify({ adapterName, configurationPath }), }) diff --git a/src/main/frontend/app/services/configuration-file-service.ts b/src/main/frontend/app/services/configuration-file-service.ts index b972e99c..57df65a0 100644 --- a/src/main/frontend/app/services/configuration-file-service.ts +++ b/src/main/frontend/app/services/configuration-file-service.ts @@ -48,8 +48,8 @@ export async function saveConfigurationFile( }) } -export async function createConfigurationFile(projectName: string, filename: string): Promise { - return apiFetch(`${getBaseUrl(projectName)}?name=${encodeURIComponent(filename)}`, { method: 'POST' }) +export async function createConfigurationFile(projectName: string, filepath: string): Promise { + return apiFetch(`${getBaseUrl(projectName)}?path=${encodeURIComponent(filepath)}`, { method: 'POST' }) } function getBaseUrl(projectName: string): string { diff --git a/src/main/frontend/app/utils/path-utils.ts b/src/main/frontend/app/utils/path-utils.ts index af12ef87..de4cce56 100644 --- a/src/main/frontend/app/utils/path-utils.ts +++ b/src/main/frontend/app/utils/path-utils.ts @@ -5,8 +5,8 @@ export function toRelativePath(absolutePath: string, marker: string): string | null { const normalizedPath = normalizePath(absolutePath) const normalizedMarker = normalizePath(marker) - const idx = normalizedPath.indexOf(marker) - return idx === -1 ? null : normalizedMarker.slice(idx + marker.length) + const idx = normalizedPath.indexOf(normalizedMarker) + return idx === -1 ? null : normalizedPath.slice(idx + normalizedMarker.length) } export function normalizePath(path: string) { diff --git a/src/main/java/org/frankframework/flow/adapter/AdapterController.java b/src/main/java/org/frankframework/flow/adapter/AdapterController.java index 729d7f92..91791109 100644 --- a/src/main/java/org/frankframework/flow/adapter/AdapterController.java +++ b/src/main/java/org/frankframework/flow/adapter/AdapterController.java @@ -44,9 +44,9 @@ public ResponseEntity updateAdapter(@RequestBody AdapterUpdateDTO dto) thr } @PostMapping("/{projectName}/adapters") - public ResponseEntity createAdapter(@PathVariable String projectName, @RequestBody AdapterCreateDTO dto) throws IOException { - adapterService.createAdapter(dto.configurationPath(), dto.adapterName()); - return ResponseEntity.ok().build(); + public ResponseEntity createAdapter(@PathVariable String projectName, @RequestBody AdapterCreateDTO dto) throws IOException { + String content = adapterService.createAdapter(dto.configurationPath(), dto.adapterName()); + return ResponseEntity.ok(new ConfigurationXmlDTO(content)); } @PatchMapping("/{projectName}/adapters/rename") diff --git a/src/main/java/org/frankframework/flow/adapter/AdapterService.java b/src/main/java/org/frankframework/flow/adapter/AdapterService.java index 974e2612..0e30fc6d 100644 --- a/src/main/java/org/frankframework/flow/adapter/AdapterService.java +++ b/src/main/java/org/frankframework/flow/adapter/AdapterService.java @@ -41,8 +41,9 @@ public ConfigurationXmlDTO getAdapter(String projectName, String configurationPa throws IOException, ApiException, SAXException, ParserConfigurationException, TransformerException { ConfigurationProject configurationProject = configurationProjectService.getProject(projectName); + String normalizedConfigPath = configurationPath.replace("\\", "/"); ConfigurationFile config = configurationProject.getConfigurationFiles().stream() - .filter(configurationFile -> configurationFile.getFilepath().equals(configurationPath)) + .filter(configurationFile -> configurationFile.getFilepath().replace("\\", "/").equals(normalizedConfigPath)) .findFirst() .orElseThrow(() -> new ApiException(String.format("Configuration File with path: %s not found", configurationPath), HttpStatus.NOT_FOUND)); @@ -84,7 +85,7 @@ public boolean updateAdapter(Path configurationFile, String adapterName, String } } - public void createAdapter(String configurationPath, String adapterName) throws IOException { + public String createAdapter(String configurationPath, String adapterName) throws IOException { if (configurationPath == null || configurationPath.isBlank()) { throw new IllegalArgumentException("Configuration path must not be empty"); } @@ -107,6 +108,7 @@ public void createAdapter(String configurationPath, String adapterName) throws I String updatedXml = XmlConfigurationUtils.convertNodeToString(configDoc); Files.writeString(absConfigFile, updatedXml, StandardCharsets.UTF_8, StandardOpenOption.TRUNCATE_EXISTING); + return updatedXml; } catch (Exception exception) { throw new IOException("Failed to create adapter: " + exception.getMessage(), exception); } diff --git a/src/main/java/org/frankframework/flow/configuration/ConfigurationController.java b/src/main/java/org/frankframework/flow/configuration/ConfigurationController.java index a443b6a0..ebc4e15b 100644 --- a/src/main/java/org/frankframework/flow/configuration/ConfigurationController.java +++ b/src/main/java/org/frankframework/flow/configuration/ConfigurationController.java @@ -51,9 +51,9 @@ public ResponseEntity updateConfiguration( @PostMapping() public ResponseEntity addConfiguration( @PathVariable String projectName, - @RequestParam String name + @RequestParam String path ) throws ApiException, IOException, TransformerException, ParserConfigurationException, SAXException { - String content = configurationService.addConfiguration(projectName, name); + String content = configurationService.addConfiguration(projectName, path); ConfigurationXmlDTO configurationXmlDTO = new ConfigurationXmlDTO(content); return ResponseEntity.ok(configurationXmlDTO); } diff --git a/src/main/java/org/frankframework/flow/configuration/ConfigurationService.java b/src/main/java/org/frankframework/flow/configuration/ConfigurationService.java index b19638cb..a912afea 100644 --- a/src/main/java/org/frankframework/flow/configuration/ConfigurationService.java +++ b/src/main/java/org/frankframework/flow/configuration/ConfigurationService.java @@ -30,8 +30,6 @@ @Service public class ConfigurationService { - private static final String CONFIGURATIONS_DIR = "src/main/configurations"; - private final FileSystemStorage fileSystemStorage; private final ConfigurationProjectService configurationProjectService; private final FileTreeService fileTreeService; @@ -94,26 +92,28 @@ public String updateConfiguration(String projectName, String filepath, String co } } - public String addConfiguration(String projectName, String configurationName) throws IOException, ApiException, TransformerException, ParserConfigurationException, SAXException { - ConfigurationProject configurationProject = configurationProjectService.getProject(projectName); - Path absProjectPath = fileSystemStorage.toAbsolutePath(configurationProject.getRootPath()); - Path configDir = absProjectPath.resolve(CONFIGURATIONS_DIR).normalize(); - - if (!Files.exists(configDir)) { - Files.createDirectories(configDir); + public String addConfiguration(String projectName, String filepath) throws IOException, ApiException, TransformerException, ParserConfigurationException, SAXException { + if (filepath == null || filepath.isBlank()) { + throw new ApiException("Configuration path must not be empty", HttpStatus.BAD_REQUEST); } + if (filepath.contains("..")) { + throw new ApiException("Invalid configuration path: " + filepath, HttpStatus.BAD_REQUEST); + } + + ConfigurationProject configurationProject = configurationProjectService.getProject(projectName); + Path projectRoot = fileSystemStorage.toAbsolutePath(configurationProject.getRootPath()).normalize(); + Path absoluteFilePath = fileSystemStorage.toAbsolutePath(filepath).normalize(); - Path filePath = configDir.resolve(configurationName).normalize(); - if (!filePath.startsWith(configDir)) { - throw new ApiException("Invalid configuration name: " + configurationName, HttpStatus.BAD_REQUEST); + if (!absoluteFilePath.startsWith(projectRoot)) { + throw new ApiException("Invalid configuration path: " + filepath, HttpStatus.BAD_REQUEST); } - Files.createDirectories(filePath.getParent()); + Files.createDirectories(absoluteFilePath.getParent()); String defaultXml = loadDefaultConfigurationXml(); Document updatedDocument = XmlConfigurationUtils.insertFlowNamespace(defaultXml); String updatedContent = XmlConfigurationUtils.convertNodeToString(updatedDocument); - fileSystemStorage.writeFile(filePath.toString(), updatedContent); + fileSystemStorage.writeFile(absoluteFilePath.toString(), updatedContent); fileTreeService.invalidateTreeCache(projectName); return updatedContent; } diff --git a/src/main/java/org/frankframework/flow/file/FileTreeService.java b/src/main/java/org/frankframework/flow/file/FileTreeService.java index 3edea9ce..50310c83 100644 --- a/src/main/java/org/frankframework/flow/file/FileTreeService.java +++ b/src/main/java/org/frankframework/flow/file/FileTreeService.java @@ -183,7 +183,7 @@ private List extractAdapterNames(Path xmlFile) { private String toNodePath(Path path, Path relativizeRoot, boolean useRelativePaths) { if (!useRelativePaths) { - return path.toAbsolutePath().toString(); + return path.toAbsolutePath().toString().replace("\\", "/"); } String relativePath = relativizeRoot.relativize(path).toString().replace("\\", "/"); return relativePath.isEmpty() ? "." : relativePath; diff --git a/src/main/java/org/frankframework/flow/project/ConfigurationProjectService.java b/src/main/java/org/frankframework/flow/project/ConfigurationProjectService.java index 6369cda3..7fd52f1f 100644 --- a/src/main/java/org/frankframework/flow/project/ConfigurationProjectService.java +++ b/src/main/java/org/frankframework/flow/project/ConfigurationProjectService.java @@ -234,7 +234,7 @@ public ConfigurationProject importProjectFromFiles(String projectName, List filepaths = getConfigurationFilesDynamically(configurationProject.getRootPath()); @@ -265,7 +265,7 @@ private List getConfigurationFilesDynamically(String projectRoot) { try (Stream stream = Files.walk(absolutePath)) { return stream.filter(Files::isRegularFile) .filter(path -> path.toString().toLowerCase().endsWith(".xml")) - .map(path -> fileSystemStorage.toRelativePath(path.toString())) + .map(path -> fileSystemStorage.toRelativePath(path.toString()).replace("\\", "/")) .toList(); } } catch (IOException exception) { diff --git a/src/test/java/org/frankframework/flow/configuration/ConfigurationControllerTest.java b/src/test/java/org/frankframework/flow/configuration/ConfigurationControllerTest.java index 9248a2ff..28c6dcf5 100644 --- a/src/test/java/org/frankframework/flow/configuration/ConfigurationControllerTest.java +++ b/src/test/java/org/frankframework/flow/configuration/ConfigurationControllerTest.java @@ -136,7 +136,9 @@ void addConfigurationReturnsDefaultContent() throws Exception { when(settings.getFilters()).thenReturn(Map.of(FilterType.ADAPTER, true)); when(configurationProject.getConfigurationSettings()).thenReturn(settings); - when(configurationService.addConfiguration(TEST_PROJECT_NAME, "NewConfig.xml")) + String filepath = "/path/to/" + TEST_PROJECT_NAME + "/NewConfig.xml"; + + when(configurationService.addConfiguration(TEST_PROJECT_NAME, filepath)) .thenReturn(""); when(configurationProjectService.toDto(configurationProject)) .thenReturn(new ConfigurationProjectDTO( @@ -148,11 +150,11 @@ void addConfigurationReturnsDefaultContent() throws Exception { false )); - mockMvc.perform(post("/api/projects/" + TEST_PROJECT_NAME + "/configuration?name=NewConfig.xml") + mockMvc.perform(post("/api/projects/" + TEST_PROJECT_NAME + "/configuration?path=" + filepath) .accept(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) .andExpect(jsonPath("$.xmlContent").value("")); - verify(configurationService).addConfiguration(TEST_PROJECT_NAME, "NewConfig.xml"); + verify(configurationService).addConfiguration(TEST_PROJECT_NAME, filepath); } } diff --git a/src/test/java/org/frankframework/flow/configuration/ConfigurationServiceTest.java b/src/test/java/org/frankframework/flow/configuration/ConfigurationServiceTest.java index f8609a17..1dcd9102 100644 --- a/src/test/java/org/frankframework/flow/configuration/ConfigurationServiceTest.java +++ b/src/test/java/org/frankframework/flow/configuration/ConfigurationServiceTest.java @@ -259,12 +259,11 @@ void addConfiguration_Success() throws Exception { when(configurationProjectService.getProject("myproject")).thenReturn(configurationProject); - String result = configurationService.addConfiguration("myproject", "NewConfig.xml"); + Path target = projectDir.resolve("src/main/configurations/NewConfig.xml"); + String result = configurationService.addConfiguration("myproject", target.toString()); assertNotNull(result); - - Path expectedFile = projectDir.resolve("src/main/configurations/NewConfig.xml"); - assertTrue(Files.exists(expectedFile), "NewConfig.xml should be created on disk"); + assertTrue(Files.exists(target), "NewConfig.xml should be created on disk"); verify(fileTreeService).invalidateTreeCache("myproject"); } @@ -278,10 +277,10 @@ void addConfiguration_CreatesNestedDirectories() throws Exception { ConfigurationProject configurationProject = new ConfigurationProject("myproject", projectDir.toString()); when(configurationProjectService.getProject("myproject")).thenReturn(configurationProject); - configurationService.addConfiguration("myproject", "subfolder/NestedConfig.xml"); + Path target = projectDir.resolve("subfolder/NestedConfig.xml"); + configurationService.addConfiguration("myproject", target.toString()); - Path expectedFile = projectDir.resolve("src/main/configurations/subfolder/NestedConfig.xml"); - assertTrue(Files.exists(expectedFile)); + assertTrue(Files.exists(target)); } @Test @@ -291,7 +290,14 @@ void addConfiguration_ProjectNotFound_ThrowsException() throws ApiException { } @Test - void addConfiguration_PathTraversal_ThrowsSecurityException() throws Exception { + void addConfiguration_PathTraversal_ThrowsException() throws Exception { + ApiException exception = assertThrows( + ApiException.class, () -> configurationService.addConfiguration("myproject", "../../../evil.xml")); + assertEquals(HttpStatus.BAD_REQUEST, exception.getStatus()); + } + + @Test + void addConfiguration_OutsideProject_ThrowsException() throws Exception { stubToAbsolutePath(); Path projectDir = tempDir.resolve("myproject"); @@ -299,8 +305,9 @@ void addConfiguration_PathTraversal_ThrowsSecurityException() throws Exception { ConfigurationProject configurationProject = new ConfigurationProject("myproject", projectDir.toString()); when(configurationProjectService.getProject("myproject")).thenReturn(configurationProject); + String outsidePath = tempDir.resolve("other/evil.xml").toString(); ApiException exception = assertThrows( - ApiException.class, () -> configurationService.addConfiguration("myproject", "../../../evil.xml")); + ApiException.class, () -> configurationService.addConfiguration("myproject", outsidePath)); assertEquals(HttpStatus.BAD_REQUEST, exception.getStatus()); } From 2c2ffdfa9b5b23a04868757053444e4ea420a8e7 Mon Sep 17 00:00:00 2001 From: stijnpotters Date: Mon, 15 Jun 2026 15:28:10 +0200 Subject: [PATCH 05/10] Refactor path handling in editor file structure to improve readability --- .../app/components/file-structure/editor-file-structure.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/main/frontend/app/components/file-structure/editor-file-structure.tsx b/src/main/frontend/app/components/file-structure/editor-file-structure.tsx index 1711a2c4..4f9392f0 100644 --- a/src/main/frontend/app/components/file-structure/editor-file-structure.tsx +++ b/src/main/frontend/app/components/file-structure/editor-file-structure.tsx @@ -118,8 +118,7 @@ export default function EditorFileStructure() { const paths = project?.filepaths if (!paths?.length) return - // each path → its directory segments (drop the filename) - const segments = paths.map((p) => p.replaceAll('\\', '/').split('/').slice(0, -1)) + const segments = paths.map((path) => path.replaceAll('\\', '/').split('/').slice(0, -1)) const common = segments.reduce((a, b) => { let i = 0 From 07040ad8db909403c61ff6600b462bd4b8252012 Mon Sep 17 00:00:00 2001 From: stijnpotters Date: Mon, 22 Jun 2026 13:22:05 +0200 Subject: [PATCH 06/10] Refactor adapter creation and configuration file handling to return adapter location response --- .../file-structure/editor-file-structure.tsx | 3 ++- .../use-file-tree-context-menu.ts | 5 +++-- .../file-structure/use-studio-context-menu.ts | 15 ++++++-------- .../frontend/app/services/adapter-service.ts | 6 +++--- .../services/configuration-file-service.ts | 11 +++++++--- src/main/frontend/app/types/project.types.ts | 10 ++++++++++ .../flow/adapter/AdapterController.java | 7 ++++--- .../flow/adapter/AdapterService.java | 17 ++++++++++++---- .../configuration/AdapterLocationDTO.java | 10 ++++++++++ .../ConfigurationController.java | 7 +++---- .../flow/configuration/ConfigurationFile.java | 4 +++- .../configuration/ConfigurationService.java | 15 ++++++++++++-- .../flow/file/FileTreeService.java | 5 +++-- .../CloudFileSystemStorageService.java | 5 +++-- .../flow/filesystem/FileSystemStorage.java | 9 +++++---- .../project/ConfigurationProjectService.java | 9 +++++---- .../flow/utility/PathUtils.java | 20 +++++++++++++++++++ .../flow/utility/XmlAdapterUtils.java | 14 +++++++++++++ .../flow/adapter/AdapterControllerTest.java | 6 +++++- .../flow/adapter/AdapterServiceTest.java | 6 ++++-- .../ConfigurationControllerTest.java | 7 ++++--- .../ConfigurationServiceTest.java | 4 +++- 22 files changed, 144 insertions(+), 51 deletions(-) create mode 100644 src/main/java/org/frankframework/flow/configuration/AdapterLocationDTO.java create mode 100644 src/main/java/org/frankframework/flow/utility/PathUtils.java diff --git a/src/main/frontend/app/components/file-structure/editor-file-structure.tsx b/src/main/frontend/app/components/file-structure/editor-file-structure.tsx index 4f9392f0..b72911a5 100644 --- a/src/main/frontend/app/components/file-structure/editor-file-structure.tsx +++ b/src/main/frontend/app/components/file-structure/editor-file-structure.tsx @@ -16,6 +16,7 @@ import { useFileWatcher } from '~/hooks/use-file-watcher' import { getAncestorIds, isVisibleInTree, selectAndReveal, toTreeItemId } from './tree-utilities' import type { ContextMenuState } from './use-file-tree-context-menu' import IconButton from '~/components/inputs/icon-button' +import { normalizePath } from '~/utils/path-utils' import { Tree, @@ -118,7 +119,7 @@ export default function EditorFileStructure() { const paths = project?.filepaths if (!paths?.length) return - const segments = paths.map((path) => path.replaceAll('\\', '/').split('/').slice(0, -1)) + const segments = paths.map((path) => normalizePath(path).split('/').slice(0, -1)) const common = segments.reduce((a, b) => { let i = 0 diff --git a/src/main/frontend/app/components/file-structure/use-file-tree-context-menu.ts b/src/main/frontend/app/components/file-structure/use-file-tree-context-menu.ts index cdac6a5b..11278cef 100644 --- a/src/main/frontend/app/components/file-structure/use-file-tree-context-menu.ts +++ b/src/main/frontend/app/components/file-structure/use-file-tree-context-menu.ts @@ -10,6 +10,7 @@ import { showErrorToast } from '~/components/toast' import { FILE_NAME_PATTERNS, FOLDER_OR_ADAPTER_NAME_PATTERNS } from '~/components/file-structure/name-input-dialog' import { logApiError } from '~/utils/logger' import { openInEditor } from '~/actions/navigationActions' +import { normalizePath } from '~/utils/path-utils' export interface ContextMenuState { position: { x: number; y: number } @@ -134,8 +135,8 @@ export function useFileTreeContextMenu({ const filePath = `${parentPath}/${name}` const isXml = name.toLowerCase().endsWith('.xml') - const configsRoot = configurationsRootPath?.replaceAll('\\', '/') - const normalizedParent = parentPath.replaceAll('\\', '/') + const configsRoot = configurationsRootPath ? normalizePath(configurationsRootPath) : undefined + const normalizedParent = normalizePath(parentPath) const isInsideConfigurations = !!configsRoot && normalizedParent.startsWith(configsRoot) try { diff --git a/src/main/frontend/app/components/file-structure/use-studio-context-menu.ts b/src/main/frontend/app/components/file-structure/use-studio-context-menu.ts index de9206de..ee447bf6 100644 --- a/src/main/frontend/app/components/file-structure/use-studio-context-menu.ts +++ b/src/main/frontend/app/components/file-structure/use-studio-context-menu.ts @@ -14,7 +14,6 @@ import { FOLDER_OR_ADAPTER_NAME_PATTERNS, } from '~/components/file-structure/name-input-dialog' import { openInStudio } from '~/actions/navigationActions' -import { findAdaptersInXml } from '~/routes/editor/xml-utils' export type StudioItemType = 'root' | 'folder' | 'configuration' | 'adapter' | 'file' @@ -182,10 +181,12 @@ export function useStudioContextMenu({ projectName, dataProvider }: UseStudioCon try { const folderPath = menu.folderPath.replace(/[/\\]$/, '') const absoluteFilePath = `${folderPath}/${fileName}` - await createConfigurationFile(projectName, absoluteFilePath) + const { adapterName, adapterPosition } = await createConfigurationFile(projectName, absoluteFilePath) await dataProvider.reloadDirectory('root') - openInStudio(navigate, { adapterName: 'SampleAdapter', filepath: absoluteFilePath, adapterPosition: 0 }) + if (adapterName) { + openInStudio(navigate, { adapterName, filepath: absoluteFilePath, adapterPosition }) + } } catch (error) { logApiError('Failed to create configuration', error as Error) } @@ -208,14 +209,10 @@ export function useStudioContextMenu({ projectName, dataProvider }: UseStudioCon submitLabel: 'Create', onSubmit: async (name: string) => { try { - const { xmlContent } = await createAdapter(projectName, name, menu.path) + const { adapterName, adapterPosition } = await createAdapter(projectName, name, menu.path) await dataProvider.reloadDirectory('root') - const adapterIndex = findAdaptersInXml(xmlContent).findIndex((adapter) => adapter.name === name) - - if (adapterIndex !== -1) { - openInStudio(navigate, { adapterName: name, filepath: menu.path, adapterPosition: adapterIndex }) - } + openInStudio(navigate, { adapterName: adapterName ?? name, filepath: menu.path, adapterPosition }) } catch (error) { logApiError('Failed to create adapter', error as Error) } diff --git a/src/main/frontend/app/services/adapter-service.ts b/src/main/frontend/app/services/adapter-service.ts index ad271ff9..9002a3de 100644 --- a/src/main/frontend/app/services/adapter-service.ts +++ b/src/main/frontend/app/services/adapter-service.ts @@ -1,4 +1,4 @@ -import type { XmlResponse } from '~/types/project.types' +import type { AdapterLocationResponse, XmlResponse } from '~/types/project.types' import { apiFetch } from '~/utils/api' export async function saveAdapter( @@ -30,8 +30,8 @@ export async function createAdapter( projectName: string, adapterName: string, configurationPath: string, -): Promise { - return apiFetch(`/projects/${encodeURIComponent(projectName)}/adapters`, { +): Promise { + return apiFetch(`/projects/${encodeURIComponent(projectName)}/adapters`, { method: 'POST', body: JSON.stringify({ adapterName, configurationPath }), }) diff --git a/src/main/frontend/app/services/configuration-file-service.ts b/src/main/frontend/app/services/configuration-file-service.ts index 57df65a0..fc6ca491 100644 --- a/src/main/frontend/app/services/configuration-file-service.ts +++ b/src/main/frontend/app/services/configuration-file-service.ts @@ -1,5 +1,5 @@ import { apiFetch } from '~/utils/api' -import type { XmlResponse } from '~/types/project.types' +import type { AdapterLocationResponse, XmlResponse } from '~/types/project.types' const configCache = new Map() @@ -48,8 +48,13 @@ export async function saveConfigurationFile( }) } -export async function createConfigurationFile(projectName: string, filepath: string): Promise { - return apiFetch(`${getBaseUrl(projectName)}?path=${encodeURIComponent(filepath)}`, { method: 'POST' }) +export async function createConfigurationFile( + projectName: string, + filepath: string, +): Promise { + return apiFetch(`${getBaseUrl(projectName)}?path=${encodeURIComponent(filepath)}`, { + method: 'POST', + }) } function getBaseUrl(projectName: string): string { diff --git a/src/main/frontend/app/types/project.types.ts b/src/main/frontend/app/types/project.types.ts index 73a9009d..bc273474 100644 --- a/src/main/frontend/app/types/project.types.ts +++ b/src/main/frontend/app/types/project.types.ts @@ -16,3 +16,13 @@ export interface RecentConfigurationProject { export interface XmlResponse { xmlContent: string } + +/** + * Returned when creating an adapter or configuration file. Points the studio at the adapter to open; + * the adapter's content is fetched lazily when the studio renders it. `adapterName` is null when the + * created file contains no adapter. + */ +export interface AdapterLocationResponse { + adapterName: string | null + adapterPosition: number +} diff --git a/src/main/java/org/frankframework/flow/adapter/AdapterController.java b/src/main/java/org/frankframework/flow/adapter/AdapterController.java index 91791109..8084a52c 100644 --- a/src/main/java/org/frankframework/flow/adapter/AdapterController.java +++ b/src/main/java/org/frankframework/flow/adapter/AdapterController.java @@ -4,6 +4,7 @@ import java.nio.file.Paths; import javax.xml.parsers.ParserConfigurationException; import javax.xml.transform.TransformerException; +import org.frankframework.flow.configuration.AdapterLocationDTO; import org.frankframework.flow.configuration.ConfigurationXmlDTO; import org.frankframework.flow.exception.ApiException; import org.springframework.http.ResponseEntity; @@ -44,9 +45,9 @@ public ResponseEntity updateAdapter(@RequestBody AdapterUpdateDTO dto) thr } @PostMapping("/{projectName}/adapters") - public ResponseEntity createAdapter(@PathVariable String projectName, @RequestBody AdapterCreateDTO dto) throws IOException { - String content = adapterService.createAdapter(dto.configurationPath(), dto.adapterName()); - return ResponseEntity.ok(new ConfigurationXmlDTO(content)); + public ResponseEntity createAdapter(@PathVariable String projectName, @RequestBody AdapterCreateDTO dto) throws IOException { + int adapterPosition = adapterService.createAdapter(dto.configurationPath(), dto.adapterName()); + return ResponseEntity.ok(new AdapterLocationDTO(dto.adapterName(), adapterPosition)); } @PatchMapping("/{projectName}/adapters/rename") diff --git a/src/main/java/org/frankframework/flow/adapter/AdapterService.java b/src/main/java/org/frankframework/flow/adapter/AdapterService.java index 0e30fc6d..01993a35 100644 --- a/src/main/java/org/frankframework/flow/adapter/AdapterService.java +++ b/src/main/java/org/frankframework/flow/adapter/AdapterService.java @@ -15,6 +15,7 @@ import org.frankframework.flow.filesystem.FileSystemStorage; import org.frankframework.flow.project.ConfigurationProject; import org.frankframework.flow.project.ConfigurationProjectService; +import org.frankframework.flow.utility.PathUtils; import org.frankframework.flow.utility.XmlAdapterUtils; import org.frankframework.flow.utility.XmlConfigurationUtils; import org.frankframework.flow.utility.XmlSecurityUtils; @@ -41,9 +42,9 @@ public ConfigurationXmlDTO getAdapter(String projectName, String configurationPa throws IOException, ApiException, SAXException, ParserConfigurationException, TransformerException { ConfigurationProject configurationProject = configurationProjectService.getProject(projectName); - String normalizedConfigPath = configurationPath.replace("\\", "/"); + String normalizedConfigPath = PathUtils.toForwardSlash(configurationPath); ConfigurationFile config = configurationProject.getConfigurationFiles().stream() - .filter(configurationFile -> configurationFile.getFilepath().replace("\\", "/").equals(normalizedConfigPath)) + .filter(configurationFile -> normalizedConfigPath.equals(configurationFile.getFilepath())) .findFirst() .orElseThrow(() -> new ApiException(String.format("Configuration File with path: %s not found", configurationPath), HttpStatus.NOT_FOUND)); @@ -85,7 +86,13 @@ public boolean updateAdapter(Path configurationFile, String adapterName, String } } - public String createAdapter(String configurationPath, String adapterName) throws IOException { + /** + * Creates a new adapter from the default template and appends it to the configuration file. + * + * @return the zero-based position of the newly appended adapter, matching the order the frontend + * uses to address adapters within a configuration + */ + public int createAdapter(String configurationPath, String adapterName) throws IOException { if (configurationPath == null || configurationPath.isBlank()) { throw new IllegalArgumentException("Configuration path must not be empty"); } @@ -108,7 +115,9 @@ public String createAdapter(String configurationPath, String adapterName) throws String updatedXml = XmlConfigurationUtils.convertNodeToString(configDoc); Files.writeString(absConfigFile, updatedXml, StandardCharsets.UTF_8, StandardOpenOption.TRUNCATE_EXISTING); - return updatedXml; + + // The adapter is appended last, so its position is the final index in document order. + return XmlAdapterUtils.countAdapters(configDoc) - 1; } catch (Exception exception) { throw new IOException("Failed to create adapter: " + exception.getMessage(), exception); } diff --git a/src/main/java/org/frankframework/flow/configuration/AdapterLocationDTO.java b/src/main/java/org/frankframework/flow/configuration/AdapterLocationDTO.java new file mode 100644 index 00000000..2bd1a5b3 --- /dev/null +++ b/src/main/java/org/frankframework/flow/configuration/AdapterLocationDTO.java @@ -0,0 +1,10 @@ +package org.frankframework.flow.configuration; + +/** + * Identifies a single adapter inside a configuration file so the frontend can open it in the studio. + * Returned when creating an adapter or a configuration file; the studio loads the actual content lazily. + * + * @param adapterName the name of the adapter to open, or {@code null} when the file contains no adapter + * @param adapterPosition the zero-based index of the adapter in document order + */ +public record AdapterLocationDTO(String adapterName, int adapterPosition) {} diff --git a/src/main/java/org/frankframework/flow/configuration/ConfigurationController.java b/src/main/java/org/frankframework/flow/configuration/ConfigurationController.java index ebc4e15b..ef22bb49 100644 --- a/src/main/java/org/frankframework/flow/configuration/ConfigurationController.java +++ b/src/main/java/org/frankframework/flow/configuration/ConfigurationController.java @@ -49,12 +49,11 @@ public ResponseEntity updateConfiguration( } @PostMapping() - public ResponseEntity addConfiguration( + public ResponseEntity addConfiguration( @PathVariable String projectName, @RequestParam String path ) throws ApiException, IOException, TransformerException, ParserConfigurationException, SAXException { - String content = configurationService.addConfiguration(projectName, path); - ConfigurationXmlDTO configurationXmlDTO = new ConfigurationXmlDTO(content); - return ResponseEntity.ok(configurationXmlDTO); + AdapterLocationDTO adapterLocation = configurationService.addConfiguration(projectName, path); + return ResponseEntity.ok(adapterLocation); } } diff --git a/src/main/java/org/frankframework/flow/configuration/ConfigurationFile.java b/src/main/java/org/frankframework/flow/configuration/ConfigurationFile.java index 8335d18b..04a509cf 100644 --- a/src/main/java/org/frankframework/flow/configuration/ConfigurationFile.java +++ b/src/main/java/org/frankframework/flow/configuration/ConfigurationFile.java @@ -2,6 +2,7 @@ import lombok.Getter; import lombok.Setter; +import org.frankframework.flow.utility.PathUtils; @Getter @Setter @@ -10,7 +11,8 @@ public class ConfigurationFile { private String xmlContent; public ConfigurationFile(String filepath, String xmlContent) { - this.filepath = filepath; + // Store the filepath normalized to forward slashes so every consumer can compare paths consistently. + this.filepath = PathUtils.toForwardSlash(filepath); this.xmlContent = xmlContent; } } diff --git a/src/main/java/org/frankframework/flow/configuration/ConfigurationService.java b/src/main/java/org/frankframework/flow/configuration/ConfigurationService.java index a912afea..96fd12fb 100644 --- a/src/main/java/org/frankframework/flow/configuration/ConfigurationService.java +++ b/src/main/java/org/frankframework/flow/configuration/ConfigurationService.java @@ -15,6 +15,7 @@ import org.frankframework.flow.frankconfig.FrankConfigXsdService; import org.frankframework.flow.project.ConfigurationProject; import org.frankframework.flow.project.ConfigurationProjectService; +import org.frankframework.flow.utility.XmlAdapterUtils; import org.frankframework.flow.utility.XmlConfigurationUtils; import org.frankframework.flow.utility.XmlFormatterUtils; import org.frankframework.flow.utility.XmlSecurityUtils; @@ -23,6 +24,7 @@ import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import org.w3c.dom.Document; +import org.w3c.dom.Element; import org.xml.sax.InputSource; import org.xml.sax.SAXException; @@ -92,7 +94,13 @@ public String updateConfiguration(String projectName, String filepath, String co } } - public String addConfiguration(String projectName, String filepath) throws IOException, ApiException, TransformerException, ParserConfigurationException, SAXException { + /** + * Creates a new configuration file from the default template. + * + * @return the location of the first adapter in the created file, so the frontend can open it in the studio; + * the actual content is loaded lazily when the studio renders the adapter + */ + public AdapterLocationDTO addConfiguration(String projectName, String filepath) throws IOException, ApiException, TransformerException, ParserConfigurationException, SAXException { if (filepath == null || filepath.isBlank()) { throw new ApiException("Configuration path must not be empty", HttpStatus.BAD_REQUEST); } @@ -115,7 +123,10 @@ public String addConfiguration(String projectName, String filepath) throws IOExc String updatedContent = XmlConfigurationUtils.convertNodeToString(updatedDocument); fileSystemStorage.writeFile(absoluteFilePath.toString(), updatedContent); fileTreeService.invalidateTreeCache(projectName); - return updatedContent; + + Element firstAdapter = XmlAdapterUtils.findFirstAdapter(updatedDocument); + String adapterName = firstAdapter != null ? firstAdapter.getAttribute("name") : null; + return new AdapterLocationDTO(adapterName, 0); } private String ensureFlowNamespace(String xml) { diff --git a/src/main/java/org/frankframework/flow/file/FileTreeService.java b/src/main/java/org/frankframework/flow/file/FileTreeService.java index 50310c83..5d00df9a 100644 --- a/src/main/java/org/frankframework/flow/file/FileTreeService.java +++ b/src/main/java/org/frankframework/flow/file/FileTreeService.java @@ -14,6 +14,7 @@ import org.frankframework.flow.filesystem.FileSystemStorage; import org.frankframework.flow.project.ConfigurationProject; import org.frankframework.flow.project.ConfigurationProjectService; +import org.frankframework.flow.utility.PathUtils; import org.frankframework.flow.utility.XmlSecurityUtils; import org.springframework.stereotype.Service; import org.w3c.dom.Document; @@ -183,9 +184,9 @@ private List extractAdapterNames(Path xmlFile) { private String toNodePath(Path path, Path relativizeRoot, boolean useRelativePaths) { if (!useRelativePaths) { - return path.toAbsolutePath().toString().replace("\\", "/"); + return PathUtils.toForwardSlash(path.toAbsolutePath().toString()); } - String relativePath = relativizeRoot.relativize(path).toString().replace("\\", "/"); + String relativePath = PathUtils.toForwardSlash(relativizeRoot.relativize(path).toString()); return relativePath.isEmpty() ? "." : relativePath; } diff --git a/src/main/java/org/frankframework/flow/filesystem/CloudFileSystemStorageService.java b/src/main/java/org/frankframework/flow/filesystem/CloudFileSystemStorageService.java index b0355718..7719939c 100644 --- a/src/main/java/org/frankframework/flow/filesystem/CloudFileSystemStorageService.java +++ b/src/main/java/org/frankframework/flow/filesystem/CloudFileSystemStorageService.java @@ -13,6 +13,7 @@ import java.util.stream.Stream; import lombok.extern.log4j.Log4j2; import org.frankframework.flow.common.config.ClientSession; +import org.frankframework.flow.utility.PathUtils; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Service; @@ -113,8 +114,8 @@ public Path toAbsolutePath(String path) { @Override public String toRelativePath(String absolutePath) { - String normalized = absolutePath.replace("\\", "/"); - String userRoot = getUserRootPath().toString().replace("\\", "/"); + String normalized = PathUtils.toForwardSlash(absolutePath); + String userRoot = PathUtils.toForwardSlash(getUserRootPath().toString()); if (normalized.startsWith(userRoot)) { String relative = normalized.substring(userRoot.length()); diff --git a/src/main/java/org/frankframework/flow/filesystem/FileSystemStorage.java b/src/main/java/org/frankframework/flow/filesystem/FileSystemStorage.java index ce03c0f1..d20f3a0f 100644 --- a/src/main/java/org/frankframework/flow/filesystem/FileSystemStorage.java +++ b/src/main/java/org/frankframework/flow/filesystem/FileSystemStorage.java @@ -4,6 +4,7 @@ import java.nio.file.NoSuchFileException; import java.nio.file.Path; import java.util.List; +import org.frankframework.flow.utility.PathUtils; public interface FileSystemStorage { boolean isLocalEnvironment(); @@ -58,12 +59,12 @@ default BrowseResult browse(String path) throws IOException { Path rename(String oldPath, String newPath) throws IOException; /** - * Strips the workspace root prefix from a path. - * Local: returns the path unchanged. - * Cloud: returns the path relative to the user's workspace root. + * Strips the workspace root prefix from a path and normalizes it to forward slashes. + * Local: returns the path normalized to forward slashes. + * Cloud: returns the path relative to the user's workspace root, normalized to forward slashes. */ default String toRelativePath(String absolutePath) { - return absolutePath; + return PathUtils.toForwardSlash(absolutePath); } diff --git a/src/main/java/org/frankframework/flow/project/ConfigurationProjectService.java b/src/main/java/org/frankframework/flow/project/ConfigurationProjectService.java index 7fd52f1f..96184b48 100644 --- a/src/main/java/org/frankframework/flow/project/ConfigurationProjectService.java +++ b/src/main/java/org/frankframework/flow/project/ConfigurationProjectService.java @@ -25,6 +25,7 @@ import org.frankframework.flow.projectsettings.FilterType; import org.frankframework.flow.recentproject.RecentProject; import org.frankframework.flow.recentproject.RecentProjectsService; +import org.frankframework.flow.utility.PathUtils; import org.springframework.context.annotation.Lazy; import org.springframework.core.io.ClassPathResource; import org.springframework.http.HttpStatus; @@ -200,7 +201,7 @@ public void exportProjectAsZip(String projectName, OutputStream outputStream) th Stream paths = Files.walk(projectPath)) { paths.filter(Files::isRegularFile).forEach(filePath -> { try { - String entryName = projectPath.relativize(filePath).toString().replace("\\", "/"); + String entryName = PathUtils.toForwardSlash(projectPath.relativize(filePath).toString()); zos.putNextEntry(new ZipEntry(entryName)); Files.copy(filePath, zos); zos.closeEntry(); @@ -215,7 +216,7 @@ public ConfigurationProject importProjectFromFiles(String projectName, List filepaths = getConfigurationFilesDynamically(configurationProject.getRootPath()); @@ -265,7 +266,7 @@ private List getConfigurationFilesDynamically(String projectRoot) { try (Stream stream = Files.walk(absolutePath)) { return stream.filter(Files::isRegularFile) .filter(path -> path.toString().toLowerCase().endsWith(".xml")) - .map(path -> fileSystemStorage.toRelativePath(path.toString()).replace("\\", "/")) + .map(path -> fileSystemStorage.toRelativePath(path.toString())) .toList(); } } catch (IOException exception) { diff --git a/src/main/java/org/frankframework/flow/utility/PathUtils.java b/src/main/java/org/frankframework/flow/utility/PathUtils.java new file mode 100644 index 00000000..82bf5a1c --- /dev/null +++ b/src/main/java/org/frankframework/flow/utility/PathUtils.java @@ -0,0 +1,20 @@ +package org.frankframework.flow.utility; + +import lombok.experimental.UtilityClass; + +@UtilityClass +public class PathUtils { + + /** + * Normalizes OS-specific path separators to forward slashes. + *

+ * Paths that originate from the filesystem (e.g. {@link java.nio.file.Path#toString()} on Windows) + * may contain backslashes. Every path that leaves the backend should be normalized once here so + * the rest of the backend and the frontend can rely on forward slashes consistently. + * + * @return the path with all backslashes replaced by forward slashes, or {@code null} if the input is {@code null} + */ + public static String toForwardSlash(String path) { + return path == null ? null : path.replace('\\', '/'); + } +} diff --git a/src/main/java/org/frankframework/flow/utility/XmlAdapterUtils.java b/src/main/java/org/frankframework/flow/utility/XmlAdapterUtils.java index e2186105..50fd3bdf 100644 --- a/src/main/java/org/frankframework/flow/utility/XmlAdapterUtils.java +++ b/src/main/java/org/frankframework/flow/utility/XmlAdapterUtils.java @@ -67,6 +67,20 @@ public static void addAdapterToDocument(Document configDoc, String adapterXml) t configDoc.getDocumentElement().appendChild(importedNode); } + public static int countAdapters(Document configDoc) { + return configDoc.getElementsByTagName("Adapter").getLength() + + configDoc.getElementsByTagName("adapter").getLength(); + } + + public static Element findFirstAdapter(Document configDoc) { + NodeList adapters = configDoc.getElementsByTagName("Adapter"); + if (adapters.getLength() == 0) { + adapters = configDoc.getElementsByTagName("adapter"); + } + + return adapters.getLength() > 0 ? (Element) adapters.item(0) : null; + } + /** * Renames an Adapter element (matched by old name) in the given configuration document. * diff --git a/src/test/java/org/frankframework/flow/adapter/AdapterControllerTest.java b/src/test/java/org/frankframework/flow/adapter/AdapterControllerTest.java index 66203f54..79b4770c 100644 --- a/src/test/java/org/frankframework/flow/adapter/AdapterControllerTest.java +++ b/src/test/java/org/frankframework/flow/adapter/AdapterControllerTest.java @@ -154,6 +154,8 @@ void updateAdapterConfigurationNotFoundReturns404() throws Exception { @Test void createAdapterReturns200() throws Exception { + when(adapterService.createAdapter("config1.xml", "NewAdapter")).thenReturn(2); + mockMvc.perform( post("/api/projects/MyProject/adapters") .contentType(MediaType.APPLICATION_JSON) @@ -164,7 +166,9 @@ void createAdapterReturns200() throws Exception { "adapterName": "NewAdapter" } """)) - .andExpect(status().isOk()); + .andExpect(status().isOk()) + .andExpect(jsonPath("$.adapterName").value("NewAdapter")) + .andExpect(jsonPath("$.adapterPosition").value(2)); verify(adapterService).createAdapter("config1.xml", "NewAdapter"); } diff --git a/src/test/java/org/frankframework/flow/adapter/AdapterServiceTest.java b/src/test/java/org/frankframework/flow/adapter/AdapterServiceTest.java index a159ce04..7b48c2c0 100644 --- a/src/test/java/org/frankframework/flow/adapter/AdapterServiceTest.java +++ b/src/test/java/org/frankframework/flow/adapter/AdapterServiceTest.java @@ -283,8 +283,9 @@ void createAdapter_createsAdapterInValidConfig() throws Exception { Files.writeString(configFile, xml, StandardCharsets.UTF_8); when(fileSystemStorage.toAbsolutePath("config.xml")).thenReturn(configFile); - adapterService.createAdapter("config.xml", "NewAdapter"); + int position = adapterService.createAdapter("config.xml", "NewAdapter"); + assertEquals(0, position, "First adapter in the file should be at position 0"); String written = Files.readString(configFile, StandardCharsets.UTF_8); assertTrue(written.contains("NewAdapter")); assertTrue(written.contains("Receiver")); @@ -298,8 +299,9 @@ void createAdapter_preservesExistingAdapters() throws Exception { Files.writeString(configFile, xml, StandardCharsets.UTF_8); when(fileSystemStorage.toAbsolutePath("config.xml")).thenReturn(configFile); - adapterService.createAdapter("config.xml", "NewAdapter"); + int position = adapterService.createAdapter("config.xml", "NewAdapter"); + assertEquals(1, position, "New adapter should be appended after the existing one"); String written = Files.readString(configFile, StandardCharsets.UTF_8); assertTrue(written.contains("Existing")); assertTrue(written.contains("OldPipe")); diff --git a/src/test/java/org/frankframework/flow/configuration/ConfigurationControllerTest.java b/src/test/java/org/frankframework/flow/configuration/ConfigurationControllerTest.java index 28c6dcf5..a6bb2f07 100644 --- a/src/test/java/org/frankframework/flow/configuration/ConfigurationControllerTest.java +++ b/src/test/java/org/frankframework/flow/configuration/ConfigurationControllerTest.java @@ -123,7 +123,7 @@ void updateConfigurationNotFoundReturns404() throws Exception { } @Test - void addConfigurationReturnsDefaultContent() throws Exception { + void addConfigurationReturnsAdapterLocation() throws Exception { ConfigurationProject configurationProject = mock(ConfigurationProject.class); when(configurationProject.getName()).thenReturn(TEST_PROJECT_NAME); when(configurationProject.getRootPath()).thenReturn("/path/to/" + TEST_PROJECT_NAME); @@ -139,7 +139,7 @@ void addConfigurationReturnsDefaultContent() throws Exception { String filepath = "/path/to/" + TEST_PROJECT_NAME + "/NewConfig.xml"; when(configurationService.addConfiguration(TEST_PROJECT_NAME, filepath)) - .thenReturn(""); + .thenReturn(new AdapterLocationDTO("SampleAdapter", 0)); when(configurationProjectService.toDto(configurationProject)) .thenReturn(new ConfigurationProjectDTO( TEST_PROJECT_NAME, @@ -153,7 +153,8 @@ void addConfigurationReturnsDefaultContent() throws Exception { mockMvc.perform(post("/api/projects/" + TEST_PROJECT_NAME + "/configuration?path=" + filepath) .accept(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) - .andExpect(jsonPath("$.xmlContent").value("")); + .andExpect(jsonPath("$.adapterName").value("SampleAdapter")) + .andExpect(jsonPath("$.adapterPosition").value(0)); verify(configurationService).addConfiguration(TEST_PROJECT_NAME, filepath); } diff --git a/src/test/java/org/frankframework/flow/configuration/ConfigurationServiceTest.java b/src/test/java/org/frankframework/flow/configuration/ConfigurationServiceTest.java index 1dcd9102..6eb84176 100644 --- a/src/test/java/org/frankframework/flow/configuration/ConfigurationServiceTest.java +++ b/src/test/java/org/frankframework/flow/configuration/ConfigurationServiceTest.java @@ -260,9 +260,11 @@ void addConfiguration_Success() throws Exception { when(configurationProjectService.getProject("myproject")).thenReturn(configurationProject); Path target = projectDir.resolve("src/main/configurations/NewConfig.xml"); - String result = configurationService.addConfiguration("myproject", target.toString()); + AdapterLocationDTO result = configurationService.addConfiguration("myproject", target.toString()); assertNotNull(result); + assertEquals("SampleAdapter", result.adapterName(), "Should point at the template adapter"); + assertEquals(0, result.adapterPosition()); assertTrue(Files.exists(target), "NewConfig.xml should be created on disk"); verify(fileTreeService).invalidateTreeCache("myproject"); } From 2e4e33918b852b6f4b4df856dcf2a67c475ab84c Mon Sep 17 00:00:00 2001 From: stijnpotters Date: Mon, 22 Jun 2026 13:27:59 +0200 Subject: [PATCH 07/10] Refactor createConfigurationFile function for improved readability --- src/main/frontend/app/services/configuration-file-service.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/main/frontend/app/services/configuration-file-service.ts b/src/main/frontend/app/services/configuration-file-service.ts index fc6ca491..af01cd39 100644 --- a/src/main/frontend/app/services/configuration-file-service.ts +++ b/src/main/frontend/app/services/configuration-file-service.ts @@ -48,10 +48,7 @@ export async function saveConfigurationFile( }) } -export async function createConfigurationFile( - projectName: string, - filepath: string, -): Promise { +export async function createConfigurationFile(projectName: string, filepath: string): Promise { return apiFetch(`${getBaseUrl(projectName)}?path=${encodeURIComponent(filepath)}`, { method: 'POST', }) From ee3dc72ef64dfaf5fdb8154cf09589b2705371c7 Mon Sep 17 00:00:00 2001 From: stijnpotters Date: Mon, 22 Jun 2026 13:38:24 +0200 Subject: [PATCH 08/10] Refactor adapter-related classes and remove outdated comments for clarity --- .../file-structure/editor-file-structure.tsx | 15 +-------------- src/main/frontend/app/types/project.types.ts | 5 ----- .../flow/adapter/AdapterService.java | 7 ------- .../flow/configuration/AdapterLocationDTO.java | 7 ------- .../flow/configuration/ConfigurationFile.java | 1 - .../frankframework/flow/utility/PathUtils.java | 6 ++---- 6 files changed, 3 insertions(+), 38 deletions(-) diff --git a/src/main/frontend/app/components/file-structure/editor-file-structure.tsx b/src/main/frontend/app/components/file-structure/editor-file-structure.tsx index b72911a5..ea3fb9e0 100644 --- a/src/main/frontend/app/components/file-structure/editor-file-structure.tsx +++ b/src/main/frontend/app/components/file-structure/editor-file-structure.tsx @@ -115,20 +115,7 @@ export default function EditorFileStructure() { [getTab, removeTabAndSelectFallback], ) - const configurationsRootPath = useMemo(() => { - const paths = project?.filepaths - if (!paths?.length) return - - const segments = paths.map((path) => normalizePath(path).split('/').slice(0, -1)) - - const common = segments.reduce((a, b) => { - let i = 0 - while (i < a.length && i < b.length && a[i] === b[i]) i++ - return a.slice(0, i) - }) - - return common.length > 0 ? common.join('/') : undefined - }, [project?.filepaths]) + const configurationsRootPath = project?.rootPath ? normalizePath(project.rootPath) : undefined const editorContextMenu = useFileTreeContextMenu({ projectName: project?.name, diff --git a/src/main/frontend/app/types/project.types.ts b/src/main/frontend/app/types/project.types.ts index bc273474..239f39d7 100644 --- a/src/main/frontend/app/types/project.types.ts +++ b/src/main/frontend/app/types/project.types.ts @@ -17,11 +17,6 @@ export interface XmlResponse { xmlContent: string } -/** - * Returned when creating an adapter or configuration file. Points the studio at the adapter to open; - * the adapter's content is fetched lazily when the studio renders it. `adapterName` is null when the - * created file contains no adapter. - */ export interface AdapterLocationResponse { adapterName: string | null adapterPosition: number diff --git a/src/main/java/org/frankframework/flow/adapter/AdapterService.java b/src/main/java/org/frankframework/flow/adapter/AdapterService.java index 01993a35..9726e63e 100644 --- a/src/main/java/org/frankframework/flow/adapter/AdapterService.java +++ b/src/main/java/org/frankframework/flow/adapter/AdapterService.java @@ -86,12 +86,6 @@ public boolean updateAdapter(Path configurationFile, String adapterName, String } } - /** - * Creates a new adapter from the default template and appends it to the configuration file. - * - * @return the zero-based position of the newly appended adapter, matching the order the frontend - * uses to address adapters within a configuration - */ public int createAdapter(String configurationPath, String adapterName) throws IOException { if (configurationPath == null || configurationPath.isBlank()) { throw new IllegalArgumentException("Configuration path must not be empty"); @@ -116,7 +110,6 @@ public int createAdapter(String configurationPath, String adapterName) throws IO String updatedXml = XmlConfigurationUtils.convertNodeToString(configDoc); Files.writeString(absConfigFile, updatedXml, StandardCharsets.UTF_8, StandardOpenOption.TRUNCATE_EXISTING); - // The adapter is appended last, so its position is the final index in document order. return XmlAdapterUtils.countAdapters(configDoc) - 1; } catch (Exception exception) { throw new IOException("Failed to create adapter: " + exception.getMessage(), exception); diff --git a/src/main/java/org/frankframework/flow/configuration/AdapterLocationDTO.java b/src/main/java/org/frankframework/flow/configuration/AdapterLocationDTO.java index 2bd1a5b3..37eba7a3 100644 --- a/src/main/java/org/frankframework/flow/configuration/AdapterLocationDTO.java +++ b/src/main/java/org/frankframework/flow/configuration/AdapterLocationDTO.java @@ -1,10 +1,3 @@ package org.frankframework.flow.configuration; -/** - * Identifies a single adapter inside a configuration file so the frontend can open it in the studio. - * Returned when creating an adapter or a configuration file; the studio loads the actual content lazily. - * - * @param adapterName the name of the adapter to open, or {@code null} when the file contains no adapter - * @param adapterPosition the zero-based index of the adapter in document order - */ public record AdapterLocationDTO(String adapterName, int adapterPosition) {} diff --git a/src/main/java/org/frankframework/flow/configuration/ConfigurationFile.java b/src/main/java/org/frankframework/flow/configuration/ConfigurationFile.java index 04a509cf..39b2eda5 100644 --- a/src/main/java/org/frankframework/flow/configuration/ConfigurationFile.java +++ b/src/main/java/org/frankframework/flow/configuration/ConfigurationFile.java @@ -11,7 +11,6 @@ public class ConfigurationFile { private String xmlContent; public ConfigurationFile(String filepath, String xmlContent) { - // Store the filepath normalized to forward slashes so every consumer can compare paths consistently. this.filepath = PathUtils.toForwardSlash(filepath); this.xmlContent = xmlContent; } diff --git a/src/main/java/org/frankframework/flow/utility/PathUtils.java b/src/main/java/org/frankframework/flow/utility/PathUtils.java index 82bf5a1c..f5ec2c41 100644 --- a/src/main/java/org/frankframework/flow/utility/PathUtils.java +++ b/src/main/java/org/frankframework/flow/utility/PathUtils.java @@ -7,12 +7,10 @@ public class PathUtils { /** * Normalizes OS-specific path separators to forward slashes. - *

- * Paths that originate from the filesystem (e.g. {@link java.nio.file.Path#toString()} on Windows) - * may contain backslashes. Every path that leaves the backend should be normalized once here so + * Every path that leaves the backend should be normalized once here so * the rest of the backend and the frontend can rely on forward slashes consistently. * - * @return the path with all backslashes replaced by forward slashes, or {@code null} if the input is {@code null} + * @return the path with all backslashes replaced by forward slashes */ public static String toForwardSlash(String path) { return path == null ? null : path.replace('\\', '/'); From d011df3d7c2da1e816c09716bb4465470fc64221 Mon Sep 17 00:00:00 2001 From: stijnpotters Date: Mon, 22 Jun 2026 13:38:50 +0200 Subject: [PATCH 09/10] Refactor adapter-related classes and remove outdated comments for clarity --- .../app/routes/projectlanding/clone-configuration-modal.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main/frontend/app/routes/projectlanding/clone-configuration-modal.tsx b/src/main/frontend/app/routes/projectlanding/clone-configuration-modal.tsx index 67ce0095..5ccdfb12 100644 --- a/src/main/frontend/app/routes/projectlanding/clone-configuration-modal.tsx +++ b/src/main/frontend/app/routes/projectlanding/clone-configuration-modal.tsx @@ -34,7 +34,10 @@ export default function CloneConfigurationModal({ setLocation(initialPath ?? '') }, [isLocal, initialPath]) - const repoName = repoUrl.split('/').pop()?.replace(/\.git$/i, '') + const repoName = repoUrl + .split('/') + .pop() + ?.replace(/\.git$/i, '') const handleClone = () => { if (!repoUrl.trim()) return From e8df211b04c4f7400ce90f657a61840d8cd07e53 Mon Sep 17 00:00:00 2001 From: stijnpotters Date: Tue, 23 Jun 2026 12:54:08 +0200 Subject: [PATCH 10/10] Refactor file path handling to remove unnecessary normalization and improve clarity --- .../app/components/file-structure/editor-file-structure.tsx | 3 +-- .../components/file-structure/use-file-tree-context-menu.ts | 5 +---- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/src/main/frontend/app/components/file-structure/editor-file-structure.tsx b/src/main/frontend/app/components/file-structure/editor-file-structure.tsx index ea3fb9e0..306c8fa2 100644 --- a/src/main/frontend/app/components/file-structure/editor-file-structure.tsx +++ b/src/main/frontend/app/components/file-structure/editor-file-structure.tsx @@ -16,7 +16,6 @@ import { useFileWatcher } from '~/hooks/use-file-watcher' import { getAncestorIds, isVisibleInTree, selectAndReveal, toTreeItemId } from './tree-utilities' import type { ContextMenuState } from './use-file-tree-context-menu' import IconButton from '~/components/inputs/icon-button' -import { normalizePath } from '~/utils/path-utils' import { Tree, @@ -115,7 +114,7 @@ export default function EditorFileStructure() { [getTab, removeTabAndSelectFallback], ) - const configurationsRootPath = project?.rootPath ? normalizePath(project.rootPath) : undefined + const configurationsRootPath = project?.rootPath const editorContextMenu = useFileTreeContextMenu({ projectName: project?.name, diff --git a/src/main/frontend/app/components/file-structure/use-file-tree-context-menu.ts b/src/main/frontend/app/components/file-structure/use-file-tree-context-menu.ts index 11278cef..0b43400e 100644 --- a/src/main/frontend/app/components/file-structure/use-file-tree-context-menu.ts +++ b/src/main/frontend/app/components/file-structure/use-file-tree-context-menu.ts @@ -10,7 +10,6 @@ import { showErrorToast } from '~/components/toast' import { FILE_NAME_PATTERNS, FOLDER_OR_ADAPTER_NAME_PATTERNS } from '~/components/file-structure/name-input-dialog' import { logApiError } from '~/utils/logger' import { openInEditor } from '~/actions/navigationActions' -import { normalizePath } from '~/utils/path-utils' export interface ContextMenuState { position: { x: number; y: number } @@ -135,9 +134,7 @@ export function useFileTreeContextMenu({ const filePath = `${parentPath}/${name}` const isXml = name.toLowerCase().endsWith('.xml') - const configsRoot = configurationsRootPath ? normalizePath(configurationsRootPath) : undefined - const normalizedParent = normalizePath(parentPath) - const isInsideConfigurations = !!configsRoot && normalizedParent.startsWith(configsRoot) + const isInsideConfigurations = parentPath.startsWith(configurationsRootPath ?? '') try { await (isXml && isInsideConfigurations