From 6a894688859f9ca801adf3de3c3d25f137186c8e Mon Sep 17 00:00:00 2001 From: Vivy <4380412+Matthbo@users.noreply.github.com> Date: Thu, 19 Mar 2026 18:15:45 +0100 Subject: [PATCH 01/18] Rework configuration endpoints --- .../app/services/configuration-service.ts | 24 +++---- .../ConfigurationController.java | 59 +++++++++-------- .../configuration/ConfigurationPathDTO.java | 3 - .../configuration/ConfigurationService.java | 66 ++++++++++--------- 4 files changed, 78 insertions(+), 74 deletions(-) delete mode 100644 src/main/java/org/frankframework/flow/configuration/ConfigurationPathDTO.java diff --git a/src/main/frontend/app/services/configuration-service.ts b/src/main/frontend/app/services/configuration-service.ts index ab7499c6..e25fe102 100644 --- a/src/main/frontend/app/services/configuration-service.ts +++ b/src/main/frontend/app/services/configuration-service.ts @@ -1,5 +1,5 @@ import { apiFetch } from '~/utils/api' -import type { Project, XmlResponse } from '~/types/project.types' +import type { XmlResponse } from '~/types/project.types' const configCache = new Map() @@ -24,24 +24,24 @@ export async function fetchConfigurationCached( } export async function fetchConfiguration(projectName: string, filepath: string, signal?: AbortSignal): Promise { - const data = await apiFetch<{ content: string }>(`/projects/${encodeURIComponent(projectName)}/configuration`, { - method: 'POST', - body: JSON.stringify({ filepath }), + const { content } = await apiFetch<{ content: string }>(`${getBaseUrl(projectName)}?filepath=${filepath}`, { + method: 'GET', signal, }) - return data.content + return content } export async function saveConfiguration(projectName: string, filepath: string, content: string): Promise { - return apiFetch(`/projects/${encodeURIComponent(projectName)}/configuration`, { - method: 'PUT', + return apiFetch(getBaseUrl(projectName), { + method: 'POST', body: JSON.stringify({ filepath, content }), }) } -export async function createConfiguration(projectName: string, filename: string): Promise { - return apiFetch( - `/projects/${encodeURIComponent(projectName)}/configurations/${encodeURIComponent(filename)}`, - { method: 'POST' }, - ) +export async function createConfiguration(projectName: string, filename: string): Promise { + return apiFetch(`${getBaseUrl(projectName)}/${encodeURIComponent(filename)}`, { method: 'POST' }) +} + +function getBaseUrl(projectName: string) { + return `/projects/${encodeURIComponent(projectName)}/configuration` } diff --git a/src/main/java/org/frankframework/flow/configuration/ConfigurationController.java b/src/main/java/org/frankframework/flow/configuration/ConfigurationController.java index 90280d73..0cefb85f 100644 --- a/src/main/java/org/frankframework/flow/configuration/ConfigurationController.java +++ b/src/main/java/org/frankframework/flow/configuration/ConfigurationController.java @@ -1,59 +1,62 @@ package org.frankframework.flow.configuration; import java.io.IOException; + import javax.xml.parsers.ParserConfigurationException; import javax.xml.transform.TransformerException; -import lombok.extern.slf4j.Slf4j; -import org.frankframework.flow.project.Project; -import org.frankframework.flow.project.ProjectDTO; -import org.frankframework.flow.project.ProjectNotFoundException; -import org.frankframework.flow.project.ProjectService; -import org.frankframework.flow.xml.XmlDTO; + import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import org.xml.sax.SAXException; +import lombok.extern.slf4j.Slf4j; + +import org.frankframework.flow.exception.ApiException; +import org.frankframework.flow.xml.XmlDTO; + @Slf4j @RestController -@RequestMapping("/projects") +@RequestMapping("/projects/{projectName}/configuration") public class ConfigurationController { private final ConfigurationService configurationService; - private final ProjectService projectService; - public ConfigurationController(ConfigurationService configurationService, ProjectService projectService) { + public ConfigurationController(ConfigurationService configurationService) { this.configurationService = configurationService; - this.projectService = projectService; } - @PostMapping("/{projectName}/configuration") - public ResponseEntity getConfigurationByPath(@RequestBody ConfigurationPathDTO requestBody) - throws ConfigurationNotFoundException, IOException { - String content = configurationService.getConfigurationContent(requestBody.filepath()); - return ResponseEntity.ok(new ConfigurationDTO(requestBody.filepath(), content)); + @GetMapping("/") + public ResponseEntity getConfigurationByPath( + @PathVariable String projectName, + @RequestParam String filepath + ) throws IOException, ApiException { + ConfigurationDTO dto = configurationService.getConfigurationContent(projectName, filepath); + return ResponseEntity.ok(dto); } - @PutMapping("/{projectName}/configuration") + @PostMapping("/") public ResponseEntity updateConfiguration( - @RequestBody ConfigurationDTO configurationDTO) - throws ConfigurationNotFoundException, IOException, ParserConfigurationException, - SAXException, TransformerException { - String updatedContent = configurationService.updateConfiguration( - configurationDTO.filepath(), configurationDTO.content()); + @PathVariable String projectName, + @RequestBody ConfigurationDTO configurationDTO + ) throws ApiException, IOException, ParserConfigurationException, SAXException, TransformerException { + String updatedContent = configurationService.updateConfiguration(projectName, configurationDTO.filepath(), configurationDTO.content()); XmlDTO xmlDTO = new XmlDTO(updatedContent); return ResponseEntity.ok(xmlDTO); } - @PostMapping("/{projectName}/configurations/{configName}") - public ResponseEntity addConfiguration( - @PathVariable String projectName, @PathVariable String configName) - throws ProjectNotFoundException, IOException { - Project project = configurationService.addConfiguration(projectName, configName); - return ResponseEntity.ok(projectService.toDto(project)); + @PostMapping("/{fileName}") + public ResponseEntity addConfiguration( + @PathVariable String projectName, + @PathVariable String fileName + ) throws ApiException, IOException { + String content = configurationService.addConfiguration(projectName, fileName); + XmlDTO xmlDTO = new XmlDTO(content); + return ResponseEntity.ok(xmlDTO); } } diff --git a/src/main/java/org/frankframework/flow/configuration/ConfigurationPathDTO.java b/src/main/java/org/frankframework/flow/configuration/ConfigurationPathDTO.java deleted file mode 100644 index fd624216..00000000 --- a/src/main/java/org/frankframework/flow/configuration/ConfigurationPathDTO.java +++ /dev/null @@ -1,3 +0,0 @@ -package org.frankframework.flow.configuration; - -public record ConfigurationPathDTO(String filepath) {} diff --git a/src/main/java/org/frankframework/flow/configuration/ConfigurationService.java b/src/main/java/org/frankframework/flow/configuration/ConfigurationService.java index 8bb6a01a..5bdda94a 100644 --- a/src/main/java/org/frankframework/flow/configuration/ConfigurationService.java +++ b/src/main/java/org/frankframework/flow/configuration/ConfigurationService.java @@ -4,18 +4,21 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; + import javax.xml.parsers.ParserConfigurationException; import javax.xml.transform.TransformerException; + +import org.springframework.core.io.ClassPathResource; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.w3c.dom.Document; +import org.xml.sax.SAXException; + import org.frankframework.flow.exception.ApiException; import org.frankframework.flow.filesystem.FileSystemStorage; import org.frankframework.flow.project.Project; -import org.frankframework.flow.project.ProjectNotFoundException; import org.frankframework.flow.project.ProjectService; import org.frankframework.flow.utility.XmlConfigurationUtils; -import org.springframework.core.io.ClassPathResource; -import org.springframework.stereotype.Service; -import org.w3c.dom.Document; -import org.xml.sax.SAXException; @Service public class ConfigurationService { @@ -30,44 +33,37 @@ public ConfigurationService(FileSystemStorage fileSystemStorage, ProjectService this.projectService = projectService; } - public String getConfigurationContent(String filepath) throws IOException, ConfigurationNotFoundException { + public ConfigurationDTO getConfigurationContent(String projectName, String filepath) throws IOException, ApiException { Path filePath = fileSystemStorage.toAbsolutePath(filepath); - if (!Files.exists(filePath)) { - throw new ConfigurationNotFoundException("Configuration file not found: " + filepath); + if (!Files.exists(filePath) || Files.isDirectory(filePath)) { + throw new ApiException("Invalid configuration path: " + filepath, HttpStatus.NOT_FOUND); } - if (Files.isDirectory(filePath)) { - throw new ConfigurationNotFoundException("Invalid configuration path: " + filepath); - } - - return fileSystemStorage.readFile(filePath.toString()); + // TODO check if filepath is part of configuration files + String content = fileSystemStorage.readFile(filePath.toString()); + return new ConfigurationDTO(filepath, content); } - public String updateConfiguration(String filepath, String content) - throws IOException, ConfigurationNotFoundException, ParserConfigurationException, SAXException, - TransformerException { + public String updateConfiguration(String projectName, String filepath, String content) + throws IOException, ApiException, ParserConfigurationException, SAXException, TransformerException { Path absolutePath = fileSystemStorage.toAbsolutePath(filepath); - if (!Files.exists(absolutePath)) { - throw new ConfigurationNotFoundException("Invalid file path: " + filepath); + if (!Files.exists(absolutePath) || Files.isDirectory(absolutePath)) { + // TODO should be a custom FileSystem/IO Exception + throw new ApiException("Invalid file path: " + filepath, HttpStatus.NOT_FOUND); } - if (Files.isDirectory(absolutePath)) { - throw new ConfigurationNotFoundException("Invalid file path: " + filepath); - } + // TODO check if filepath is part of configuration files Document updatedDocument = XmlConfigurationUtils.insertFlowNamespace(content); String updatedContent = XmlConfigurationUtils.convertNodeToString(updatedDocument); - // Just write to the disk. ProjectService reads directly from disk now! fileSystemStorage.writeFile(absolutePath.toString(), updatedContent); return updatedContent; } - public Project addConfiguration(String projectName, String configurationName) - throws ProjectNotFoundException, IOException { - Project project = projectService.getProject(projectName); - + public String addConfiguration(String projectName, String configurationName) throws IOException, ApiException { + Project project = projectService.getProject(projectName); Path absProjectPath = fileSystemStorage.toAbsolutePath(project.getRootPath()); Path configDir = absProjectPath.resolve(CONFIGURATIONS_DIR).normalize(); @@ -77,16 +73,22 @@ public Project addConfiguration(String projectName, String configurationName) Path filePath = configDir.resolve(configurationName).normalize(); if (!filePath.startsWith(configDir)) { - throw new SecurityException("Invalid configuration name: " + configurationName); + // TODO should be a custom FileSystem/IO Exception + throw new ApiException("Invalid configuration name: " + configurationName, HttpStatus.BAD_REQUEST); } + // TODO check if filepath is part of configuration files + String defaultXml = loadDefaultConfigurationXml(); fileSystemStorage.writeFile(filePath.toString(), defaultXml); - - // Returning the project handles everything, as 'toDto' will pick up the new file - return project; + return defaultXml; } + + /* + * Gets called from FileTreeService, maybe this should be part of that there? + * TODO see if this should be reworked + * */ public Project addConfigurationToFolder(String projectName, String configurationName, String folderPath) throws IOException, ApiException { Project project = projectService.getProject(projectName); @@ -95,6 +97,7 @@ public Project addConfigurationToFolder(String projectName, String configuration Path targetDir = fileSystemStorage.toAbsolutePath(folderPath); if (!targetDir.startsWith(absProjectPath)) { + // TODO should be a custom FileSystem/IO Exception throw new SecurityException("Configuration location must be within the project directory"); } @@ -122,6 +125,7 @@ private String loadDefaultConfigurationXml() throws IOException { new ClassPathResource("templates/default-configuration.xml") .getInputStream() .readAllBytes(), - StandardCharsets.UTF_8); + StandardCharsets.UTF_8 + ); } } From 2afbbb105097b61d69918322527a5b3ae7b0e037 Mon Sep 17 00:00:00 2001 From: Vivy <4380412+Matthbo@users.noreply.github.com> Date: Thu, 19 Mar 2026 19:14:51 +0100 Subject: [PATCH 02/18] Make configuration endpoints RESTful --- .../app/services/configuration-service.ts | 14 ++++----- .../app/services/file-tree-service.ts | 30 ++++++++++++------- .../ConfigurationController.java | 13 ++++---- 3 files changed, 34 insertions(+), 23 deletions(-) diff --git a/src/main/frontend/app/services/configuration-service.ts b/src/main/frontend/app/services/configuration-service.ts index e25fe102..f073f0b5 100644 --- a/src/main/frontend/app/services/configuration-service.ts +++ b/src/main/frontend/app/services/configuration-service.ts @@ -24,16 +24,16 @@ export async function fetchConfigurationCached( } export async function fetchConfiguration(projectName: string, filepath: string, signal?: AbortSignal): Promise { - const { content } = await apiFetch<{ content: string }>(`${getBaseUrl(projectName)}?filepath=${filepath}`, { - method: 'GET', - signal, - }) + const { content } = await apiFetch<{ content: string }>( + `${getBaseUrl(projectName)}/${encodeURIComponent(filepath)}`, + { method: 'GET', signal }, + ) return content } export async function saveConfiguration(projectName: string, filepath: string, content: string): Promise { - return apiFetch(getBaseUrl(projectName), { - method: 'POST', + return apiFetch(`${getBaseUrl(projectName)}/${encodeURIComponent(filepath)}`, { + method: 'PUT', body: JSON.stringify({ filepath, content }), }) } @@ -42,6 +42,6 @@ export async function createConfiguration(projectName: string, filename: string) return apiFetch(`${getBaseUrl(projectName)}/${encodeURIComponent(filename)}`, { method: 'POST' }) } -function getBaseUrl(projectName: string) { +function getBaseUrl(projectName: string): string { return `/projects/${encodeURIComponent(projectName)}/configuration` } diff --git a/src/main/frontend/app/services/file-tree-service.ts b/src/main/frontend/app/services/file-tree-service.ts index c80dbea1..5390ecb1 100644 --- a/src/main/frontend/app/services/file-tree-service.ts +++ b/src/main/frontend/app/services/file-tree-service.ts @@ -2,17 +2,15 @@ import { apiFetch } from '~/utils/api' import type { FileTreeNode } from '~/types/filesystem.types' export async function fetchProjectTree(projectName: string, signal?: AbortSignal): Promise { - return apiFetch(`/projects/${encodeURIComponent(projectName)}/tree/configurations`, { signal }) + return apiFetch(`${getTreeUrl(projectName)}/configurations`, { signal }) } export async function fetchShallowConfigurationsTree(projectName: string, signal?: AbortSignal): Promise { - return apiFetch(`/projects/${encodeURIComponent(projectName)}/tree/configurations?shallow=true`, { - signal, - }) + return apiFetch(`${getTreeUrl(projectName)}/configurations?shallow=true`, { signal }) } export async function fetchProjectRootTree(projectName: string, signal?: AbortSignal): Promise { - return apiFetch(`/projects/${encodeURIComponent(projectName)}/tree`, { signal }) + return apiFetch(getTreeUrl(projectName), { signal }) } export async function fetchDirectoryByPath( @@ -48,14 +46,26 @@ export async function createFolderInProject( } export async function renameInProject(projectName: string, oldPath: string, newName: string): Promise { - return apiFetch(`/projects/${encodeURIComponent(projectName)}/files/rename`, { - method: 'PATCH', + return apiFetch(`${getFilesUrl(projectName)}/rename`, { + method: 'PATCH', // TODO this should be POST body: JSON.stringify({ oldPath, newName }), }) } +// TODO saveFile (none configuration) + export async function deleteInProject(projectName: string, path: string): Promise { - await apiFetch(`/projects/${encodeURIComponent(projectName)}/files?path=${encodeURIComponent(path)}`, { - method: 'DELETE', - }) + await apiFetch(`${getFilesUrl(projectName)}/${encodeURIComponent(path)}`, { method: 'DELETE' }) +} + +function getBaseUrl(projectName: string): string { + return `/projects/${encodeURIComponent(projectName)}` +} + +function getFilesUrl(projectName: string): string { + return `${getBaseUrl(projectName)}/files` +} + +function getTreeUrl(projectName: string): string { + return `${getBaseUrl(projectName)}/tree` } diff --git a/src/main/java/org/frankframework/flow/configuration/ConfigurationController.java b/src/main/java/org/frankframework/flow/configuration/ConfigurationController.java index 0cefb85f..b7091ba0 100644 --- a/src/main/java/org/frankframework/flow/configuration/ConfigurationController.java +++ b/src/main/java/org/frankframework/flow/configuration/ConfigurationController.java @@ -9,9 +9,9 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import org.xml.sax.SAXException; @@ -31,21 +31,22 @@ public ConfigurationController(ConfigurationService configurationService) { this.configurationService = configurationService; } - @GetMapping("/") + @GetMapping("/{filepath}") public ResponseEntity getConfigurationByPath( @PathVariable String projectName, - @RequestParam String filepath + @PathVariable String filepath ) throws IOException, ApiException { ConfigurationDTO dto = configurationService.getConfigurationContent(projectName, filepath); return ResponseEntity.ok(dto); } - @PostMapping("/") + @PutMapping("/{filepath}") public ResponseEntity updateConfiguration( @PathVariable String projectName, - @RequestBody ConfigurationDTO configurationDTO + @PathVariable String filepath, + @RequestBody String content ) throws ApiException, IOException, ParserConfigurationException, SAXException, TransformerException { - String updatedContent = configurationService.updateConfiguration(projectName, configurationDTO.filepath(), configurationDTO.content()); + String updatedContent = configurationService.updateConfiguration(projectName, filepath, content); XmlDTO xmlDTO = new XmlDTO(updatedContent); return ResponseEntity.ok(xmlDTO); } From d768971814ad21a41ae6437738f3d5646712be2c Mon Sep 17 00:00:00 2001 From: Vivy <4380412+Matthbo@users.noreply.github.com> Date: Mon, 23 Mar 2026 17:14:31 +0100 Subject: [PATCH 03/18] Split filetree service into file-service & filetree-service --- .../use-file-tree-context-menu.ts | 16 +-- .../app/services/configuration-service.ts | 2 +- .../frontend/app/services/file-service.ts | 36 ++++++ .../app/services/file-tree-service.ts | 40 +----- .../datamapper/DatamapperConfigService.java | 5 +- .../DatamapperGeneratorService.java | 15 ++- .../flow/file/FileController.java | 54 ++++++++ .../flow/file/FileRenameDTO.java | 4 + .../frankframework/flow/file/FileService.java | 115 ++++++++++++++++++ .../flow/file/FileTreeController.java | 59 +++++++++ .../flow/file/FileTreeNode.java | 20 +++ .../{filetree => file}/FileTreeService.java | 110 ++--------------- .../flow/file/FolderCreateDTO.java | 4 + .../flow/{filetree => file}/NodeType.java | 2 +- .../DatamapperConfigServiceTest.java | 17 +-- .../DatamapperGeneratorServiceTest.java | 56 +++++---- .../FileTreeServiceTest.java | 60 +++++---- 17 files changed, 415 insertions(+), 200 deletions(-) create mode 100644 src/main/frontend/app/services/file-service.ts create mode 100644 src/main/java/org/frankframework/flow/file/FileController.java create mode 100644 src/main/java/org/frankframework/flow/file/FileRenameDTO.java create mode 100644 src/main/java/org/frankframework/flow/file/FileService.java create mode 100644 src/main/java/org/frankframework/flow/file/FileTreeController.java create mode 100644 src/main/java/org/frankframework/flow/file/FileTreeNode.java rename src/main/java/org/frankframework/flow/{filetree => file}/FileTreeService.java (69%) create mode 100644 src/main/java/org/frankframework/flow/file/FolderCreateDTO.java rename src/main/java/org/frankframework/flow/{filetree => file}/NodeType.java (51%) rename src/test/java/org/frankframework/flow/{filetree => file}/FileTreeServiceTest.java (98%) 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 ffd8177f..ad755557 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,11 +1,7 @@ import { useCallback, useRef, useState } from 'react' import type { TreeItemIndex } from 'react-complex-tree' -import { - createFileInProject, - createFolderInProject, - renameInProject, - deleteInProject, -} from '~/services/file-tree-service' +import { createFile, deleteFile, renameFile } from '~/services/file-service'; +import { createFolderInProject } from '~/services/file-tree-service' import { clearConfigurationCache } from '~/services/configuration-service' import useTabStore from '~/stores/tab-store' import useEditorTabStore from '~/stores/editor-tab-store' @@ -117,9 +113,9 @@ export function useFileTreeContextMenu({ setNameDialog({ title: 'New File', onSubmit: async (name: string) => { - const fileName = ensureXmlExtension(name) + try { - await createFileInProject(projectName, parentPath, fileName) + await createFile(projectName, `${parentPath}/${ensureXmlExtension(name)}`) await dataProvider.reloadDirectory(parentItemId) } catch (error) { showErrorToastFrom('Failed to create file', error) @@ -173,7 +169,7 @@ export function useFileTreeContextMenu({ return } try { - await renameInProject(projectName, oldPath, newName) + await renameFile(projectName, oldPath, newName) clearConfigurationCache(projectName, oldPath) const newPath = buildNewPath(oldPath, newName) useTabStore.getState().renameTabsForConfig(oldPath, newPath) @@ -209,7 +205,7 @@ export function useFileTreeContextMenu({ if (!deleteTarget || !projectName || !dataProvider) return try { - await deleteInProject(projectName, deleteTarget.path) + await deleteFile(projectName, deleteTarget.path) clearConfigurationCache(projectName, deleteTarget.path) useTabStore.getState().removeTabsForConfig(deleteTarget.path) useEditorTabStore.getState().refreshAllTabs() diff --git a/src/main/frontend/app/services/configuration-service.ts b/src/main/frontend/app/services/configuration-service.ts index f073f0b5..73be8499 100644 --- a/src/main/frontend/app/services/configuration-service.ts +++ b/src/main/frontend/app/services/configuration-service.ts @@ -26,7 +26,7 @@ export async function fetchConfigurationCached( export async function fetchConfiguration(projectName: string, filepath: string, signal?: AbortSignal): Promise { const { content } = await apiFetch<{ content: string }>( `${getBaseUrl(projectName)}/${encodeURIComponent(filepath)}`, - { method: 'GET', signal }, + { signal }, ) return content } diff --git a/src/main/frontend/app/services/file-service.ts b/src/main/frontend/app/services/file-service.ts new file mode 100644 index 00000000..9d38c185 --- /dev/null +++ b/src/main/frontend/app/services/file-service.ts @@ -0,0 +1,36 @@ +import type { FileTreeNode } from '~/types/filesystem.types' +import { apiFetch } from '~/utils/api' + +export async function createFile(projectName: string, filePath: string): Promise { + await updateFile(projectName, filePath, '') +} + +export async function fetchFile(projectName: string, filepath: string, signal?: AbortSignal): Promise { + const { content } = await apiFetch<{ content: string }>( + `${getBaseUrl(projectName)}/${encodeURIComponent(filepath)}`, + { signal }, + ) + return content +} + +export async function updateFile(projectName: string, filePath: string, fileContent: string): Promise { + return apiFetch(`${getBaseUrl(projectName)}/${encodeURIComponent(filePath)}`, { + method: 'PUT', + body: fileContent, + }) +} + +export async function renameFile(projectName: string, oldPath: string, newPath: string): Promise { + return apiFetch(`${getBaseUrl(projectName)}/move`, { + method: 'POST', + body: JSON.stringify({ oldPath, newPath }), + }) +} + +export async function deleteFile(projectName: string, path: string): Promise { + await apiFetch(`${getBaseUrl(projectName)}/${encodeURIComponent(path)}`, { method: 'DELETE' }) +} + +function getBaseUrl(projectName: string): string { + return `/projects/${encodeURIComponent(projectName)}/files` +} diff --git a/src/main/frontend/app/services/file-tree-service.ts b/src/main/frontend/app/services/file-tree-service.ts index 5390ecb1..db5abda4 100644 --- a/src/main/frontend/app/services/file-tree-service.ts +++ b/src/main/frontend/app/services/file-tree-service.ts @@ -18,54 +18,22 @@ export async function fetchDirectoryByPath( path: string, signal?: AbortSignal, ): Promise { - return apiFetch(`/projects/${encodeURIComponent(projectName)}?path=${encodeURIComponent(path)}`, { + return apiFetch(`${getBaseUrl(projectName)}?path=${encodeURIComponent(path)}`, { signal, }) } -export async function createFileInProject( - projectName: string, - parentPath: string, - name: string, -): Promise { - return apiFetch(`/projects/${encodeURIComponent(projectName)}/files`, { - method: 'POST', - body: JSON.stringify({ path: parentPath, name }), - }) -} - -export async function createFolderInProject( - projectName: string, - parentPath: string, - name: string, -): Promise { - return apiFetch(`/projects/${encodeURIComponent(projectName)}/folders`, { +export async function createFolderInProject(projectName: string, path: string, name: string): Promise { + return apiFetch(`${getBaseUrl(projectName)}/folders`, { method: 'POST', - body: JSON.stringify({ path: parentPath, name }), + body: JSON.stringify({ path, name }), }) } -export async function renameInProject(projectName: string, oldPath: string, newName: string): Promise { - return apiFetch(`${getFilesUrl(projectName)}/rename`, { - method: 'PATCH', // TODO this should be POST - body: JSON.stringify({ oldPath, newName }), - }) -} - -// TODO saveFile (none configuration) - -export async function deleteInProject(projectName: string, path: string): Promise { - await apiFetch(`${getFilesUrl(projectName)}/${encodeURIComponent(path)}`, { method: 'DELETE' }) -} - function getBaseUrl(projectName: string): string { return `/projects/${encodeURIComponent(projectName)}` } -function getFilesUrl(projectName: string): string { - return `${getBaseUrl(projectName)}/files` -} - function getTreeUrl(projectName: string): string { return `${getBaseUrl(projectName)}/tree` } diff --git a/src/main/java/org/frankframework/flow/datamapper/DatamapperConfigService.java b/src/main/java/org/frankframework/flow/datamapper/DatamapperConfigService.java index 2e47ddf5..a7e2f237 100644 --- a/src/main/java/org/frankframework/flow/datamapper/DatamapperConfigService.java +++ b/src/main/java/org/frankframework/flow/datamapper/DatamapperConfigService.java @@ -3,10 +3,13 @@ import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; + import javax.naming.ConfigurationException; + import org.frankframework.flow.configuration.ConfigurationNotFoundException; import org.frankframework.flow.filesystem.FileSystemStorage; -import org.frankframework.flow.filetree.FileTreeService; +import org.frankframework.flow.file.FileTreeService; + import org.springframework.stereotype.Service; @Service diff --git a/src/main/java/org/frankframework/flow/datamapper/DatamapperGeneratorService.java b/src/main/java/org/frankframework/flow/datamapper/DatamapperGeneratorService.java index 3e396d28..2e9fdb0e 100644 --- a/src/main/java/org/frankframework/flow/datamapper/DatamapperGeneratorService.java +++ b/src/main/java/org/frankframework/flow/datamapper/DatamapperGeneratorService.java @@ -5,13 +5,17 @@ import java.net.URL; import java.nio.file.Files; import java.nio.file.Path; + import javax.xml.transform.stream.StreamSource; + import lombok.extern.slf4j.Slf4j; import net.sf.saxon.s9api.*; + import org.frankframework.flow.configuration.ConfigurationNotFoundException; import org.frankframework.flow.exception.ApiException; import org.frankframework.flow.filesystem.FileSystemStorage; -import org.frankframework.flow.filetree.FileTreeService; +import org.frankframework.flow.file.FileTreeService; + import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; @@ -41,7 +45,8 @@ private Path getDatamapperDir(String projectName) throws ApiException { } catch (IOException e) { throw new ApiException( "Failed to resolve configuration file path for project: " + projectName, - HttpStatus.INTERNAL_SERVER_ERROR); + HttpStatus.INTERNAL_SERVER_ERROR + ); } } @@ -63,7 +68,8 @@ public void saveGenerationFile(String projectName, String content) throws ApiExc if (Files.isDirectory(configurationPath)) { throw new ApiException( "Cannot update configuration because path is a directory: " + configurationPath, - HttpStatus.INTERNAL_SERVER_ERROR); + HttpStatus.INTERNAL_SERVER_ERROR + ); } try { @@ -94,7 +100,8 @@ public void generateFromProject(String projectName, String content) throws ApiEx saveGenerationFile(projectName, content); generate( getConfigFilePath(projectName).toString(), - getDatamapperDir(projectName).resolve("export.xslt").toString()); + getDatamapperDir(projectName).resolve("export.xslt").toString() + ); deleteGenerationFile(projectName); } diff --git a/src/main/java/org/frankframework/flow/file/FileController.java b/src/main/java/org/frankframework/flow/file/FileController.java new file mode 100644 index 00000000..f07bfb84 --- /dev/null +++ b/src/main/java/org/frankframework/flow/file/FileController.java @@ -0,0 +1,54 @@ +package org.frankframework.flow.file; + +import org.frankframework.flow.exception.ApiException; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.io.IOException; + +@RestController +@RequestMapping("/projects/{projectName}/files") +public class FileController { + + private final FileService fileService; + + public FileController(FileService fileService) { + this.fileService = fileService; + } + + @GetMapping("/{filePath}") + public ResponseEntity getFile(@PathVariable String projectName, @PathVariable String filePath) throws IOException { + String fileContent = fileService.readFile(projectName, filePath); + return ResponseEntity.ok(fileContent); + } + + @PostMapping("/{filePath}") + public ResponseEntity createOrUpdateFile( + @PathVariable String projectName, + @PathVariable String filePath, + @RequestBody String fileContent + ) throws IOException, ApiException { + FileTreeNode node = fileService.createOrUpdateFile(projectName, filePath, fileContent); + return ResponseEntity.status(HttpStatus.CREATED.value()).body(node); + } + + @PostMapping("/move") + public ResponseEntity renameFile(@PathVariable String projectName, @RequestBody FileRenameDTO dto) throws IOException { + FileTreeNode node = fileService.renameFile(projectName, dto.oldPath(), dto.newPath()); + return ResponseEntity.ok(node); + } + + @DeleteMapping("/{filePath}") + public ResponseEntity deleteFile(@PathVariable String projectName, @PathVariable String filePath) throws IOException { + fileService.deleteFile(projectName, filePath); + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/org/frankframework/flow/file/FileRenameDTO.java b/src/main/java/org/frankframework/flow/file/FileRenameDTO.java new file mode 100644 index 00000000..0b4fc847 --- /dev/null +++ b/src/main/java/org/frankframework/flow/file/FileRenameDTO.java @@ -0,0 +1,4 @@ +package org.frankframework.flow.file; + +public record FileRenameDTO(String oldPath, String newPath) { +} diff --git a/src/main/java/org/frankframework/flow/file/FileService.java b/src/main/java/org/frankframework/flow/file/FileService.java new file mode 100644 index 00000000..bb0eef28 --- /dev/null +++ b/src/main/java/org/frankframework/flow/file/FileService.java @@ -0,0 +1,115 @@ +package org.frankframework.flow.file; + +import org.frankframework.flow.exception.ApiException; + +import org.frankframework.flow.filesystem.FileSystemStorage; +import org.frankframework.flow.project.Project; +import org.frankframework.flow.project.ProjectNotFoundException; +import org.frankframework.flow.project.ProjectService; + +import org.springframework.stereotype.Service; + +import java.io.IOException; +import java.nio.file.FileAlreadyExistsException; +import java.nio.file.Files; +import java.nio.file.Path; + +@Service +public class FileService { + + private final ProjectService projectService; + private final FileSystemStorage fileSystemStorage; + + public FileService(ProjectService projectService, FileSystemStorage fileSystemStorage) { + this.projectService = projectService; + this.fileSystemStorage = fileSystemStorage; + } + + public String readFile(String projectName, String path) throws IOException { + validatePath(path); + validateWithinProject(projectName, path); + return fileSystemStorage.readFile(path); + } + + public FileTreeNode createOrUpdateFile(String projectName, String path, String fileContent) throws IOException, ApiException { + validatePath(path); + String fileName = path.substring(path.lastIndexOf("/") + 1); + validateFileName(fileName); + validateWithinProject(projectName, path); + + fileSystemStorage.createFile(path); + fileSystemStorage.writeFile(path, fileContent); + +// invalidateTreeCache(projectName); + + FileTreeNode node = new FileTreeNode(); + node.setName(fileName); + node.setPath(path); + node.setType(NodeType.FILE); + return node; + } + + public FileTreeNode renameFile(String projectName, String oldPath, String newPath) throws IOException { + validatePath(newPath); + String newFileName = newPath.substring(newPath.lastIndexOf("/") + 1); + validateFileName(newFileName); + validateWithinProject(projectName, oldPath); + + Path absoluteNewPath = fileSystemStorage.toAbsolutePath(newPath); + String absoluteNewPathString = absoluteNewPath.toString(); + validateWithinProject(projectName, absoluteNewPathString); + + if (Files.exists(absoluteNewPath)) { + throw new FileAlreadyExistsException("A file or folder with that path already exists: " + absoluteNewPathString); + } + + fileSystemStorage.rename(oldPath, absoluteNewPathString); +// invalidateTreeCache(projectName); + + boolean isDir = Files.isDirectory(absoluteNewPath); + FileTreeNode node = new FileTreeNode(); + node.setName(newFileName); + node.setPath(newPath); + node.setType(isDir ? NodeType.DIRECTORY : NodeType.FILE); + return node; + } + + public void deleteFile(String projectName, String path) throws IOException { + validateWithinProject(projectName, path); + fileSystemStorage.delete(path); +// invalidateTreeCache(projectName); + } + + public void validateWithinProject(String projectName, String path) throws IOException { + try { + Project project = projectService.getProject(projectName); + Path projectPath = fileSystemStorage.toAbsolutePath(project.getRootPath()); + Path targetPath = fileSystemStorage.toAbsolutePath(path).normalize(); + + if (!targetPath.startsWith(projectPath)) { + throw new SecurityException("Path is outside project directory"); + } + } catch (ProjectNotFoundException e) { + throw new IllegalArgumentException("Project does not exist: " + projectName); + } + } + + protected void validateFileName(String name) { + if (name == null || name.isBlank()) { + throw new IllegalArgumentException("File name must not be empty"); + } + if (name.contains("/") || name.contains("\\") || name.contains("..")) { + throw new IllegalArgumentException("File name contains invalid characters: " + name); + } + } + + protected void validatePath(String path) { + if (path == null || path.isBlank()) { + throw new IllegalArgumentException("File path must not be empty"); + } + if (path.contains("\\") || path.contains("..")) { + throw new IllegalArgumentException("File path contains invalid characters: " + path); + } + } + +} diff --git a/src/main/java/org/frankframework/flow/file/FileTreeController.java b/src/main/java/org/frankframework/flow/file/FileTreeController.java new file mode 100644 index 00000000..42f50658 --- /dev/null +++ b/src/main/java/org/frankframework/flow/file/FileTreeController.java @@ -0,0 +1,59 @@ +package org.frankframework.flow.file; + +import java.io.IOException; + +import org.frankframework.flow.exception.ApiException; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/projects/{projectName}") +public class FileTreeController { + + private final FileTreeService fileTreeService; + + public FileTreeController(FileTreeService fileTreeService) { + this.fileTreeService = fileTreeService; + } + + @GetMapping("/tree") + public FileTreeNode getProjectTree(@PathVariable String projectName) throws IOException { + return fileTreeService.getProjectTree(projectName); + } + + @GetMapping("/tree/configuration") + public FileTreeNode getConfigurationTree( + @PathVariable String projectName, + @RequestParam(required = false, defaultValue = "false") boolean shallow + ) throws IOException { + if (shallow) { + return fileTreeService.getShallowConfigurationsDirectoryTree(projectName); + } else { + return fileTreeService.getConfigurationsDirectoryTree(projectName); + } + } + + @GetMapping(value = "/tree/directory", params = "path") + public FileTreeNode getDirectoryContent( + @PathVariable String projectName, + @RequestParam String path + ) throws IOException { + return fileTreeService.getShallowDirectoryTree(projectName, path); + } + + @PostMapping("/folders") + public ResponseEntity createFolder(@PathVariable String projectName, @RequestBody FolderCreateDTO folderCreate) throws IOException { + FileTreeNode node = fileTreeService.createFolder(projectName, folderCreate.path()); + return ResponseEntity.status(HttpStatus.CREATED.value()).body(node); + } +} diff --git a/src/main/java/org/frankframework/flow/file/FileTreeNode.java b/src/main/java/org/frankframework/flow/file/FileTreeNode.java new file mode 100644 index 00000000..abdac6f7 --- /dev/null +++ b/src/main/java/org/frankframework/flow/file/FileTreeNode.java @@ -0,0 +1,20 @@ +package org.frankframework.flow.file; + +import java.util.List; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class FileTreeNode { + private String name; + private String path; + private NodeType type; + private boolean projectRoot; + private List children; + private List adapterNames; + + public FileTreeNode() { + } +} diff --git a/src/main/java/org/frankframework/flow/filetree/FileTreeService.java b/src/main/java/org/frankframework/flow/file/FileTreeService.java similarity index 69% rename from src/main/java/org/frankframework/flow/filetree/FileTreeService.java rename to src/main/java/org/frankframework/flow/file/FileTreeService.java index a2e5daea..608cefb4 100644 --- a/src/main/java/org/frankframework/flow/filetree/FileTreeService.java +++ b/src/main/java/org/frankframework/flow/file/FileTreeService.java @@ -1,4 +1,4 @@ -package org.frankframework.flow.filetree; +package org.frankframework.flow.file; import java.io.IOException; import java.io.UncheckedIOException; @@ -11,7 +11,9 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.stream.Collectors; import java.util.stream.Stream; + import javax.xml.parsers.DocumentBuilder; + import org.frankframework.flow.configuration.ConfigurationService; import org.frankframework.flow.exception.ApiException; import org.frankframework.flow.filesystem.FileSystemStorage; @@ -19,6 +21,7 @@ import org.frankframework.flow.project.ProjectNotFoundException; import org.frankframework.flow.project.ProjectService; import org.frankframework.flow.utility.XmlSecurityUtils; + import org.springframework.stereotype.Service; import org.w3c.dom.Document; import org.w3c.dom.Element; @@ -30,17 +33,18 @@ public class FileTreeService { private final ProjectService projectService; private final FileSystemStorage fileSystemStorage; - private final ConfigurationService configurationService; + private final FileService fileService; private final Map treeCache = new ConcurrentHashMap<>(); public FileTreeService( ProjectService projectService, FileSystemStorage fileSystemStorage, - ConfigurationService configurationService) { + FileService fileService + ) { this.projectService = projectService; this.fileSystemStorage = fileSystemStorage; - this.configurationService = configurationService; + this.fileService = fileService; } public FileTreeNode getProjectTree(String projectName) throws IOException { @@ -126,84 +130,19 @@ public FileTreeNode getConfigurationsDirectoryTree(String projectName) throws IO } } - public FileTreeNode createFile(String projectName, String parentPath, String fileName) - throws IOException, ApiException { - if (parentPath == null || parentPath.isBlank()) { - throw new IllegalArgumentException("Parent path must not be empty"); - } - validateFileName(fileName); - String fullPath = parentPath.endsWith("/") ? parentPath + fileName : parentPath + "/" + fileName; - validateWithinProject(projectName, fullPath); - - if (fileName.toLowerCase().endsWith(".xml")) { - configurationService.addConfigurationToFolder(projectName, fileName, parentPath); - } else { - fileSystemStorage.createFile(fullPath); - } - - invalidateTreeCache(projectName); - - FileTreeNode node = new FileTreeNode(); - node.setName(fileName); - node.setPath(fullPath); - node.setType(NodeType.FILE); - return node; - } - - public FileTreeNode createFolder(String projectName, String parentPath, String folderName) throws IOException { - if (parentPath == null || parentPath.isBlank()) { - throw new IllegalArgumentException("Parent path must not be empty"); - } - validateFileName(folderName); - String fullPath = parentPath.endsWith("/") ? parentPath + folderName : parentPath + "/" + folderName; - validateWithinProject(projectName, fullPath); - - fileSystemStorage.createProjectDirectory(fullPath); + public FileTreeNode createFolder(String projectName, String path) throws IOException { + fileService.validateWithinProject(projectName, path); + fileSystemStorage.createProjectDirectory(path); invalidateTreeCache(projectName); + String folderName = path.substring(path.lastIndexOf("/") + 1); FileTreeNode node = new FileTreeNode(); node.setName(folderName); - node.setPath(fullPath); + node.setPath(path); node.setType(NodeType.DIRECTORY); return node; } - public FileTreeNode renameFile(String projectName, String oldPath, String newName) throws IOException { - validateFileName(newName); - validateWithinProject(projectName, oldPath); - - Path oldAbsPath = fileSystemStorage.toAbsolutePath(oldPath); - Path newAbsPath = oldAbsPath.getParent().resolve(newName); - String newPath = newAbsPath.toString(); - - if (!fileSystemStorage.isLocalEnvironment()) { - String parentRelative = oldPath.contains("/") ? oldPath.substring(0, oldPath.lastIndexOf('/')) : ""; - newPath = parentRelative.isEmpty() ? newName : parentRelative + "/" + newName; - } - - validateWithinProject(projectName, newPath); - - if (Files.exists(newAbsPath)) { - throw new FileAlreadyExistsException("A file or folder with that name already exists: " + newName); - } - - fileSystemStorage.rename(oldPath, newPath); - invalidateTreeCache(projectName); - - boolean isDir = Files.isDirectory(newAbsPath); - FileTreeNode node = new FileTreeNode(); - node.setName(newName); - node.setPath(newPath); - node.setType(isDir ? NodeType.DIRECTORY : NodeType.FILE); - return node; - } - - public void deleteFile(String projectName, String path) throws IOException { - validateWithinProject(projectName, path); - fileSystemStorage.delete(path); - invalidateTreeCache(projectName); - } - public void invalidateTreeCache() { treeCache.clear(); } @@ -212,29 +151,6 @@ public void invalidateTreeCache(String projectName) { treeCache.remove(projectName); } - private void validateWithinProject(String projectName, String path) throws IOException { - try { - Project project = projectService.getProject(projectName); - Path projectPath = fileSystemStorage.toAbsolutePath(project.getRootPath()); - Path targetPath = fileSystemStorage.toAbsolutePath(path).normalize(); - - if (!targetPath.startsWith(projectPath)) { - throw new SecurityException("Path is outside project directory"); - } - } catch (ProjectNotFoundException e) { - throw new IllegalArgumentException("Project does not exist: " + projectName); - } - } - - private void validateFileName(String name) { - if (name == null || name.isBlank()) { - throw new IllegalArgumentException("File name must not be empty"); - } - if (name.contains("/") || name.contains("\\") || name.contains("..")) { - throw new IllegalArgumentException("File name contains invalid characters: " + name); - } - } - private FileTreeNode buildTree(Path path, Path relativizeRoot, boolean useRelativePaths) throws IOException { FileTreeNode node = new FileTreeNode(); node.setName(path.getFileName().toString()); diff --git a/src/main/java/org/frankframework/flow/file/FolderCreateDTO.java b/src/main/java/org/frankframework/flow/file/FolderCreateDTO.java new file mode 100644 index 00000000..876835e4 --- /dev/null +++ b/src/main/java/org/frankframework/flow/file/FolderCreateDTO.java @@ -0,0 +1,4 @@ +package org.frankframework.flow.file; + +public record FolderCreateDTO(String path) { +} diff --git a/src/main/java/org/frankframework/flow/filetree/NodeType.java b/src/main/java/org/frankframework/flow/file/NodeType.java similarity index 51% rename from src/main/java/org/frankframework/flow/filetree/NodeType.java rename to src/main/java/org/frankframework/flow/file/NodeType.java index bb1e5a77..04f9ad15 100644 --- a/src/main/java/org/frankframework/flow/filetree/NodeType.java +++ b/src/main/java/org/frankframework/flow/file/NodeType.java @@ -1,4 +1,4 @@ -package org.frankframework.flow.filetree; +package org.frankframework.flow.file; public enum NodeType { FILE, diff --git a/src/test/java/org/frankframework/flow/datamapper/DatamapperConfigServiceTest.java b/src/test/java/org/frankframework/flow/datamapper/DatamapperConfigServiceTest.java index 0c73f401..4761dfe5 100644 --- a/src/test/java/org/frankframework/flow/datamapper/DatamapperConfigServiceTest.java +++ b/src/test/java/org/frankframework/flow/datamapper/DatamapperConfigServiceTest.java @@ -13,11 +13,14 @@ import java.nio.file.Path; import java.nio.file.Paths; import java.util.Comparator; + import javax.naming.ConfigurationException; + import org.frankframework.flow.configuration.ConfigurationNotFoundException; import org.frankframework.flow.filesystem.FileSystemStorage; -import org.frankframework.flow.filetree.FileTreeNode; -import org.frankframework.flow.filetree.FileTreeService; +import org.frankframework.flow.file.FileTreeNode; +import org.frankframework.flow.file.FileTreeService; + import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -72,11 +75,11 @@ private void stubReadFile() throws IOException { private void stubWriteFile() throws IOException { doAnswer(invocation -> { - String path = invocation.getArgument(0); - String content = invocation.getArgument(1); - Files.writeString(Paths.get(path), content); - return null; - }) + String path = invocation.getArgument(0); + String content = invocation.getArgument(1); + Files.writeString(Paths.get(path), content); + return null; + }) .when(fileSystemStorage) .writeFile(anyString(), anyString()); } diff --git a/src/test/java/org/frankframework/flow/datamapper/DatamapperGeneratorServiceTest.java b/src/test/java/org/frankframework/flow/datamapper/DatamapperGeneratorServiceTest.java index d7f86ef4..0bb4386e 100644 --- a/src/test/java/org/frankframework/flow/datamapper/DatamapperGeneratorServiceTest.java +++ b/src/test/java/org/frankframework/flow/datamapper/DatamapperGeneratorServiceTest.java @@ -15,6 +15,7 @@ import java.nio.file.Paths; import java.nio.file.StandardCopyOption; import java.util.Comparator; + import javax.xml.parsers.DocumentBuilder; import javax.xml.parsers.DocumentBuilderFactory; import javax.xml.parsers.ParserConfigurationException; @@ -22,12 +23,15 @@ import javax.xml.transform.dom.DOMSource; import javax.xml.transform.stream.StreamResult; import javax.xml.transform.stream.StreamSource; + import net.sf.saxon.s9api.*; + import org.frankframework.flow.exception.ApiException; import org.frankframework.flow.filesystem.FileOperations; import org.frankframework.flow.filesystem.FileSystemStorage; -import org.frankframework.flow.filetree.FileTreeNode; -import org.frankframework.flow.filetree.FileTreeService; +import org.frankframework.flow.file.FileTreeNode; +import org.frankframework.flow.file.FileTreeService; + import org.junit.jupiter.api.*; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; @@ -60,21 +64,21 @@ private void stubToAbsolutePath() throws IOException { private void stubDeleteFile() throws IOException { doAnswer(invocation -> { - String path = invocation.getArgument(0); - FileOperations.deleteRecursively(Paths.get(path)); - return null; - }) + String path = invocation.getArgument(0); + FileOperations.deleteRecursively(Paths.get(path)); + return null; + }) .when(fileSystemStorage) .delete(anyString()); } private void stubWriteFile() throws IOException { doAnswer(invocation -> { - String path = invocation.getArgument(0); - String content = invocation.getArgument(1); - Files.writeString(Paths.get(path), content); - return null; - }) + String path = invocation.getArgument(0); + String content = invocation.getArgument(1); + Files.writeString(Paths.get(path), content); + return null; + }) .when(fileSystemStorage) .writeFile(anyString(), anyString()); } @@ -93,19 +97,23 @@ void setUp() throws IOException { Files.copy( Paths.get("src/test/resources/datamapper/productSchema.xml"), tempProjectRoot.resolve("productSchema.xml"), - StandardCopyOption.REPLACE_EXISTING); + StandardCopyOption.REPLACE_EXISTING + ); Files.copy( Paths.get("src/test/resources/datamapper/userSchema.xml"), tempProjectRoot.resolve("userSchema.xml"), - StandardCopyOption.REPLACE_EXISTING); + StandardCopyOption.REPLACE_EXISTING + ); Files.copy( Paths.get("src/test/resources/datamapper/productSchema.json"), tempProjectRoot.resolve("productSchema.json"), - StandardCopyOption.REPLACE_EXISTING); + StandardCopyOption.REPLACE_EXISTING + ); Files.copy( Paths.get("src/test/resources/datamapper/userSchema.json"), tempProjectRoot.resolve("userSchema.json"), - StandardCopyOption.REPLACE_EXISTING); + StandardCopyOption.REPLACE_EXISTING + ); service = new DatamapperGeneratorService(fileSystemStorage, fileTreeService); processor = new Processor(false); @@ -140,7 +148,7 @@ public void generateMapping() throws IOException, ParserConfigurationException, @DisplayName("Test XML to XML mapping") public void testXMLtoXMLGeneratedMapping() throws SaxonApiException, IOException, ParserConfigurationException, SAXException, TransformerException, - ApiException { + ApiException { stubToAbsolutePath(); service.generate( "src/test/resources/datamapper/inputXmlToXml.json", tempProjectRoot.toAbsolutePath() + "/output.xslt"); @@ -166,11 +174,12 @@ public void testXMLtoXMLGeneratedMapping() @DisplayName("Test XML to XML mapping with arrays") public void testXMLtoXMLWithArraysGeneratedMapping() throws SaxonApiException, IOException, ParserConfigurationException, SAXException, TransformerException, - ApiException { + ApiException { stubToAbsolutePath(); service.generate( "src/test/resources/datamapper/inputXmlToXmlWithArray.json", - tempProjectRoot.toAbsolutePath() + "/output.xslt"); + tempProjectRoot.toAbsolutePath() + "/output.xslt" + ); XsltExecutable executable = compiler.compile(new StreamSource(new File(tempProjectRoot.toAbsolutePath() + "/output.xslt"))); @@ -272,7 +281,8 @@ public void testManualXMLtoJSONWithArraysGeneratedMapping() throws SaxonApiExcep service.generate( "src/test/resources/datamapper/inputXmlToJsonWithArray.json", - tempProjectRoot.toAbsolutePath() + "/output.xslt"); + tempProjectRoot.toAbsolutePath() + "/output.xslt" + ); XsltExecutable executable = compiler.compile(new StreamSource(new File(tempProjectRoot.toAbsolutePath() + "/output.xslt"))); @@ -295,7 +305,7 @@ public void testManualXMLtoJSONWithArraysGeneratedMapping() throws SaxonApiExcep @DisplayName("Test Json to XML mapping") public void testJSONtoXMLGeneratedMapping() throws IOException, SaxonApiException, ParserConfigurationException, SAXException, TransformerException, - ApiException { + ApiException { stubToAbsolutePath(); service.generate( @@ -326,7 +336,8 @@ public void testJSONtoJSONGeneratedMapping() throws SaxonApiException, IOExcepti service.generate( "src/test/resources/datamapper/inputJsonToJson.json", - tempProjectRoot.toAbsolutePath() + "/output.xslt"); + tempProjectRoot.toAbsolutePath() + "/output.xslt" + ); XsltExecutable executable = compiler.compile(new StreamSource(new File(tempProjectRoot.toAbsolutePath() + "/output.xslt"))); @@ -338,7 +349,8 @@ public void testJSONtoJSONGeneratedMapping() throws SaxonApiException, IOExcepti Path absolutePath = Paths.get("").toAbsolutePath().resolve("src/test/resources/datamapper/inputData.json"); StreamSource paramsSource = new StreamSource( new StringReader("" + absolutePath.toUri() + ""), - absolutePath.getParent().toUri().toString()); + absolutePath.getParent().toUri().toString() + ); transformer.transform(paramsSource, out); diff --git a/src/test/java/org/frankframework/flow/filetree/FileTreeServiceTest.java b/src/test/java/org/frankframework/flow/file/FileTreeServiceTest.java similarity index 98% rename from src/test/java/org/frankframework/flow/filetree/FileTreeServiceTest.java rename to src/test/java/org/frankframework/flow/file/FileTreeServiceTest.java index cfdb12fb..d51e2ecd 100644 --- a/src/test/java/org/frankframework/flow/filetree/FileTreeServiceTest.java +++ b/src/test/java/org/frankframework/flow/file/FileTreeServiceTest.java @@ -1,4 +1,4 @@ -package org.frankframework.flow.filetree; +package org.frankframework.flow.file; import static org.junit.Assert.assertSame; import static org.junit.jupiter.api.Assertions.*; @@ -13,6 +13,7 @@ import java.nio.file.Paths; import java.util.Comparator; import java.util.List; + import org.frankframework.flow.configuration.ConfigurationService; import org.frankframework.flow.exception.ApiException; import org.frankframework.flow.filesystem.FileOperations; @@ -20,6 +21,7 @@ import org.frankframework.flow.project.Project; import org.frankframework.flow.project.ProjectNotFoundException; import org.frankframework.flow.project.ProjectService; + import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -172,7 +174,8 @@ void getShallowDirectoryTreeThrowsIllegalArgumentExceptionIfDirectoryDoesNotExis IllegalArgumentException ex = assertThrows( IllegalArgumentException.class, - () -> fileTreeService.getShallowDirectoryTree(TEST_PROJECT_NAME, "nonexistent")); + () -> fileTreeService.getShallowDirectoryTree(TEST_PROJECT_NAME, "nonexistent") + ); assertTrue(ex.getMessage().contains("Directory does not exist")); } @@ -214,7 +217,8 @@ public void getShallowConfigurationsDirectoryTreeThrowsIfDirectoryDoesNotExist() IllegalArgumentException ex = assertThrows( IllegalArgumentException.class, - () -> fileTreeService.getShallowConfigurationsDirectoryTree(TEST_PROJECT_NAME)); + () -> fileTreeService.getShallowConfigurationsDirectoryTree(TEST_PROJECT_NAME) + ); assertTrue(ex.getMessage().contains("Configurations directory does not exist")); } @@ -225,7 +229,8 @@ public void getShallowConfigurationsDirectoryTreeThrowsIfProjectDoesNotExist() t IllegalArgumentException ex = assertThrows( IllegalArgumentException.class, - () -> fileTreeService.getShallowConfigurationsDirectoryTree("NonExistentProject")); + () -> fileTreeService.getShallowConfigurationsDirectoryTree("NonExistentProject") + ); assertTrue(ex.getMessage().contains("Configurations directory does not exist")); } @@ -279,7 +284,8 @@ public void getConfigurationsDirectoryTreeThrowsIfDirectoryDoesNotExist() IllegalArgumentException ex = assertThrows( IllegalArgumentException.class, - () -> fileTreeService.getConfigurationsDirectoryTree(TEST_PROJECT_NAME)); + () -> fileTreeService.getConfigurationsDirectoryTree(TEST_PROJECT_NAME) + ); assertTrue(ex.getMessage().contains("Configurations directory does not exist")); } @@ -290,7 +296,8 @@ public void getConfigurationsDirectoryTreeThrowsIfProjectDoesNotExist() throws P IllegalArgumentException ex = assertThrows( IllegalArgumentException.class, - () -> fileTreeService.getConfigurationsDirectoryTree("NonExistingProject")); + () -> fileTreeService.getConfigurationsDirectoryTree("NonExistingProject") + ); assertTrue(ex.getMessage().contains("Configurations directory does not exist")); } @@ -300,7 +307,8 @@ public void getConfigurationsDirectoryTreeThrowsIfProjectDoesNotExist() throws P void createFile_NullName_ThrowsIllegalArgument() { assertThrows( IllegalArgumentException.class, - () -> fileTreeService.createFile(TEST_PROJECT_NAME, "/some/path", null)); + () -> fileTreeService.createFile(TEST_PROJECT_NAME, "/some/path", null) + ); } @Test @@ -308,7 +316,8 @@ void createFile_NullName_ThrowsIllegalArgument() { void createFile_BlankName_ThrowsIllegalArgument() { assertThrows( IllegalArgumentException.class, - () -> fileTreeService.createFile(TEST_PROJECT_NAME, "/some/path", " ")); + () -> fileTreeService.createFile(TEST_PROJECT_NAME, "/some/path", " ") + ); } @Test @@ -316,7 +325,8 @@ void createFile_BlankName_ThrowsIllegalArgument() { void createFile_NameWithForwardSlash_ThrowsIllegalArgument() { assertThrows( IllegalArgumentException.class, - () -> fileTreeService.createFile(TEST_PROJECT_NAME, "/some/path", "bad/name.xml")); + () -> fileTreeService.createFile(TEST_PROJECT_NAME, "/some/path", "bad/name.xml") + ); } @Test @@ -324,7 +334,8 @@ void createFile_NameWithForwardSlash_ThrowsIllegalArgument() { void createFile_NameWithBackslash_ThrowsIllegalArgument() { assertThrows( IllegalArgumentException.class, - () -> fileTreeService.createFile(TEST_PROJECT_NAME, "/some/path", "bad\\name.xml")); + () -> fileTreeService.createFile(TEST_PROJECT_NAME, "/some/path", "bad\\name.xml") + ); } @Test @@ -332,7 +343,8 @@ void createFile_NameWithBackslash_ThrowsIllegalArgument() { void createFile_NameWithDoubleDots_ThrowsIllegalArgument() { assertThrows( IllegalArgumentException.class, - () -> fileTreeService.createFile(TEST_PROJECT_NAME, "/some/path", "..")); + () -> fileTreeService.createFile(TEST_PROJECT_NAME, "/some/path", "..") + ); } @Test @@ -388,7 +400,8 @@ void createFile_OutsideProject_ThrowsSecurityException() throws IOException, Pro String outsidePath = tempProjectRoot.getParent().toAbsolutePath().toString(); assertThrows( SecurityException.class, - () -> fileTreeService.createFile(TEST_PROJECT_NAME, outsidePath, "escape.json")); + () -> fileTreeService.createFile(TEST_PROJECT_NAME, outsidePath, "escape.json") + ); } @Test @@ -443,7 +456,8 @@ void createFolder_Success() throws IOException, ProjectNotFoundException { void createFolder_NullName_ThrowsIllegalArgument() { assertThrows( IllegalArgumentException.class, - () -> fileTreeService.createFolder(TEST_PROJECT_NAME, "/some/path", null)); + () -> fileTreeService.createFolder(TEST_PROJECT_NAME, "/some/path", null) + ); } @Test @@ -451,7 +465,8 @@ void createFolder_NullName_ThrowsIllegalArgument() { void createFolder_NameWithBackslash_ThrowsIllegalArgument() { assertThrows( IllegalArgumentException.class, - () -> fileTreeService.createFolder(TEST_PROJECT_NAME, "/some/path", "bad\\folder")); + () -> fileTreeService.createFolder(TEST_PROJECT_NAME, "/some/path", "bad\\folder") + ); } @Test @@ -513,7 +528,8 @@ void renameFile_TargetAlreadyExists_ThrowsFileAlreadyExistsException() String oldPath = tempProjectRoot.resolve("old.xml").toAbsolutePath().toString(); assertThrows( FileAlreadyExistsException.class, - () -> fileTreeService.renameFile(TEST_PROJECT_NAME, oldPath, "existing.xml")); + () -> fileTreeService.renameFile(TEST_PROJECT_NAME, oldPath, "existing.xml") + ); } @Test @@ -521,7 +537,8 @@ void renameFile_TargetAlreadyExists_ThrowsFileAlreadyExistsException() void renameFile_InvalidNewName_ThrowsIllegalArgument() { assertThrows( IllegalArgumentException.class, - () -> fileTreeService.renameFile(TEST_PROJECT_NAME, "/some/old.xml", "bad/name.xml")); + () -> fileTreeService.renameFile(TEST_PROJECT_NAME, "/some/old.xml", "bad/name.xml") + ); } @Test @@ -696,7 +713,8 @@ void getShallowDirectoryTree_LocalEnvironment_UsesAbsolutePaths() throws IOExcep assertTrue(Paths.get(node.getPath()).isAbsolute(), "Root node path must be absolute"); assertTrue( node.getChildren().stream().allMatch(c -> Paths.get(c.getPath()).isAbsolute()), - "All child paths must be absolute"); + "All child paths must be absolute" + ); } private void stubCreateProjectDirectory() throws IOException { @@ -717,10 +735,10 @@ private void stubCreateFile() throws IOException { private void stubDelete() throws IOException { doAnswer(invocation -> { - String path = invocation.getArgument(0); - FileOperations.deleteRecursively(Paths.get(path)); - return null; - }) + String path = invocation.getArgument(0); + FileOperations.deleteRecursively(Paths.get(path)); + return null; + }) .when(fileSystemStorage) .delete(anyString()); } From f561c353bfe9fa0a73ed367e3df20526f18e39ff Mon Sep 17 00:00:00 2001 From: Vivy <4380412+Matthbo@users.noreply.github.com> Date: Wed, 25 Mar 2026 18:05:25 +0100 Subject: [PATCH 04/18] remove files broken by rebase --- .../flow/file/FileTreeNode.java | 3 +- .../flow/filetree/FileCreateDTO.java | 3 - .../flow/filetree/FileRenameDTO.java | 3 - .../flow/filetree/FileTreeController.java | 76 ------------------- .../flow/filetree/FileTreeNode.java | 18 ----- 5 files changed, 1 insertion(+), 102 deletions(-) delete mode 100644 src/main/java/org/frankframework/flow/filetree/FileCreateDTO.java delete mode 100644 src/main/java/org/frankframework/flow/filetree/FileRenameDTO.java delete mode 100644 src/main/java/org/frankframework/flow/filetree/FileTreeController.java delete mode 100644 src/main/java/org/frankframework/flow/filetree/FileTreeNode.java diff --git a/src/main/java/org/frankframework/flow/file/FileTreeNode.java b/src/main/java/org/frankframework/flow/file/FileTreeNode.java index abdac6f7..ae00446a 100644 --- a/src/main/java/org/frankframework/flow/file/FileTreeNode.java +++ b/src/main/java/org/frankframework/flow/file/FileTreeNode.java @@ -15,6 +15,5 @@ public class FileTreeNode { private List children; private List adapterNames; - public FileTreeNode() { - } + public FileTreeNode() {} } diff --git a/src/main/java/org/frankframework/flow/filetree/FileCreateDTO.java b/src/main/java/org/frankframework/flow/filetree/FileCreateDTO.java deleted file mode 100644 index f28b8a79..00000000 --- a/src/main/java/org/frankframework/flow/filetree/FileCreateDTO.java +++ /dev/null @@ -1,3 +0,0 @@ -package org.frankframework.flow.filetree; - -public record FileCreateDTO(String path, String name) {} diff --git a/src/main/java/org/frankframework/flow/filetree/FileRenameDTO.java b/src/main/java/org/frankframework/flow/filetree/FileRenameDTO.java deleted file mode 100644 index 0342dc74..00000000 --- a/src/main/java/org/frankframework/flow/filetree/FileRenameDTO.java +++ /dev/null @@ -1,3 +0,0 @@ -package org.frankframework.flow.filetree; - -public record FileRenameDTO(String oldPath, String newName) {} diff --git a/src/main/java/org/frankframework/flow/filetree/FileTreeController.java b/src/main/java/org/frankframework/flow/filetree/FileTreeController.java deleted file mode 100644 index 24927db5..00000000 --- a/src/main/java/org/frankframework/flow/filetree/FileTreeController.java +++ /dev/null @@ -1,76 +0,0 @@ -package org.frankframework.flow.filetree; - -import java.io.IOException; -import org.frankframework.flow.exception.ApiException; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.DeleteMapping; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PatchMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@RequestMapping("/projects") -public class FileTreeController { - - private final FileTreeService fileTreeService; - - public FileTreeController(FileTreeService fileTreeService) { - this.fileTreeService = fileTreeService; - } - - @GetMapping("/{name}/tree") - public FileTreeNode getProjectTree(@PathVariable String name) throws IOException { - return fileTreeService.getProjectTree(name); - } - - @GetMapping("/{name}/tree/configurations") - public FileTreeNode getConfigurationTree( - @PathVariable String name, @RequestParam(required = false, defaultValue = "false") boolean shallow) - throws IOException { - if (shallow) { - return fileTreeService.getShallowConfigurationsDirectoryTree(name); - } else { - return fileTreeService.getConfigurationsDirectoryTree(name); - } - } - - @GetMapping(value = "/{projectName}", params = "path") - public FileTreeNode getDirectoryContent(@PathVariable String projectName, @RequestParam String path) - throws IOException { - return fileTreeService.getShallowDirectoryTree(projectName, path); - } - - @PostMapping("/{projectName}/files") - public ResponseEntity createFile(@PathVariable String projectName, @RequestBody FileCreateDTO dto) - throws IOException, ApiException { - FileTreeNode node = fileTreeService.createFile(projectName, dto.path(), dto.name()); - return ResponseEntity.status(HttpStatus.CREATED.value()).body(node); - } - - @PostMapping("/{projectName}/folders") - public ResponseEntity createFolder(@PathVariable String projectName, @RequestBody FileCreateDTO dto) - throws IOException { - FileTreeNode node = fileTreeService.createFolder(projectName, dto.path(), dto.name()); - return ResponseEntity.status(HttpStatus.CREATED.value()).body(node); - } - - @PatchMapping("/{projectName}/files/rename") - public ResponseEntity renameFile(@PathVariable String projectName, @RequestBody FileRenameDTO dto) - throws IOException { - FileTreeNode node = fileTreeService.renameFile(projectName, dto.oldPath(), dto.newName()); - return ResponseEntity.ok(node); - } - - @DeleteMapping("/{projectName}/files") - public ResponseEntity deleteFile(@PathVariable String projectName, @RequestParam String path) - throws IOException { - fileTreeService.deleteFile(projectName, path); - return ResponseEntity.noContent().build(); - } -} diff --git a/src/main/java/org/frankframework/flow/filetree/FileTreeNode.java b/src/main/java/org/frankframework/flow/filetree/FileTreeNode.java deleted file mode 100644 index 9db0379d..00000000 --- a/src/main/java/org/frankframework/flow/filetree/FileTreeNode.java +++ /dev/null @@ -1,18 +0,0 @@ -package org.frankframework.flow.filetree; - -import java.util.List; -import lombok.Getter; -import lombok.Setter; - -@Getter -@Setter -public class FileTreeNode { - private String name; - private String path; - private NodeType type; - private boolean projectRoot; - private List children; - private List adapterNames; - - public FileTreeNode() {} -} From 6b095668bcc81cc78a77e2a3e2d2fbd78998dfe2 Mon Sep 17 00:00:00 2001 From: Vivy <4380412+Matthbo@users.noreply.github.com> Date: Thu, 26 Mar 2026 17:19:37 +0100 Subject: [PATCH 05/18] Read filetype --- .../frontend/app/routes/editor/editor.tsx | 29 ++++++++++--------- .../frontend/app/services/file-service.ts | 17 ++++++----- .../datamapper/DatamapperConfigService.java | 1 + .../flow/file/FileController.java | 8 +++-- .../org/frankframework/flow/file/FileDTO.java | 4 +++ .../frankframework/flow/file/FileService.java | 7 +++-- .../CloudFileSystemStorageService.java | 8 +++++ .../flow/filesystem/FileSystemStorage.java | 2 ++ .../LocalFileSystemStorageService.java | 6 ++++ 9 files changed, 55 insertions(+), 27 deletions(-) create mode 100644 src/main/java/org/frankframework/flow/file/FileDTO.java diff --git a/src/main/frontend/app/routes/editor/editor.tsx b/src/main/frontend/app/routes/editor/editor.tsx index bff43211..149aa9a6 100644 --- a/src/main/frontend/app/routes/editor/editor.tsx +++ b/src/main/frontend/app/routes/editor/editor.tsx @@ -147,7 +147,8 @@ export default function CodeEditor() { const theme = useTheme() const project = useProjectStore.getState().project const [activeTabFilePath, setActiveTabFilePath] = useState(useEditorTabStore.getState().activeTabFilePath) - const [xmlContent, setXmlContent] = useState('') + const [fileContent, setFileContent] = useState('') + const [fileLanguage, setFileLanguage] = useState('xml') const [saveStatus, setSaveStatus] = useState('idle') const [leftTab, setLeftTab] = useState('files') const [editorMounted, setEditorMounted] = useState(false) @@ -191,7 +192,7 @@ export default function CodeEditor() { setSaveStatus('saving') try { const xmlResponse = await saveConfiguration(project.name, configPath, updatedContent) - setXmlContent(xmlResponse.xmlContent) + setFileContent(xmlResponse.xmlContent) contentCacheRef.current.set(activeTabFilePath, updatedContent) setSaveStatus('saved') if (savedTimerRef.current) clearTimeout(savedTimerRef.current) @@ -424,7 +425,7 @@ export default function CodeEditor() { if (!isForceRefresh) { const cached = contentCacheRef.current.get(activeTabFilePath) if (cached !== undefined) { - setXmlContent(cached) + setFileContent(cached) return } } @@ -432,7 +433,7 @@ export default function CodeEditor() { const xmlString = await fetchConfiguration(project.name, configPath, abortController.signal) if (!abortController.signal.aborted) { contentCacheRef.current.set(activeTabFilePath, xmlString) - setXmlContent(xmlString) + setFileContent(xmlString) } } catch (error) { if (!abortController.signal.aborted) { @@ -459,18 +460,18 @@ export default function CodeEditor() { }, [activeTabFilePath]) useEffect(() => { - if (!xmlContent || !xsdLoaded || isDiffTab) return - runSchemaValidation(xmlContent) - }, [xmlContent, xsdLoaded, isDiffTab, runSchemaValidation]) + if (!fileContent || !xsdLoaded || isDiffTab) return + runSchemaValidation(fileContent) + }, [fileContent, xsdLoaded, isDiffTab, runSchemaValidation]) useEffect(() => { - if (!xmlContent || !activeTabFilePath || !editorReference.current || isDiffTab) return + if (!fileContent || !activeTabFilePath || !editorReference.current || isDiffTab) return const editor = editorReference.current const model = editor.getModel() if (!model) return - const lines = xmlContent.split('\n') + const lines = fileContent.split('\n') const matchIndex = lines.findIndex((line) => line.includes(' decorations.clear(), 2000) return () => clearTimeout(timeout) - }, [xmlContent, activeTabFilePath, isDiffTab]) + }, [fileContent, activeTabFilePath, isDiffTab]) const handleOpenInStudio = useCallback(() => { const editorTab = useEditorTabStore.getState().getTab(activeTabFilePath) if (!editorTab) return - const xml = editorReference.current?.getValue() || xmlContent + const xml = editorReference.current?.getValue() || fileContent if (!xml) return const adapters = findAdaptersInXml(xml) @@ -505,7 +506,7 @@ export default function CodeEditor() { adapters.length === 1 || !cursorLine ? 0 : findAdapterIndexAtOffset(adapters, lineToOffset(xml, cursorLine)) openInStudio(adapters[adapterPosition].name, editorTab.configurationPath, adapterPosition) - }, [activeTabFilePath, xmlContent]) + }, [activeTabFilePath, fileContent]) const isGitRepo = !!project?.isGitRepository @@ -583,9 +584,9 @@ export default function CodeEditor() {
{ scheduleSave() diff --git a/src/main/frontend/app/services/file-service.ts b/src/main/frontend/app/services/file-service.ts index 9d38c185..49414fb8 100644 --- a/src/main/frontend/app/services/file-service.ts +++ b/src/main/frontend/app/services/file-service.ts @@ -1,26 +1,27 @@ import type { FileTreeNode } from '~/types/filesystem.types' import { apiFetch } from '~/utils/api' +export interface FileDTO { + content: string + type: string +} + export async function createFile(projectName: string, filePath: string): Promise { await updateFile(projectName, filePath, '') } -export async function fetchFile(projectName: string, filepath: string, signal?: AbortSignal): Promise { - const { content } = await apiFetch<{ content: string }>( - `${getBaseUrl(projectName)}/${encodeURIComponent(filepath)}`, - { signal }, - ) - return content +export function fetchFile(projectName: string, filepath: string, signal?: AbortSignal): Promise { + return apiFetch(`${getBaseUrl(projectName)}/${encodeURIComponent(filepath)}`, { signal }) } -export async function updateFile(projectName: string, filePath: string, fileContent: string): Promise { +export function updateFile(projectName: string, filePath: string, fileContent: string): Promise { return apiFetch(`${getBaseUrl(projectName)}/${encodeURIComponent(filePath)}`, { method: 'PUT', body: fileContent, }) } -export async function renameFile(projectName: string, oldPath: string, newPath: string): Promise { +export function renameFile(projectName: string, oldPath: string, newPath: string): Promise { return apiFetch(`${getBaseUrl(projectName)}/move`, { method: 'POST', body: JSON.stringify({ oldPath, newPath }), diff --git a/src/main/java/org/frankframework/flow/datamapper/DatamapperConfigService.java b/src/main/java/org/frankframework/flow/datamapper/DatamapperConfigService.java index a7e2f237..d2c0f5ce 100644 --- a/src/main/java/org/frankframework/flow/datamapper/DatamapperConfigService.java +++ b/src/main/java/org/frankframework/flow/datamapper/DatamapperConfigService.java @@ -82,6 +82,7 @@ public String getConfig(String projectName) throws ConfigurationNotFoundExceptio String filePath = this.getConfigFilePath(projectName).toString(); if (!Files.exists(Path.of(filePath))) { fileSystemStorage.createFile(filePath); + return ""; } return fileSystemStorage.readFile(filePath); } catch (IOException e) { diff --git a/src/main/java/org/frankframework/flow/file/FileController.java b/src/main/java/org/frankframework/flow/file/FileController.java index f07bfb84..125097f2 100644 --- a/src/main/java/org/frankframework/flow/file/FileController.java +++ b/src/main/java/org/frankframework/flow/file/FileController.java @@ -3,6 +3,7 @@ import org.frankframework.flow.exception.ApiException; import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; @@ -25,9 +26,10 @@ public FileController(FileService fileService) { } @GetMapping("/{filePath}") - public ResponseEntity getFile(@PathVariable String projectName, @PathVariable String filePath) throws IOException { - String fileContent = fileService.readFile(projectName, filePath); - return ResponseEntity.ok(fileContent); + public ResponseEntity getFile(@PathVariable String projectName, @PathVariable String filePath) throws IOException { + FileDTO file = fileService.readFile(projectName, filePath); + MediaType fileType = MediaType.valueOf(file.type()); + return ResponseEntity.ok().contentType(fileType).body(file); } @PostMapping("/{filePath}") diff --git a/src/main/java/org/frankframework/flow/file/FileDTO.java b/src/main/java/org/frankframework/flow/file/FileDTO.java new file mode 100644 index 00000000..8d297e9e --- /dev/null +++ b/src/main/java/org/frankframework/flow/file/FileDTO.java @@ -0,0 +1,4 @@ +package org.frankframework.flow.file; + +public record FileDTO(String content, String type) { +} diff --git a/src/main/java/org/frankframework/flow/file/FileService.java b/src/main/java/org/frankframework/flow/file/FileService.java index bb0eef28..9e66e37e 100644 --- a/src/main/java/org/frankframework/flow/file/FileService.java +++ b/src/main/java/org/frankframework/flow/file/FileService.java @@ -25,10 +25,13 @@ public FileService(ProjectService projectService, FileSystemStorage fileSystemSt this.fileSystemStorage = fileSystemStorage; } - public String readFile(String projectName, String path) throws IOException { + public FileDTO readFile(String projectName, String path) throws IOException { validatePath(path); validateWithinProject(projectName, path); - return fileSystemStorage.readFile(path); + + String content = fileSystemStorage.readFile(path); + String type = fileSystemStorage.readFileType(path); + return new FileDTO(content, type); } public FileTreeNode createOrUpdateFile(String projectName, String path, String fileContent) throws IOException, ApiException { diff --git a/src/main/java/org/frankframework/flow/filesystem/CloudFileSystemStorageService.java b/src/main/java/org/frankframework/flow/filesystem/CloudFileSystemStorageService.java index efe995ab..a7a41b7e 100644 --- a/src/main/java/org/frankframework/flow/filesystem/CloudFileSystemStorageService.java +++ b/src/main/java/org/frankframework/flow/filesystem/CloudFileSystemStorageService.java @@ -11,8 +11,11 @@ import java.util.Collections; import java.util.List; import java.util.stream.Stream; + import lombok.extern.slf4j.Slf4j; + import org.frankframework.flow.security.UserWorkspaceContext; + import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Service; @@ -90,6 +93,11 @@ public String readFile(String path) throws IOException { return Files.readString(resolveSecurely(path), StandardCharsets.UTF_8); } + @Override + public String readFileType(String path) throws IOException { + return Files.probeContentType(resolveSecurely(path)); + } + @Override public void writeFile(String path, String content) throws IOException { Files.writeString(resolveSecurely(path), content, StandardCharsets.UTF_8); diff --git a/src/main/java/org/frankframework/flow/filesystem/FileSystemStorage.java b/src/main/java/org/frankframework/flow/filesystem/FileSystemStorage.java index 666b2b5e..c7651cd0 100644 --- a/src/main/java/org/frankframework/flow/filesystem/FileSystemStorage.java +++ b/src/main/java/org/frankframework/flow/filesystem/FileSystemStorage.java @@ -19,6 +19,8 @@ public interface FileSystemStorage { String readFile(String path) throws IOException; + String readFileType(String path) throws IOException; + void writeFile(String path, String content) throws IOException; /** diff --git a/src/main/java/org/frankframework/flow/filesystem/LocalFileSystemStorageService.java b/src/main/java/org/frankframework/flow/filesystem/LocalFileSystemStorageService.java index 1be4bdaa..5781d1f1 100644 --- a/src/main/java/org/frankframework/flow/filesystem/LocalFileSystemStorageService.java +++ b/src/main/java/org/frankframework/flow/filesystem/LocalFileSystemStorageService.java @@ -10,6 +10,7 @@ import java.util.ArrayList; import java.util.List; import java.util.stream.Stream; + import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Service; @@ -51,6 +52,11 @@ public String readFile(String path) throws IOException { return Files.readString(sanitizePath(path), StandardCharsets.UTF_8); } + @Override + public String readFileType(String path) throws IOException { + return Files.probeContentType(sanitizePath(path)); + } + @Override public void writeFile(String path, String content) throws IOException { Files.writeString(sanitizePath(path), content, StandardCharsets.UTF_8); From 0647835ca768dbbc33687ac664bf672f8f06e2c1 Mon Sep 17 00:00:00 2001 From: Vivy <4380412+Matthbo@users.noreply.github.com> Date: Fri, 27 Mar 2026 17:23:41 +0100 Subject: [PATCH 06/18] Filetype handling in frontend --- .../file-structure/use-studio-context-menu.ts | 12 +- .../add-configuration-modal.tsx | 8 +- .../frontend/app/routes/editor/editor.tsx | 110 +++++++++++------- .../frontend/app/services/file-service.ts | 14 +-- .../app/services/file-tree-service.ts | 6 +- .../frontend/app/stores/editor-tab-store.ts | 3 +- .../flow/file/FileController.java | 26 ++--- .../frankframework/flow/file/FileService.java | 5 +- .../flow/file/FileTreeController.java | 2 +- 9 files changed, 105 insertions(+), 81 deletions(-) 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 2adf08f0..af7f4a75 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,13 +1,11 @@ import { useCallback, useRef, useState } from 'react' import type { TreeItemIndex } from 'react-complex-tree' +import { deleteFile, renameFile } from '~/services/file-service' import { - createFileInProject, createFolderInProject, - renameInProject, - deleteInProject, } from '~/services/file-tree-service' import { createAdapter, renameAdapter, deleteAdapter } from '~/services/adapter-service' -import { clearConfigurationCache } from '~/services/configuration-service' +import { clearConfigurationCache, createConfiguration } from '~/services/configuration-service' import useTabStore from '~/stores/tab-store' import { showErrorToastFrom } from '~/components/toast' import type { StudioItemData, StudioFolderData, StudioAdapterData } from './studio-files-data-provider' @@ -156,7 +154,7 @@ export function useStudioContextMenu({ projectName, dataProvider }: UseStudioCon onSubmit: async (name: string) => { const fileName = ensureXmlExtension(name) try { - await createFileInProject(projectName, menu.folderPath, fileName) + await createConfiguration(projectName, `${menu.folderPath}/${fileName}`) await dataProvider.reloadDirectory('root') } catch (error) { showErrorToastFrom('Failed to create configuration', error) @@ -232,7 +230,7 @@ export function useStudioContextMenu({ projectName, dataProvider }: UseStudioCon await renameAdapter(projectName, oldName, newName, menu.path) } else { const finalName = menu.itemType === 'configuration' ? ensureXmlExtension(newName) : newName - await renameInProject(projectName, menu.path, finalName) + await renameFile(projectName, `${menu.path}/${oldName}`, `${menu.path}/${newName}`) clearConfigurationCache(projectName, menu.path) const newPath = `${getParentDir(menu.path)}/${finalName}` useTabStore.getState().renameTabsForConfig(menu.path, newPath) @@ -270,7 +268,7 @@ export function useStudioContextMenu({ projectName, dataProvider }: UseStudioCon await deleteAdapter(projectName, deleteTarget.name, deleteTarget.path) removeAdapterTab(deleteTarget.path, deleteTarget.name) } else { - await deleteInProject(projectName, deleteTarget.path) + await deleteFile(projectName, deleteTarget.path) clearConfigurationCache(projectName, deleteTarget.path) useTabStore.getState().removeTabsForConfig(deleteTarget.path) } 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 e34f25be..7bc34415 100644 --- a/src/main/frontend/app/routes/configurations/add-configuration-modal.tsx +++ b/src/main/frontend/app/routes/configurations/add-configuration-modal.tsx @@ -1,9 +1,9 @@ -import { useEffect, useState } from 'react' +import React, { useEffect, useState } from 'react' +import { createConfiguration } from '~/services/configuration-service' import { useProjectStore } from '~/stores/project-store' import type { Project } from '~/types/project.types' import Button from '~/components/inputs/button' import DirectoryPicker from '~/components/directory-picker/directory-picker' -import { createFileInProject } from '~/services/file-tree-service' import { fetchProject } from '~/services/project-service' interface AddConfigurationModalProperties { @@ -50,7 +50,7 @@ export default function AddConfigurationModal({ configname = `${configname}.xml` } - await createFileInProject(currentProject.name, rootLocationName, configname) + await createConfiguration(currentProject.name, `${rootLocationName}/${configname}`) const updatedProject = await fetchProject(currentProject.name) setProject(updatedProject) onSuccess?.() @@ -92,7 +92,7 @@ export default function AddConfigurationModal({ className="bg-background/50 absolute inset-0 z-50 flex items-center justify-center" onClick={handleClickedOutside} > -
+

Add Configuration

Add a new configuration file.

diff --git a/src/main/frontend/app/routes/editor/editor.tsx b/src/main/frontend/app/routes/editor/editor.tsx index 149aa9a6..d6dd0967 100644 --- a/src/main/frontend/app/routes/editor/editor.tsx +++ b/src/main/frontend/app/routes/editor/editor.tsx @@ -9,6 +9,7 @@ import SidebarContentClose from '~/components/sidebars-layout/sidebar-content-cl import { SidebarSide } from '~/components/sidebars-layout/sidebar-layout-store' import { useTheme } from '~/hooks/use-theme' import { useCallback, useEffect, useRef, useState } from 'react' +import { fetchFile } from '~/services/file-service' import { useProjectStore } from '~/stores/project-store' import EditorFileStructure from '~/components/file-structure/editor-file-structure' import useEditorTabStore from '~/stores/editor-tab-store' @@ -45,12 +46,18 @@ export interface ValidationError { startColumn: number endColumn: number } + export interface TextModel { getLineContent: (n: number) => string getLineCount: () => number getLineMaxColumn: (n: number) => number } +interface CachedFile { + content: string + type: string +} + const SAVED_DISPLAY_DURATION = 2000 const ELEMENT_ERROR_RE = /[Ee]lement [\u2018\u2019'"'{]?([\w:.-]+)[\u2018\u2019'"'}]?/ const ATTRIBUTE_ERROR_RE = /[Aa]ttribute [\u2018\u2019'"'{]?([\w:.-]+)[\u2018\u2019'"'}]?/ @@ -161,7 +168,7 @@ export default function CodeEditor() { const savedTimerRef = useRef | null>(null) const validationTimerRef = useRef | null>(null) const validationCounterRef = useRef(0) - const contentCacheRef = useRef>(new Map()) + const contentCacheRef = useRef>(new Map()) const activeTab = useEditorTabStore( useShallow((state) => { @@ -180,7 +187,7 @@ export default function CodeEditor() { const isDiffTab = activeTab.type === 'diff' const performSave = useCallback( - async (content?: string) => { + (content?: string) => { if (!project || !activeTabFilePath || isDiffTab) return const updatedContent = content ?? editorReference.current?.getValue?.() @@ -190,18 +197,19 @@ export default function CodeEditor() { if (!configPath) return setSaveStatus('saving') - try { - const xmlResponse = await saveConfiguration(project.name, configPath, updatedContent) - setFileContent(xmlResponse.xmlContent) - contentCacheRef.current.set(activeTabFilePath, updatedContent) - setSaveStatus('saved') - if (savedTimerRef.current) clearTimeout(savedTimerRef.current) - savedTimerRef.current = setTimeout(() => setSaveStatus('idle'), SAVED_DISPLAY_DURATION) - if (project.isGitRepository) refreshOpenDiffs(project.name) - } catch (error) { - showErrorToastFrom('Error saving', error) - setSaveStatus('idle') - } + saveConfiguration(project.name, configPath, updatedContent) + .then(({ xmlContent }) => { + setFileContent(xmlContent) + contentCacheRef.current.set(activeTabFilePath, { type: 'xml', content: xmlContent }) + setSaveStatus('saved') + if (savedTimerRef.current) clearTimeout(savedTimerRef.current) + savedTimerRef.current = setTimeout(() => setSaveStatus('idle'), SAVED_DISPLAY_DURATION) + if (project.isGitRepository) refreshOpenDiffs(project.name) + }) + .catch((error) => { + showErrorToastFrom('Error saving', error) + setSaveStatus('idle') + }) }, [project, activeTabFilePath, isDiffTab], ) @@ -398,9 +406,11 @@ export default function CodeEditor() { (newActiveTab, oldActiveTab) => { if (oldActiveTab && oldActiveTab !== newActiveTab) flushPendingSave() if (oldActiveTab && oldActiveTab !== newActiveTab) { - const currentContent = editorReference.current?.getValue() - if (currentContent !== undefined) { - contentCacheRef.current.set(oldActiveTab, currentContent) + const currentEditor = editorReference.current + if (currentEditor) { + const content = currentEditor.getValue() + const type = currentEditor.getModel()?.getLanguageId() || 'xml' + contentCacheRef.current.set(oldActiveTab, { type, content }) } flushPendingSave() } @@ -412,37 +422,49 @@ export default function CodeEditor() { useEffect(() => { if (isDiffTab) return - const abortController = new AbortController() - - async function fetchXml() { - try { - const configPath = useEditorTabStore.getState().getTab(activeTabFilePath)?.configurationPath - if (!configPath || !project) return - - const isForceRefresh = refreshCounter !== lastRefreshCounterRef.current - lastRefreshCounterRef.current = refreshCounter - - if (!isForceRefresh) { - const cached = contentCacheRef.current.get(activeTabFilePath) - if (cached !== undefined) { - setFileContent(cached) - return - } - } + function setMonacoContent(abortSignal: AbortSignal, content: string, type: string) { + if (!abortSignal.aborted) { + contentCacheRef.current.set(activeTabFilePath, { type, content }) + setFileContent(content) + setFileLanguage(type) + } + } - const xmlString = await fetchConfiguration(project.name, configPath, abortController.signal) - if (!abortController.signal.aborted) { - contentCacheRef.current.set(activeTabFilePath, xmlString) - setFileContent(xmlString) - } - } catch (error) { - if (!abortController.signal.aborted) { - console.error('Failed to load XML:', error) - } + const abortController = new AbortController() + const activeTab = useEditorTabStore.getState().getTab(activeTabFilePath) + if (!activeTab || !project) return + + const filePath = activeTab?.configurationPath + const fileExtension = activeTab.name.split('.').pop()?.toLowerCase() + const isForceRefresh = refreshCounter !== lastRefreshCounterRef.current + lastRefreshCounterRef.current = refreshCounter + + if (!isForceRefresh) { + const cached = contentCacheRef.current.get(activeTabFilePath) + if (cached !== undefined) { + setFileContent(cached.content) + setFileLanguage(cached.type) + return } } - fetchXml() + if (fileExtension === 'xml') { + fetchConfiguration(project.name, filePath, abortController.signal) + .then((content) => setMonacoContent(abortController.signal, content, 'xml')) + .catch((error) => { + if (!abortController.signal.aborted) { + console.error('Failed to load configuration XML:', error) + } + }) + } else { + fetchFile(project.name, filePath, abortController.signal) + .then(({ content, type }) => setMonacoContent(abortController.signal, content, type)) + .catch((error) => { + if (!abortController.signal.aborted) { + console.error('Failed to load file:', error) + } + }) + } return () => abortController.abort() }, [project, activeTabFilePath, isDiffTab, refreshCounter]) diff --git a/src/main/frontend/app/services/file-service.ts b/src/main/frontend/app/services/file-service.ts index 49414fb8..4cf9b8cf 100644 --- a/src/main/frontend/app/services/file-service.ts +++ b/src/main/frontend/app/services/file-service.ts @@ -10,14 +10,14 @@ export async function createFile(projectName: string, filePath: string): Promise await updateFile(projectName, filePath, '') } -export function fetchFile(projectName: string, filepath: string, signal?: AbortSignal): Promise { - return apiFetch(`${getBaseUrl(projectName)}/${encodeURIComponent(filepath)}`, { signal }) +export function fetchFile(projectName: string, path: string, signal?: AbortSignal): Promise { + return apiFetch(`${getBaseUrl(projectName)}?path=${encodeURIComponent(path)}`, { signal }) } -export function updateFile(projectName: string, filePath: string, fileContent: string): Promise { - return apiFetch(`${getBaseUrl(projectName)}/${encodeURIComponent(filePath)}`, { +export function updateFile(projectName: string, path: string, content: string): Promise { + return apiFetch(`${getBaseUrl(projectName)}?path=${encodeURIComponent(path)}`, { method: 'PUT', - body: fileContent, + body: content, }) } @@ -29,9 +29,9 @@ export function renameFile(projectName: string, oldPath: string, newPath: string } export async function deleteFile(projectName: string, path: string): Promise { - await apiFetch(`${getBaseUrl(projectName)}/${encodeURIComponent(path)}`, { method: 'DELETE' }) + await apiFetch(`${getBaseUrl(projectName)}?path=${encodeURIComponent(path)}`, { method: 'DELETE' }) } function getBaseUrl(projectName: string): string { - return `/projects/${encodeURIComponent(projectName)}/files` + return `/projects/${encodeURIComponent(projectName)}/file/` } diff --git a/src/main/frontend/app/services/file-tree-service.ts b/src/main/frontend/app/services/file-tree-service.ts index db5abda4..8712e000 100644 --- a/src/main/frontend/app/services/file-tree-service.ts +++ b/src/main/frontend/app/services/file-tree-service.ts @@ -2,11 +2,11 @@ import { apiFetch } from '~/utils/api' import type { FileTreeNode } from '~/types/filesystem.types' export async function fetchProjectTree(projectName: string, signal?: AbortSignal): Promise { - return apiFetch(`${getTreeUrl(projectName)}/configurations`, { signal }) + return apiFetch(`${getTreeUrl(projectName)}/configuration`, { signal }) } export async function fetchShallowConfigurationsTree(projectName: string, signal?: AbortSignal): Promise { - return apiFetch(`${getTreeUrl(projectName)}/configurations?shallow=true`, { signal }) + return apiFetch(`${getTreeUrl(projectName)}/configuration?shallow=true`, { signal }) } export async function fetchProjectRootTree(projectName: string, signal?: AbortSignal): Promise { @@ -24,7 +24,7 @@ export async function fetchDirectoryByPath( } export async function createFolderInProject(projectName: string, path: string, name: string): Promise { - return apiFetch(`${getBaseUrl(projectName)}/folders`, { + return apiFetch(`${getBaseUrl(projectName)}/folder`, { method: 'POST', body: JSON.stringify({ path, name }), }) diff --git a/src/main/frontend/app/stores/editor-tab-store.ts b/src/main/frontend/app/stores/editor-tab-store.ts index 00dec4b1..ca239dc9 100644 --- a/src/main/frontend/app/stores/editor-tab-store.ts +++ b/src/main/frontend/app/stores/editor-tab-store.ts @@ -1,11 +1,12 @@ import { create } from 'zustand' import { subscribeWithSelector } from 'zustand/middleware' +import type { GitHunk } from '~/types/git.types' export interface DiffTabData { oldContent: string newContent: string filePath: string - hunks: import('~/types/git.types').GitHunk[] + hunks: GitHunk[] } export interface EditorTabData { diff --git a/src/main/java/org/frankframework/flow/file/FileController.java b/src/main/java/org/frankframework/flow/file/FileController.java index 125097f2..ce84644c 100644 --- a/src/main/java/org/frankframework/flow/file/FileController.java +++ b/src/main/java/org/frankframework/flow/file/FileController.java @@ -11,12 +11,13 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import java.io.IOException; @RestController -@RequestMapping("/projects/{projectName}/files") +@RequestMapping("/projects/{projectName}/file") public class FileController { private final FileService fileService; @@ -25,32 +26,31 @@ public FileController(FileService fileService) { this.fileService = fileService; } - @GetMapping("/{filePath}") - public ResponseEntity getFile(@PathVariable String projectName, @PathVariable String filePath) throws IOException { - FileDTO file = fileService.readFile(projectName, filePath); - MediaType fileType = MediaType.valueOf(file.type()); - return ResponseEntity.ok().contentType(fileType).body(file); + @GetMapping() + public ResponseEntity getFile(@PathVariable String projectName, @RequestParam String path) throws IOException { + FileDTO file = fileService.readFile(projectName, path); + return ResponseEntity.ok(file); } - @PostMapping("/{filePath}") + @PostMapping() public ResponseEntity createOrUpdateFile( @PathVariable String projectName, - @PathVariable String filePath, + @RequestParam String path, @RequestBody String fileContent ) throws IOException, ApiException { - FileTreeNode node = fileService.createOrUpdateFile(projectName, filePath, fileContent); + FileTreeNode node = fileService.createOrUpdateFile(projectName, path, fileContent); return ResponseEntity.status(HttpStatus.CREATED.value()).body(node); } - @PostMapping("/move") + @PostMapping("move") public ResponseEntity renameFile(@PathVariable String projectName, @RequestBody FileRenameDTO dto) throws IOException { FileTreeNode node = fileService.renameFile(projectName, dto.oldPath(), dto.newPath()); return ResponseEntity.ok(node); } - @DeleteMapping("/{filePath}") - public ResponseEntity deleteFile(@PathVariable String projectName, @PathVariable String filePath) throws IOException { - fileService.deleteFile(projectName, filePath); + @DeleteMapping() + public ResponseEntity deleteFile(@PathVariable String projectName, @RequestParam String path) throws IOException { + fileService.deleteFile(projectName, path); return ResponseEntity.noContent().build(); } } diff --git a/src/main/java/org/frankframework/flow/file/FileService.java b/src/main/java/org/frankframework/flow/file/FileService.java index 9e66e37e..a9b070e1 100644 --- a/src/main/java/org/frankframework/flow/file/FileService.java +++ b/src/main/java/org/frankframework/flow/file/FileService.java @@ -31,10 +31,13 @@ public FileDTO readFile(String projectName, String path) throws IOException { String content = fileSystemStorage.readFile(path); String type = fileSystemStorage.readFileType(path); + if (type == null) { + type = "text/plain"; + } return new FileDTO(content, type); } - public FileTreeNode createOrUpdateFile(String projectName, String path, String fileContent) throws IOException, ApiException { + public FileTreeNode createOrUpdateFile(String projectName, String path, String fileContent) throws IOException { validatePath(path); String fileName = path.substring(path.lastIndexOf("/") + 1); validateFileName(fileName); diff --git a/src/main/java/org/frankframework/flow/file/FileTreeController.java b/src/main/java/org/frankframework/flow/file/FileTreeController.java index 42f50658..fee45097 100644 --- a/src/main/java/org/frankframework/flow/file/FileTreeController.java +++ b/src/main/java/org/frankframework/flow/file/FileTreeController.java @@ -51,7 +51,7 @@ public FileTreeNode getDirectoryContent( return fileTreeService.getShallowDirectoryTree(projectName, path); } - @PostMapping("/folders") + @PostMapping("/folder") public ResponseEntity createFolder(@PathVariable String projectName, @RequestBody FolderCreateDTO folderCreate) throws IOException { FileTreeNode node = fileTreeService.createFolder(projectName, folderCreate.path()); return ResponseEntity.status(HttpStatus.CREATED.value()).body(node); From 0cdd4b10f34e81e0cd471bacf97b8dd8f1932031 Mon Sep 17 00:00:00 2001 From: Vivy <4380412+Matthbo@users.noreply.github.com> Date: Mon, 30 Mar 2026 17:18:27 +0200 Subject: [PATCH 07/18] Fix fetching file & configuration --- .../configurations/configuration-manager.tsx | 5 +++-- .../frontend/app/routes/editor/editor.tsx | 18 +++++++++++------- .../app/services/configuration-service.ts | 6 +++--- .../frontend/app/services/file-service.ts | 4 ++-- src/main/frontend/app/utils/api.ts | 4 ++-- .../ConfigurationController.java | 19 ++++++++++--------- .../flow/file/FileController.java | 6 +++--- .../frankframework/flow/file/FileService.java | 16 ++++++++++------ 8 files changed, 44 insertions(+), 34 deletions(-) diff --git a/src/main/frontend/app/routes/configurations/configuration-manager.tsx b/src/main/frontend/app/routes/configurations/configuration-manager.tsx index c44c1514..dd7988fd 100644 --- a/src/main/frontend/app/routes/configurations/configuration-manager.tsx +++ b/src/main/frontend/app/routes/configurations/configuration-manager.tsx @@ -1,3 +1,4 @@ +import { deleteFile } from '~/services/file-service' import { useProjectStore } from '~/stores/project-store' import ConfigurationTile from './configuration-tile' import ArrowLeftIcon from '/icons/solar/Alt Arrow Left.svg?react' @@ -7,7 +8,7 @@ import { useState, useEffect, useCallback, type ChangeEvent, useMemo } from 'rea import AddConfigurationModal from './add-configuration-modal' import LoadingSpinner from '~/components/loading-spinner' import type { FileTreeNode } from '~/types/filesystem.types' -import { deleteInProject, fetchProjectTree } from '~/services/file-tree-service' +import { fetchProjectTree } from '~/services/file-tree-service' import Button from '~/components/inputs/button' import Search from '~/components/search/search' import { toRelativePath } from '~/utils/path-utils' @@ -104,7 +105,7 @@ export default function ConfigurationManager() { const handleDelete = async (filepath: string) => { if (!currentProject?.name) return - await deleteInProject(currentProject.name, filepath) + await deleteFile(currentProject.name, filepath) loadTree() } diff --git a/src/main/frontend/app/routes/editor/editor.tsx b/src/main/frontend/app/routes/editor/editor.tsx index d6dd0967..9e9025c9 100644 --- a/src/main/frontend/app/routes/editor/editor.tsx +++ b/src/main/frontend/app/routes/editor/editor.tsx @@ -422,8 +422,8 @@ export default function CodeEditor() { useEffect(() => { if (isDiffTab) return - function setMonacoContent(abortSignal: AbortSignal, content: string, type: string) { - if (!abortSignal.aborted) { + function setMonacoContent(content: string, type: string, abortSignal?: AbortSignal) { + if (!abortSignal || !abortSignal.aborted) { contentCacheRef.current.set(activeTabFilePath, { type, content }) setFileContent(content) setFileLanguage(type) @@ -450,7 +450,7 @@ export default function CodeEditor() { if (fileExtension === 'xml') { fetchConfiguration(project.name, filePath, abortController.signal) - .then((content) => setMonacoContent(abortController.signal, content, 'xml')) + .then((content) => setMonacoContent(content, 'xml', abortController.signal)) .catch((error) => { if (!abortController.signal.aborted) { console.error('Failed to load configuration XML:', error) @@ -458,10 +458,14 @@ export default function CodeEditor() { }) } else { fetchFile(project.name, filePath, abortController.signal) - .then(({ content, type }) => setMonacoContent(abortController.signal, content, type)) + .then(({ content, type }) => { + const fileType = type ? type.split('/')[1] : '' + setMonacoContent(content, fileType, abortController.signal) + }) .catch((error) => { if (!abortController.signal.aborted) { console.error('Failed to load file:', error) + setMonacoContent('', '') } }) } @@ -482,9 +486,9 @@ export default function CodeEditor() { }, [activeTabFilePath]) useEffect(() => { - if (!fileContent || !xsdLoaded || isDiffTab) return + if (!fileContent || !xsdLoaded || isDiffTab || fileLanguage !== 'xml') return runSchemaValidation(fileContent) - }, [fileContent, xsdLoaded, isDiffTab, runSchemaValidation]) + }, [fileContent, xsdLoaded, isDiffTab, runSchemaValidation, fileLanguage]) useEffect(() => { if (!fileContent || !activeTabFilePath || !editorReference.current || isDiffTab) return @@ -612,7 +616,7 @@ export default function CodeEditor() { onMount={handleEditorMount} onChange={(value) => { scheduleSave() - if (value) scheduleSchemaValidation(value) + if (value && fileLanguage === 'xml') scheduleSchemaValidation(value) }} options={{ automaticLayout: true, quickSuggestions: false }} /> diff --git a/src/main/frontend/app/services/configuration-service.ts b/src/main/frontend/app/services/configuration-service.ts index 73be8499..8eeea889 100644 --- a/src/main/frontend/app/services/configuration-service.ts +++ b/src/main/frontend/app/services/configuration-service.ts @@ -25,21 +25,21 @@ export async function fetchConfigurationCached( export async function fetchConfiguration(projectName: string, filepath: string, signal?: AbortSignal): Promise { const { content } = await apiFetch<{ content: string }>( - `${getBaseUrl(projectName)}/${encodeURIComponent(filepath)}`, + `${getBaseUrl(projectName)}?path=${encodeURIComponent(filepath)}`, { signal }, ) return content } export async function saveConfiguration(projectName: string, filepath: string, content: string): Promise { - return apiFetch(`${getBaseUrl(projectName)}/${encodeURIComponent(filepath)}`, { + return apiFetch(`${getBaseUrl(projectName)}?path=${encodeURIComponent(filepath)}`, { method: 'PUT', body: JSON.stringify({ filepath, content }), }) } export async function createConfiguration(projectName: string, filename: string): Promise { - return apiFetch(`${getBaseUrl(projectName)}/${encodeURIComponent(filename)}`, { method: 'POST' }) + return apiFetch(`${getBaseUrl(projectName)}?path=${encodeURIComponent(filename)}`, { method: 'POST' }) } function getBaseUrl(projectName: string): string { diff --git a/src/main/frontend/app/services/file-service.ts b/src/main/frontend/app/services/file-service.ts index 4cf9b8cf..987fbf36 100644 --- a/src/main/frontend/app/services/file-service.ts +++ b/src/main/frontend/app/services/file-service.ts @@ -3,7 +3,7 @@ import { apiFetch } from '~/utils/api' export interface FileDTO { content: string - type: string + type: string | null } export async function createFile(projectName: string, filePath: string): Promise { @@ -33,5 +33,5 @@ export async function deleteFile(projectName: string, path: string): Promise getConfigurationByPath( @PathVariable String projectName, - @PathVariable String filepath + @RequestParam String path ) throws IOException, ApiException { - ConfigurationDTO dto = configurationService.getConfigurationContent(projectName, filepath); + ConfigurationDTO dto = configurationService.getConfigurationContent(projectName, path); return ResponseEntity.ok(dto); } - @PutMapping("/{filepath}") + @PutMapping() public ResponseEntity updateConfiguration( @PathVariable String projectName, - @PathVariable String filepath, + @RequestParam String path, @RequestBody String content ) throws ApiException, IOException, ParserConfigurationException, SAXException, TransformerException { - String updatedContent = configurationService.updateConfiguration(projectName, filepath, content); + String updatedContent = configurationService.updateConfiguration(projectName, path, content); XmlDTO xmlDTO = new XmlDTO(updatedContent); return ResponseEntity.ok(xmlDTO); } - @PostMapping("/{fileName}") + @PostMapping() public ResponseEntity addConfiguration( @PathVariable String projectName, - @PathVariable String fileName + @RequestParam String name ) throws ApiException, IOException { - String content = configurationService.addConfiguration(projectName, fileName); + String content = configurationService.addConfiguration(projectName, name); XmlDTO xmlDTO = new XmlDTO(content); return ResponseEntity.ok(xmlDTO); } diff --git a/src/main/java/org/frankframework/flow/file/FileController.java b/src/main/java/org/frankframework/flow/file/FileController.java index ce84644c..fb93c129 100644 --- a/src/main/java/org/frankframework/flow/file/FileController.java +++ b/src/main/java/org/frankframework/flow/file/FileController.java @@ -27,7 +27,7 @@ public FileController(FileService fileService) { } @GetMapping() - public ResponseEntity getFile(@PathVariable String projectName, @RequestParam String path) throws IOException { + public ResponseEntity getFile(@PathVariable String projectName, @RequestParam String path) { FileDTO file = fileService.readFile(projectName, path); return ResponseEntity.ok(file); } @@ -43,13 +43,13 @@ public ResponseEntity createOrUpdateFile( } @PostMapping("move") - public ResponseEntity renameFile(@PathVariable String projectName, @RequestBody FileRenameDTO dto) throws IOException { + public ResponseEntity renameFile(@PathVariable String projectName, @RequestBody FileRenameDTO dto) { FileTreeNode node = fileService.renameFile(projectName, dto.oldPath(), dto.newPath()); return ResponseEntity.ok(node); } @DeleteMapping() - public ResponseEntity deleteFile(@PathVariable String projectName, @RequestParam String path) throws IOException { + public ResponseEntity deleteFile(@PathVariable String projectName, @RequestParam String path) { fileService.deleteFile(projectName, path); return ResponseEntity.noContent().build(); } diff --git a/src/main/java/org/frankframework/flow/file/FileService.java b/src/main/java/org/frankframework/flow/file/FileService.java index a9b070e1..4aabc170 100644 --- a/src/main/java/org/frankframework/flow/file/FileService.java +++ b/src/main/java/org/frankframework/flow/file/FileService.java @@ -7,6 +7,7 @@ import org.frankframework.flow.project.ProjectNotFoundException; import org.frankframework.flow.project.ProjectService; +import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import java.io.IOException; @@ -25,14 +26,17 @@ public FileService(ProjectService projectService, FileSystemStorage fileSystemSt this.fileSystemStorage = fileSystemStorage; } - public FileDTO readFile(String projectName, String path) throws IOException { + public FileDTO readFile(String projectName, String path) throws ApiException { validatePath(path); - validateWithinProject(projectName, path); + String content; + String type; - String content = fileSystemStorage.readFile(path); - String type = fileSystemStorage.readFileType(path); - if (type == null) { - type = "text/plain"; + try { + validateWithinProject(projectName, path); + content = fileSystemStorage.readFile(path); + type = fileSystemStorage.readFileType(path); + } catch (IOException exception) { + throw new ApiException("Failed to read file: " + exception.getMessage(), HttpStatus.UNPROCESSABLE_CONTENT); } return new FileDTO(content, type); } From d56082509b9ecce4621799ce161285c0695f9ef6 Mon Sep 17 00:00:00 2001 From: Vivy <4380412+Matthbo@users.noreply.github.com> Date: Tue, 31 Mar 2026 19:48:15 +0200 Subject: [PATCH 08/18] Fix tests --- .../DatamapperGeneratorService.java | 7 +- .../flow/file/FileController.java | 6 +- .../frankframework/flow/file/FileService.java | 65 +++++---- .../flow/file/FileTreeService.java | 12 +- .../CloudFileSystemStorageService.java | 15 +- .../flow/filesystem/FileSystemStorage.java | 2 +- .../flow/project/ProjectService.java | 8 +- .../ConfigurationControllerTest.java | 73 ++++------ .../ConfigurationServiceTest.java | 24 ++-- .../flow/file/FileTreeServiceTest.java | 135 +++++------------- 10 files changed, 132 insertions(+), 215 deletions(-) diff --git a/src/main/java/org/frankframework/flow/datamapper/DatamapperGeneratorService.java b/src/main/java/org/frankframework/flow/datamapper/DatamapperGeneratorService.java index 2e9fdb0e..7de1dd53 100644 --- a/src/main/java/org/frankframework/flow/datamapper/DatamapperGeneratorService.java +++ b/src/main/java/org/frankframework/flow/datamapper/DatamapperGeneratorService.java @@ -109,12 +109,7 @@ public void generate(String jsonPath, String outputPath) throws ApiException { if (jsonPath == null || jsonPath.isBlank()) { throw new ApiException("JSON file path must not be empty", HttpStatus.BAD_REQUEST); } - Path absolutePath; - try { - absolutePath = fileSystemStorage.toAbsolutePath(jsonPath); - } catch (IOException e) { - throw new ApiException("Invalid filepath", HttpStatus.BAD_REQUEST); - } + Path absolutePath = fileSystemStorage.toAbsolutePath(jsonPath); if (!Files.exists(absolutePath)) { throw new ApiException("JSON file not found: " + absolutePath, HttpStatus.INTERNAL_SERVER_ERROR); diff --git a/src/main/java/org/frankframework/flow/file/FileController.java b/src/main/java/org/frankframework/flow/file/FileController.java index fb93c129..4bffcc9f 100644 --- a/src/main/java/org/frankframework/flow/file/FileController.java +++ b/src/main/java/org/frankframework/flow/file/FileController.java @@ -27,7 +27,7 @@ public FileController(FileService fileService) { } @GetMapping() - public ResponseEntity getFile(@PathVariable String projectName, @RequestParam String path) { + public ResponseEntity getFile(@PathVariable String projectName, @RequestParam String path) throws ApiException { FileDTO file = fileService.readFile(projectName, path); return ResponseEntity.ok(file); } @@ -43,13 +43,13 @@ public ResponseEntity createOrUpdateFile( } @PostMapping("move") - public ResponseEntity renameFile(@PathVariable String projectName, @RequestBody FileRenameDTO dto) { + public ResponseEntity renameFile(@PathVariable String projectName, @RequestBody FileRenameDTO dto) throws ApiException { FileTreeNode node = fileService.renameFile(projectName, dto.oldPath(), dto.newPath()); return ResponseEntity.ok(node); } @DeleteMapping() - public ResponseEntity deleteFile(@PathVariable String projectName, @RequestParam String path) { + public ResponseEntity deleteFile(@PathVariable String projectName, @RequestParam String path) throws ApiException { fileService.deleteFile(projectName, path); return ResponseEntity.noContent().build(); } diff --git a/src/main/java/org/frankframework/flow/file/FileService.java b/src/main/java/org/frankframework/flow/file/FileService.java index 4aabc170..927053a7 100644 --- a/src/main/java/org/frankframework/flow/file/FileService.java +++ b/src/main/java/org/frankframework/flow/file/FileService.java @@ -7,6 +7,7 @@ import org.frankframework.flow.project.ProjectNotFoundException; import org.frankframework.flow.project.ProjectService; +import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; @@ -41,14 +42,17 @@ public FileDTO readFile(String projectName, String path) throws ApiException { return new FileDTO(content, type); } - public FileTreeNode createOrUpdateFile(String projectName, String path, String fileContent) throws IOException { + public FileTreeNode createOrUpdateFile(String projectName, String path, String fileContent) throws ApiException { validatePath(path); - String fileName = path.substring(path.lastIndexOf("/") + 1); - validateFileName(fileName); - validateWithinProject(projectName, path); + String fileName = Path.of(path).getFileName().toString(); - fileSystemStorage.createFile(path); - fileSystemStorage.writeFile(path, fileContent); + try { + validateWithinProject(projectName, path); + fileSystemStorage.createFile(path); + fileSystemStorage.writeFile(path, fileContent); + } catch (IOException exception) { + throw new ApiException("Failed to write file: " + exception.getMessage(), HttpStatus.INTERNAL_SERVER_ERROR); + } // invalidateTreeCache(projectName); @@ -59,21 +63,25 @@ public FileTreeNode createOrUpdateFile(String projectName, String path, String f return node; } - public FileTreeNode renameFile(String projectName, String oldPath, String newPath) throws IOException { + public FileTreeNode renameFile(String projectName, String oldPath, String newPath) throws ApiException { validatePath(newPath); - String newFileName = newPath.substring(newPath.lastIndexOf("/") + 1); - validateFileName(newFileName); - validateWithinProject(projectName, oldPath); - + String newFileName = Path.of(newPath).getFileName().toString(); Path absoluteNewPath = fileSystemStorage.toAbsolutePath(newPath); - String absoluteNewPathString = absoluteNewPath.toString(); - validateWithinProject(projectName, absoluteNewPathString); - if (Files.exists(absoluteNewPath)) { - throw new FileAlreadyExistsException("A file or folder with that path already exists: " + absoluteNewPathString); - } + try { + validateWithinProject(projectName, oldPath); + + String absoluteNewPathString = absoluteNewPath.toString(); + validateWithinProject(projectName, absoluteNewPathString); + + if (Files.exists(absoluteNewPath)) { + throw new FileAlreadyExistsException("A file or folder with that path already exists: " + absoluteNewPathString); + } - fileSystemStorage.rename(oldPath, absoluteNewPathString); + fileSystemStorage.rename(oldPath, absoluteNewPathString); + } catch (IOException exception) { + throw new ApiException(exception.getMessage(), HttpStatus.NOT_ACCEPTABLE); + } // invalidateTreeCache(projectName); boolean isDir = Files.isDirectory(absoluteNewPath); @@ -84,9 +92,13 @@ public FileTreeNode renameFile(String projectName, String oldPath, String newPat return node; } - public void deleteFile(String projectName, String path) throws IOException { - validateWithinProject(projectName, path); - fileSystemStorage.delete(path); + public void deleteFile(String projectName, String path) throws ApiException { + try { + validateWithinProject(projectName, path); + fileSystemStorage.delete(path); + } catch (IOException exception) { + throw new ApiException("Failed to delete file: " + exception.getMessage(), HttpStatus.INTERNAL_SERVER_ERROR); + } // invalidateTreeCache(projectName); } @@ -104,20 +116,11 @@ public void validateWithinProject(String projectName, String path) throws IOExce } } - protected void validateFileName(String name) { - if (name == null || name.isBlank()) { - throw new IllegalArgumentException("File name must not be empty"); - } - if (name.contains("/") || name.contains("\\") || name.contains("..")) { - throw new IllegalArgumentException("File name contains invalid characters: " + name); - } - } - - protected void validatePath(String path) { + protected void validatePath(String path) throws IllegalArgumentException { if (path == null || path.isBlank()) { throw new IllegalArgumentException("File path must not be empty"); } - if (path.contains("\\") || path.contains("..")) { + if (path.contains("..")) { throw new IllegalArgumentException("File path contains invalid characters: " + path); } } diff --git a/src/main/java/org/frankframework/flow/file/FileTreeService.java b/src/main/java/org/frankframework/flow/file/FileTreeService.java index 608cefb4..bcabb5bc 100644 --- a/src/main/java/org/frankframework/flow/file/FileTreeService.java +++ b/src/main/java/org/frankframework/flow/file/FileTreeService.java @@ -131,11 +131,12 @@ public FileTreeNode getConfigurationsDirectoryTree(String projectName) throws IO } public FileTreeNode createFolder(String projectName, String path) throws IOException { + validatePath(path); fileService.validateWithinProject(projectName, path); fileSystemStorage.createProjectDirectory(path); invalidateTreeCache(projectName); - String folderName = path.substring(path.lastIndexOf("/") + 1); + String folderName = Path.of(path).getFileName().toString(); FileTreeNode node = new FileTreeNode(); node.setName(folderName); node.setPath(path); @@ -242,4 +243,13 @@ private FileTreeNode buildShallowTree(Path path, Path relativizeRoot, boolean us return node; } + + protected void validatePath(String path) throws IllegalArgumentException { + if (path == null || path.isBlank()) { + throw new IllegalArgumentException("File path must not be empty"); + } + if (path.contains("..")) { + throw new IllegalArgumentException("File path contains invalid characters: " + path); + } + } } diff --git a/src/main/java/org/frankframework/flow/filesystem/CloudFileSystemStorageService.java b/src/main/java/org/frankframework/flow/filesystem/CloudFileSystemStorageService.java index a7a41b7e..43163a93 100644 --- a/src/main/java/org/frankframework/flow/filesystem/CloudFileSystemStorageService.java +++ b/src/main/java/org/frankframework/flow/filesystem/CloudFileSystemStorageService.java @@ -40,17 +40,16 @@ private Path getUserRootPath() { return Paths.get(baseWorkspacePath, workspaceId).toAbsolutePath().normalize(); } - private Path getOrCreateUserRoot() throws IOException { + private Path getOrCreateUserRoot() { Path userRoot = getUserRootPath(); - if (!Files.exists(userRoot)) { - Files.createDirectories(userRoot); - } - try { + if (!Files.exists(userRoot)) { + Files.createDirectories(userRoot); + } Files.setLastModifiedTime(userRoot, FileTime.from(Instant.now())); } catch (IOException e) { - log.debug("Could not touch workspace dir", e); + log.warn("Could not touch workspace dir", e); } return userRoot; @@ -111,7 +110,7 @@ public Path createProjectDirectory(String path) throws IOException { } @Override - public Path toAbsolutePath(String path) throws IOException { + public Path toAbsolutePath(String path) { return resolveSecurely(path); } @@ -146,7 +145,7 @@ public Path rename(String oldPath, String newPath) throws IOException { return FileOperations.rename(resolveSecurely(oldPath), resolveSecurely(newPath)); } - private Path resolveSecurely(String path) throws IOException { + private Path resolveSecurely(String path) { Path root = getOrCreateUserRoot(); if (path == null || path.isBlank() || path.equals("/") || path.equals("\\")) { diff --git a/src/main/java/org/frankframework/flow/filesystem/FileSystemStorage.java b/src/main/java/org/frankframework/flow/filesystem/FileSystemStorage.java index c7651cd0..521af3a3 100644 --- a/src/main/java/org/frankframework/flow/filesystem/FileSystemStorage.java +++ b/src/main/java/org/frankframework/flow/filesystem/FileSystemStorage.java @@ -28,7 +28,7 @@ public interface FileSystemStorage { */ Path createProjectDirectory(String path) throws IOException; - Path toAbsolutePath(String path) throws IOException; + Path toAbsolutePath(String path); /** * Creates an empty file at the given path, including parent directories. diff --git a/src/main/java/org/frankframework/flow/project/ProjectService.java b/src/main/java/org/frankframework/flow/project/ProjectService.java index 8a0489ca..7fcd7b32 100644 --- a/src/main/java/org/frankframework/flow/project/ProjectService.java +++ b/src/main/java/org/frankframework/flow/project/ProjectService.java @@ -233,12 +233,8 @@ public ProjectDTO toDto(Project project) { List filepaths = getConfigurationFilesDynamically(project.getRootPath()); boolean isGitRepo = false; - try { - Path absPath = fileSystemStorage.toAbsolutePath(project.getRootPath()); - isGitRepo = Files.isDirectory(absPath.resolve(".git")); - } catch (IOException e) { - log.info("Could not determine if project is a git repository: {}", e.getMessage()); - } + Path absPath = fileSystemStorage.toAbsolutePath(project.getRootPath()); + isGitRepo = Files.isDirectory(absPath.resolve(".git")); boolean hasStoredToken = project.getGitToken() != null && !project.getGitToken().isBlank(); diff --git a/src/test/java/org/frankframework/flow/configuration/ConfigurationControllerTest.java b/src/test/java/org/frankframework/flow/configuration/ConfigurationControllerTest.java index de155c53..f2266164 100644 --- a/src/test/java/org/frankframework/flow/configuration/ConfigurationControllerTest.java +++ b/src/test/java/org/frankframework/flow/configuration/ConfigurationControllerTest.java @@ -58,44 +58,35 @@ void setUp() { void getConfigurationByPathReturnsExpectedJson() throws Exception { String filepath = "config1.xml"; String xmlContent = "content"; + ConfigurationDTO configDto = new ConfigurationDTO(filepath, xmlContent); - when(configurationService.getConfigurationContent(filepath)).thenReturn(xmlContent); + when(configurationService.getConfigurationContent(TEST_PROJECT_NAME, filepath)).thenReturn(configDto); - mockMvc.perform(post("/api/projects/MyProject/configuration") + mockMvc.perform(get("/api/projects/" + TEST_PROJECT_NAME + "/configuration?path=" + filepath) .contentType(MediaType.APPLICATION_JSON) - .accept(MediaType.APPLICATION_JSON) - .content(""" - { - "filepath": "config1.xml" - } - """)) + .accept(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) .andExpect(jsonPath("$.filepath").value(filepath)) .andExpect(jsonPath("$.content").value(xmlContent)); - verify(configurationService).getConfigurationContent(filepath); + verify(configurationService).getConfigurationContent(TEST_PROJECT_NAME, filepath); } @Test void getConfigurationNotFoundReturns404() throws Exception { String filepath = "unknown.xml"; - when(configurationService.getConfigurationContent(filepath)) + when(configurationService.getConfigurationContent(TEST_PROJECT_NAME, filepath)) .thenThrow(new ConfigurationNotFoundException("Configuration file not found: " + filepath)); - mockMvc.perform(post("/api/projects/MyProject/configuration") + mockMvc.perform(get("/api/projects/" + TEST_PROJECT_NAME + "/configuration?path=" + filepath) .contentType(MediaType.APPLICATION_JSON) - .accept(MediaType.APPLICATION_JSON) - .content(""" - { - "filepath": "unknown.xml" - } - """)) + .accept(MediaType.APPLICATION_JSON)) .andExpect(status().isNotFound()) .andExpect(jsonPath("$.httpStatus").value(404)) .andExpect(jsonPath("$.messages[0]").value("Configuration file not found: " + filepath)); - verify(configurationService).getConfigurationContent(filepath); + verify(configurationService).getConfigurationContent(TEST_PROJECT_NAME, filepath); } @Test @@ -103,22 +94,16 @@ void updateConfigurationSuccessReturns200() throws Exception { String filepath = "config1.xml"; String xmlContent = "updated"; - when(configurationService.updateConfiguration(filepath, xmlContent)) + when(configurationService.updateConfiguration(TEST_PROJECT_NAME, filepath, xmlContent)) .thenReturn(xmlContent); mockMvc.perform( - put("/api/projects/" + TEST_PROJECT_NAME + "/configuration") + put("/api/projects/" + TEST_PROJECT_NAME + "/configuration?path=" + filepath) .contentType(MediaType.APPLICATION_JSON) - .content( - """ - { - "filepath": "config1.xml", - "content": "updated" - } - """)) + .content("updated")) .andExpect(status().isOk()); - verify(configurationService).updateConfiguration(filepath, xmlContent); + verify(configurationService).updateConfiguration(TEST_PROJECT_NAME, filepath, xmlContent); } @Test @@ -128,30 +113,24 @@ void updateConfigurationNotFoundReturns404() throws Exception { doThrow(new ConfigurationNotFoundException("Invalid file path: " + filepath)) .when(configurationService) - .updateConfiguration(filepath, xmlContent); + .updateConfiguration(TEST_PROJECT_NAME, filepath, xmlContent); mockMvc.perform( - put("/api/projects/" + TEST_PROJECT_NAME + "/configuration") + put("/api/projects/" + TEST_PROJECT_NAME + "/configuration?path=" + filepath) .contentType(MediaType.APPLICATION_JSON) - .content( - """ - { - "filepath": "unknown.xml", - "content": "updated" - } - """)) + .content("updated")) .andExpect(status().isNotFound()) .andExpect(jsonPath("$.httpStatus").value(404)) .andExpect(jsonPath("$.messages[0]").value("Invalid file path: " + filepath)); - verify(configurationService).updateConfiguration(filepath, xmlContent); + verify(configurationService).updateConfiguration(TEST_PROJECT_NAME, filepath, xmlContent); } @Test void addConfigurationReturnsProjectDto() throws Exception { Project project = mock(Project.class); - when(project.getName()).thenReturn("MyProject"); - when(project.getRootPath()).thenReturn("/path/to/MyProject"); + when(project.getName()).thenReturn(TEST_PROJECT_NAME); + when(project.getRootPath()).thenReturn("/path/to/" + TEST_PROJECT_NAME); Configuration config = mock(Configuration.class); when(config.getFilepath()).thenReturn("config1.xml"); @@ -161,22 +140,22 @@ void addConfigurationReturnsProjectDto() throws Exception { when(settings.getFilters()).thenReturn(Map.of(FilterType.ADAPTER, true)); when(project.getProjectSettings()).thenReturn(settings); - when(configurationService.addConfiguration("MyProject", "NewConfig.xml")) - .thenReturn(project); + when(configurationService.addConfiguration(TEST_PROJECT_NAME, "NewConfig.xml")) + .thenReturn(""); when(projectService.toDto(project)) .thenReturn(new ProjectDTO( - "MyProject", - "/path/to/MyProject", + TEST_PROJECT_NAME, + "/path/to/" + TEST_PROJECT_NAME, List.of("config1.xml"), Map.of(FilterType.ADAPTER, true), false, false)); - mockMvc.perform(post("/api/projects/MyProject/configurations/NewConfig.xml") + mockMvc.perform(post("/api/projects/" + TEST_PROJECT_NAME + "/configuration?name=NewConfig.xml") .accept(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) - .andExpect(jsonPath("$.name").value("MyProject")); + .andExpect(jsonPath("xmlContent").value("")); - verify(configurationService).addConfiguration("MyProject", "NewConfig.xml"); + verify(configurationService).addConfiguration(TEST_PROJECT_NAME, "NewConfig.xml"); } } diff --git a/src/test/java/org/frankframework/flow/configuration/ConfigurationServiceTest.java b/src/test/java/org/frankframework/flow/configuration/ConfigurationServiceTest.java index 792ad8a5..69729d9f 100644 --- a/src/test/java/org/frankframework/flow/configuration/ConfigurationServiceTest.java +++ b/src/test/java/org/frankframework/flow/configuration/ConfigurationServiceTest.java @@ -8,6 +8,8 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; + +import org.frankframework.flow.exception.ApiException; import org.frankframework.flow.filesystem.FileSystemStorage; import org.frankframework.flow.project.Project; import org.frankframework.flow.project.ProjectNotFoundException; @@ -38,7 +40,7 @@ void setUp() { configurationService = new ConfigurationService(fileSystemStorage, projectService); } - private void stubToAbsolutePath() throws IOException { + private void stubToAbsolutePath() { when(fileSystemStorage.toAbsolutePath(anyString())).thenAnswer(invocation -> { String path = invocation.getArgument(0); Path p = Path.of(path); @@ -74,9 +76,9 @@ void getConfigurationContent_Success() throws Exception { Path file = tempDir.resolve("config.xml"); Files.writeString(file, "", StandardCharsets.UTF_8); - String result = configurationService.getConfigurationContent(file.toString()); + ConfigurationDTO result = configurationService.getConfigurationContent("test", file.toString()); - assertEquals("", result); + assertEquals("", result.content()); } @Test @@ -85,7 +87,7 @@ void getConfigurationContent_FileNotFound_ThrowsConfigurationNotFoundException() String path = tempDir.resolve("missing.xml").toString(); - assertThrows(ConfigurationNotFoundException.class, () -> configurationService.getConfigurationContent(path)); + assertThrows(ApiException.class, () -> configurationService.getConfigurationContent("test", path)); } @Test @@ -95,8 +97,8 @@ void getConfigurationContent_IsDirectory_ThrowsConfigurationNotFoundException() Path dir = Files.createDirectory(tempDir.resolve("subdir")); assertThrows( - ConfigurationNotFoundException.class, - () -> configurationService.getConfigurationContent(dir.toString())); + ApiException.class, + () -> configurationService.getConfigurationContent("test", dir.toString())); } @Test @@ -107,7 +109,7 @@ void updateConfiguration_Success() throws Exception { Path file = tempDir.resolve("config.xml"); Files.writeString(file, "", StandardCharsets.UTF_8); - configurationService.updateConfiguration(file.toString(), ""); + configurationService.updateConfiguration("test", file.toString(), ""); assertEquals("\n", Files.readString(file, StandardCharsets.UTF_8)); verify(fileSystemStorage).writeFile(file.toString(), "\n"); @@ -120,8 +122,8 @@ void updateConfiguration_FileNotFound_ThrowsConfigurationNotFoundException() thr String path = tempDir.resolve("missing.xml").toString(); assertThrows( - ConfigurationNotFoundException.class, - () -> configurationService.updateConfiguration(path, "")); + ApiException.class, + () -> configurationService.updateConfiguration("test", path, "")); } @Test @@ -135,7 +137,7 @@ void addConfiguration_Success() throws Exception { when(projectService.getProject("myproject")).thenReturn(project); - Project result = configurationService.addConfiguration("myproject", "NewConfig.xml"); + String result = configurationService.addConfiguration("myproject", "NewConfig.xml"); assertNotNull(result); @@ -161,7 +163,7 @@ void addConfiguration_PathTraversal_ThrowsSecurityException() throws Exception { when(projectService.getProject("myproject")).thenReturn(project); assertThrows( - SecurityException.class, () -> configurationService.addConfiguration("myproject", "../../../evil.xml")); + ApiException.class, () -> configurationService.addConfiguration("myproject", "../../../evil.xml")); } @Test diff --git a/src/test/java/org/frankframework/flow/file/FileTreeServiceTest.java b/src/test/java/org/frankframework/flow/file/FileTreeServiceTest.java index d51e2ecd..54fefe4a 100644 --- a/src/test/java/org/frankframework/flow/file/FileTreeServiceTest.java +++ b/src/test/java/org/frankframework/flow/file/FileTreeServiceTest.java @@ -42,15 +42,16 @@ public class FileTreeServiceTest { @Mock private ConfigurationService configurationService; + private FileService fileService; private FileTreeService fileTreeService; - private Path tempProjectRoot; private static final String TEST_PROJECT_NAME = "FrankFlowTestProject"; @BeforeEach public void setUp() throws IOException { tempProjectRoot = Files.createTempDirectory("flow_unit_test"); - fileTreeService = new FileTreeService(projectService, fileSystemStorage, configurationService); + fileService = new FileService(projectService, fileSystemStorage); + fileTreeService = new FileTreeService(projectService, fileSystemStorage, fileService); } @AfterEach @@ -307,7 +308,7 @@ public void getConfigurationsDirectoryTreeThrowsIfProjectDoesNotExist() throws P void createFile_NullName_ThrowsIllegalArgument() { assertThrows( IllegalArgumentException.class, - () -> fileTreeService.createFile(TEST_PROJECT_NAME, "/some/path", null) + () -> fileService.createOrUpdateFile(TEST_PROJECT_NAME, null, "") ); } @@ -316,25 +317,7 @@ void createFile_NullName_ThrowsIllegalArgument() { void createFile_BlankName_ThrowsIllegalArgument() { assertThrows( IllegalArgumentException.class, - () -> fileTreeService.createFile(TEST_PROJECT_NAME, "/some/path", " ") - ); - } - - @Test - @DisplayName("Should throw IllegalArgumentException when file name contains a forward slash") - void createFile_NameWithForwardSlash_ThrowsIllegalArgument() { - assertThrows( - IllegalArgumentException.class, - () -> fileTreeService.createFile(TEST_PROJECT_NAME, "/some/path", "bad/name.xml") - ); - } - - @Test - @DisplayName("Should throw IllegalArgumentException when file name contains a backslash") - void createFile_NameWithBackslash_ThrowsIllegalArgument() { - assertThrows( - IllegalArgumentException.class, - () -> fileTreeService.createFile(TEST_PROJECT_NAME, "/some/path", "bad\\name.xml") + () -> fileService.createOrUpdateFile(TEST_PROJECT_NAME, "/some/path/ ", "") ); } @@ -343,22 +326,21 @@ void createFile_NameWithBackslash_ThrowsIllegalArgument() { void createFile_NameWithDoubleDots_ThrowsIllegalArgument() { assertThrows( IllegalArgumentException.class, - () -> fileTreeService.createFile(TEST_PROJECT_NAME, "/some/path", "..") + () -> fileService.createOrUpdateFile(TEST_PROJECT_NAME, "/some/path/..", "") ); } @Test @DisplayName("Should create a file and return a FileTreeNode with FILE type") - void createFile_Success() throws IOException, ProjectNotFoundException, ApiException { + void createFile_Success() throws IOException, ApiException { stubToAbsolutePath(); stubCreateFile(); - Project project = - new Project(TEST_PROJECT_NAME, tempProjectRoot.toAbsolutePath().toString()); + Path parentPath = tempProjectRoot.toAbsolutePath(); + Project project = new Project(TEST_PROJECT_NAME, parentPath.toString()); when(projectService.getProject(TEST_PROJECT_NAME)).thenReturn(project); - String parentPath = tempProjectRoot.toAbsolutePath().toString(); - FileTreeNode node = fileTreeService.createFile(TEST_PROJECT_NAME, parentPath, "newFile.json"); + FileTreeNode node = fileService.createOrUpdateFile(TEST_PROJECT_NAME, parentPath.resolve("newFile.json").toString(), ""); assertNotNull(node); assertEquals("newFile.json", node.getName()); @@ -367,27 +349,6 @@ void createFile_Success() throws IOException, ProjectNotFoundException, ApiExcep assertTrue(Files.exists(tempProjectRoot.resolve("newFile.json")), "File must exist on disk after creation"); } - @Test - @DisplayName("Should create a file correctly when parent path already ends with a slash") - void createFile_ParentPathWithTrailingSlash_DoesNotDoubleSlash() - throws IOException, ProjectNotFoundException, ApiException { - stubToAbsolutePath(); - stubCreateFile(); - - Project project = - new Project(TEST_PROJECT_NAME, tempProjectRoot.toAbsolutePath().toString()); - when(projectService.getProject(TEST_PROJECT_NAME)).thenReturn(project); - - String parentPath = tempProjectRoot.toAbsolutePath() + "/"; - FileTreeNode node = fileTreeService.createFile(TEST_PROJECT_NAME, parentPath, "trailing.json"); - - assertNotNull(node); - assertEquals("trailing.json", node.getName()); - assertEquals(NodeType.FILE, node.getType()); - assertFalse(node.getPath().contains("//"), "Path must not contain double slashes"); - assertTrue(Files.exists(tempProjectRoot.resolve("trailing.json")), "File must exist on disk after creation"); - } - @Test @DisplayName("Should throw SecurityException when the file path is outside the project directory") void createFile_OutsideProject_ThrowsSecurityException() throws IOException, ProjectNotFoundException { @@ -400,7 +361,7 @@ void createFile_OutsideProject_ThrowsSecurityException() throws IOException, Pro String outsidePath = tempProjectRoot.getParent().toAbsolutePath().toString(); assertThrows( SecurityException.class, - () -> fileTreeService.createFile(TEST_PROJECT_NAME, outsidePath, "escape.json") + () -> fileService.createOrUpdateFile(TEST_PROJECT_NAME, outsidePath, "escape.json") ); } @@ -410,7 +371,7 @@ void createFile_ProjectNotFound_ThrowsIllegalArgument() throws ProjectNotFoundEx when(projectService.getProject("Unknown")).thenThrow(new ProjectNotFoundException("err")); assertThrows( - IllegalArgumentException.class, () -> fileTreeService.createFile("Unknown", "/some/path", "file.json")); + IllegalArgumentException.class, () -> fileService.createOrUpdateFile("Unknown", "/some/path", "file.json")); } @Test @@ -425,7 +386,7 @@ void createFile_ShouldDelegateToConfigurationService_WhenXml() throws Exception .thenReturn(project); when(projectService.getProject(TEST_PROJECT_NAME)).thenReturn(project); - FileTreeNode node = fileTreeService.createFile( + FileTreeNode node = fileService.createOrUpdateFile( TEST_PROJECT_NAME, tempProjectRoot.toAbsolutePath().toString(), "config.xml"); assertNotNull(node); @@ -442,8 +403,8 @@ void createFolder_Success() throws IOException, ProjectNotFoundException { new Project(TEST_PROJECT_NAME, tempProjectRoot.toAbsolutePath().toString()); when(projectService.getProject(TEST_PROJECT_NAME)).thenReturn(project); - String parentPath = tempProjectRoot.toAbsolutePath().toString(); - FileTreeNode node = fileTreeService.createFolder(TEST_PROJECT_NAME, parentPath, "newFolder"); + Path path = tempProjectRoot.toAbsolutePath().resolve("newFolder"); + FileTreeNode node = fileTreeService.createFolder(TEST_PROJECT_NAME, path.toString()); assertNotNull(node); assertEquals("newFolder", node.getName()); @@ -456,25 +417,15 @@ void createFolder_Success() throws IOException, ProjectNotFoundException { void createFolder_NullName_ThrowsIllegalArgument() { assertThrows( IllegalArgumentException.class, - () -> fileTreeService.createFolder(TEST_PROJECT_NAME, "/some/path", null) - ); - } - - @Test - @DisplayName("Should throw IllegalArgumentException when folder name contains a backslash") - void createFolder_NameWithBackslash_ThrowsIllegalArgument() { - assertThrows( - IllegalArgumentException.class, - () -> fileTreeService.createFolder(TEST_PROJECT_NAME, "/some/path", "bad\\folder") + () -> fileTreeService.createFolder(TEST_PROJECT_NAME, null) ); } @Test @DisplayName("Should rename a file and return a node with FILE type in local environment") - void renameFile_LocalEnvironment_File() throws IOException, ProjectNotFoundException { + void renameFile_LocalEnvironment_File() throws IOException, ApiException { stubToAbsolutePath(); stubRename(); - when(fileSystemStorage.isLocalEnvironment()).thenReturn(true); Project project = new Project(TEST_PROJECT_NAME, tempProjectRoot.toAbsolutePath().toString()); @@ -482,8 +433,9 @@ void renameFile_LocalEnvironment_File() throws IOException, ProjectNotFoundExcep Path oldFile = Files.writeString(tempProjectRoot.resolve("old.xml"), "content"); String oldPath = oldFile.toAbsolutePath().toString(); + String newPath = tempProjectRoot.resolve("new.xml").toAbsolutePath().toString(); - FileTreeNode node = fileTreeService.renameFile(TEST_PROJECT_NAME, oldPath, "new.xml"); + FileTreeNode node = fileService.renameFile(TEST_PROJECT_NAME, oldPath, newPath); assertEquals("new.xml", node.getName()); assertEquals(NodeType.FILE, node.getType()); @@ -493,10 +445,9 @@ void renameFile_LocalEnvironment_File() throws IOException, ProjectNotFoundExcep @Test @DisplayName("Should rename a directory and return a node with DIRECTORY type") - void renameFile_LocalEnvironment_Directory() throws IOException, ProjectNotFoundException { + void renameFile_LocalEnvironment_Directory() throws IOException, ApiException { stubToAbsolutePath(); stubRename(); - when(fileSystemStorage.isLocalEnvironment()).thenReturn(true); Project project = new Project(TEST_PROJECT_NAME, tempProjectRoot.toAbsolutePath().toString()); @@ -504,8 +455,9 @@ void renameFile_LocalEnvironment_Directory() throws IOException, ProjectNotFound Path oldDir = Files.createDirectory(tempProjectRoot.resolve("oldDir")); String oldPath = oldDir.toAbsolutePath().toString(); + String newPath = tempProjectRoot.resolve("newDir").toAbsolutePath().toString(); - FileTreeNode node = fileTreeService.renameFile(TEST_PROJECT_NAME, oldPath, "newDir"); + FileTreeNode node = fileService.renameFile(TEST_PROJECT_NAME, oldPath, newPath); assertEquals("newDir", node.getName()); assertEquals(NodeType.DIRECTORY, node.getType()); @@ -526,9 +478,10 @@ void renameFile_TargetAlreadyExists_ThrowsFileAlreadyExistsException() Files.writeString(tempProjectRoot.resolve("existing.xml"), "already here"); String oldPath = tempProjectRoot.resolve("old.xml").toAbsolutePath().toString(); + String newPath = tempProjectRoot.resolve("existing.xml").toAbsolutePath().toString(); assertThrows( - FileAlreadyExistsException.class, - () -> fileTreeService.renameFile(TEST_PROJECT_NAME, oldPath, "existing.xml") + ApiException.class, + () -> fileService.renameFile(TEST_PROJECT_NAME, oldPath, newPath) ); } @@ -537,48 +490,28 @@ void renameFile_TargetAlreadyExists_ThrowsFileAlreadyExistsException() void renameFile_InvalidNewName_ThrowsIllegalArgument() { assertThrows( IllegalArgumentException.class, - () -> fileTreeService.renameFile(TEST_PROJECT_NAME, "/some/old.xml", "bad/name.xml") + () -> fileService.renameFile(TEST_PROJECT_NAME, "/some/old.xml", "/some/../bad\\name.xml") ); } - @Test - @DisplayName("Should use relative parent path when old path contains a slash in non-local environment") - void renameFile_NonLocalEnvironment_PathWithSlash() throws IOException, ProjectNotFoundException { - stubToAbsolutePath(); - when(fileSystemStorage.isLocalEnvironment()).thenReturn(false); - when(fileSystemStorage.rename(anyString(), anyString())).thenReturn(null); - - Project project = - new Project(TEST_PROJECT_NAME, tempProjectRoot.toAbsolutePath().toString()); - when(projectService.getProject(TEST_PROJECT_NAME)).thenReturn(project); - - Path subDir = Files.createDirectory(tempProjectRoot.resolve("subdir")); - Files.writeString(subDir.resolve("old.xml"), "content"); - - FileTreeNode node = fileTreeService.renameFile(TEST_PROJECT_NAME, "subdir/old.xml", "new.xml"); - - assertEquals("new.xml", node.getName()); - assertEquals("subdir/new.xml", node.getPath()); - assertEquals(NodeType.FILE, node.getType()); - } - @Test @DisplayName("Should use just the new name as path when old path has no slash in non-local environment") - void renameFile_NonLocalEnvironment_PathWithoutSlash() throws IOException, ProjectNotFoundException { + void renameFile_NonLocalEnvironment_PathWithoutSlash() throws IOException, ApiException { stubToAbsolutePath(); - when(fileSystemStorage.isLocalEnvironment()).thenReturn(false); when(fileSystemStorage.rename(anyString(), anyString())).thenReturn(null); Project project = new Project(TEST_PROJECT_NAME, tempProjectRoot.toAbsolutePath().toString()); when(projectService.getProject(TEST_PROJECT_NAME)).thenReturn(project); - Files.writeString(tempProjectRoot.resolve("old.xml"), "content"); + Path oldPath = tempProjectRoot.resolve("old.xml"); + Path newPath = tempProjectRoot.resolve("new.xml"); + Files.writeString(oldPath, "content"); - FileTreeNode node = fileTreeService.renameFile(TEST_PROJECT_NAME, "old.xml", "new.xml"); + FileTreeNode node = fileService.renameFile(TEST_PROJECT_NAME, oldPath.toString(), newPath.toString()); assertEquals("new.xml", node.getName()); - assertEquals("new.xml", node.getPath()); + assertEquals(newPath.toString(), node.getPath()); } @Test @@ -594,7 +527,7 @@ void deleteFile_Success() throws IOException, ProjectNotFoundException { Path fileToDelete = Files.writeString(tempProjectRoot.resolve("toDelete.xml"), "content"); String path = fileToDelete.toAbsolutePath().toString(); - assertDoesNotThrow(() -> fileTreeService.deleteFile(TEST_PROJECT_NAME, path)); + assertDoesNotThrow(() -> fileService.deleteFile(TEST_PROJECT_NAME, path)); assertFalse(Files.exists(fileToDelete)); } @@ -604,7 +537,7 @@ void deleteFile_ProjectNotFound_ThrowsIllegalArgument() throws ProjectNotFoundEx when(projectService.getProject("Unknown")).thenThrow(new ProjectNotFoundException("err")); assertThrows( - IllegalArgumentException.class, () -> fileTreeService.deleteFile("Unknown", "/some/path/file.xml")); + IllegalArgumentException.class, () -> fileService.deleteFile("Unknown", "/some/path/file.xml")); } @Test From cd9ca9afe47ca1e1f03cf16a87557b277cd0eccc Mon Sep 17 00:00:00 2001 From: Vivy <4380412+Matthbo@users.noreply.github.com> Date: Wed, 1 Apr 2026 13:20:05 +0200 Subject: [PATCH 09/18] Disabled datamapper exception test --- .../flow/datamapper/DatamapperConfigService.java | 3 +-- .../flow/datamapper/DatamapperConfigServiceTest.java | 3 +++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/frankframework/flow/datamapper/DatamapperConfigService.java b/src/main/java/org/frankframework/flow/datamapper/DatamapperConfigService.java index d2c0f5ce..93bb422b 100644 --- a/src/main/java/org/frankframework/flow/datamapper/DatamapperConfigService.java +++ b/src/main/java/org/frankframework/flow/datamapper/DatamapperConfigService.java @@ -86,8 +86,7 @@ public String getConfig(String projectName) throws ConfigurationNotFoundExceptio } return fileSystemStorage.readFile(filePath); } catch (IOException e) { - throw new ConfigurationNotFoundException( - "Failed to resolve configuration file path for project: " + projectName); + throw new ConfigurationNotFoundException("Failed to resolve configuration file path for project: " + projectName); } } } diff --git a/src/test/java/org/frankframework/flow/datamapper/DatamapperConfigServiceTest.java b/src/test/java/org/frankframework/flow/datamapper/DatamapperConfigServiceTest.java index 4761dfe5..2af59e00 100644 --- a/src/test/java/org/frankframework/flow/datamapper/DatamapperConfigServiceTest.java +++ b/src/test/java/org/frankframework/flow/datamapper/DatamapperConfigServiceTest.java @@ -23,6 +23,7 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -112,7 +113,9 @@ public void readFileContent_Success() throws IOException, ConfigurationNotFoundE assertEquals(content, result); } + /* Disabled due to it never throwing NoSuchFileException and creating the file itself when needed */ @Test + @Disabled @DisplayName("Should throw NoSuchFileException when file does not exist") public void readFileContent_FileNotFound() throws IOException { stubReadFile(); From 4965ace4d67149747bf1fa739881d9643812fb15 Mon Sep 17 00:00:00 2001 From: Vivy <4380412+Matthbo@users.noreply.github.com> Date: Wed, 1 Apr 2026 13:27:28 +0200 Subject: [PATCH 10/18] Remove unused java imports --- .../java/org/frankframework/flow/file/FileController.java | 1 - src/main/java/org/frankframework/flow/file/FileService.java | 1 - .../java/org/frankframework/flow/file/FileTreeController.java | 4 ---- .../java/org/frankframework/flow/file/FileTreeService.java | 3 --- 4 files changed, 9 deletions(-) diff --git a/src/main/java/org/frankframework/flow/file/FileController.java b/src/main/java/org/frankframework/flow/file/FileController.java index 4bffcc9f..96b262fa 100644 --- a/src/main/java/org/frankframework/flow/file/FileController.java +++ b/src/main/java/org/frankframework/flow/file/FileController.java @@ -3,7 +3,6 @@ import org.frankframework.flow.exception.ApiException; import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; diff --git a/src/main/java/org/frankframework/flow/file/FileService.java b/src/main/java/org/frankframework/flow/file/FileService.java index 927053a7..1ed6c3eb 100644 --- a/src/main/java/org/frankframework/flow/file/FileService.java +++ b/src/main/java/org/frankframework/flow/file/FileService.java @@ -7,7 +7,6 @@ import org.frankframework.flow.project.ProjectNotFoundException; import org.frankframework.flow.project.ProjectService; -import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; diff --git a/src/main/java/org/frankframework/flow/file/FileTreeController.java b/src/main/java/org/frankframework/flow/file/FileTreeController.java index fee45097..3a5edc00 100644 --- a/src/main/java/org/frankframework/flow/file/FileTreeController.java +++ b/src/main/java/org/frankframework/flow/file/FileTreeController.java @@ -2,13 +2,9 @@ import java.io.IOException; -import org.frankframework.flow.exception.ApiException; - import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; diff --git a/src/main/java/org/frankframework/flow/file/FileTreeService.java b/src/main/java/org/frankframework/flow/file/FileTreeService.java index bcabb5bc..f73fb7a9 100644 --- a/src/main/java/org/frankframework/flow/file/FileTreeService.java +++ b/src/main/java/org/frankframework/flow/file/FileTreeService.java @@ -2,7 +2,6 @@ import java.io.IOException; import java.io.UncheckedIOException; -import java.nio.file.FileAlreadyExistsException; import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; @@ -14,8 +13,6 @@ import javax.xml.parsers.DocumentBuilder; -import org.frankframework.flow.configuration.ConfigurationService; -import org.frankframework.flow.exception.ApiException; import org.frankframework.flow.filesystem.FileSystemStorage; import org.frankframework.flow.project.Project; import org.frankframework.flow.project.ProjectNotFoundException; From 709d0f4bd85ced9f6ec77a5c19bf1600bfadcf1e Mon Sep 17 00:00:00 2001 From: Vivy <4380412+Matthbo@users.noreply.github.com> Date: Wed, 1 Apr 2026 13:29:36 +0200 Subject: [PATCH 11/18] Spotless apply --- .../flow/configuration/ConfigurationController.java | 10 +++------- .../flow/configuration/ConfigurationService.java | 13 +++++-------- .../flow/datamapper/DatamapperConfigService.java | 5 +---- .../flow/datamapper/DatamapperGeneratorService.java | 6 +----- .../frankframework/flow/file/FileController.java | 4 +--- .../org/frankframework/flow/file/FileService.java | 11 ++++------- .../flow/file/FileTreeController.java | 1 - .../org/frankframework/flow/file/FileTreeNode.java | 1 - .../frankframework/flow/file/FileTreeService.java | 3 --- .../filesystem/CloudFileSystemStorageService.java | 3 --- .../filesystem/LocalFileSystemStorageService.java | 1 - .../configuration/ConfigurationServiceTest.java | 1 - .../datamapper/DatamapperConfigServiceTest.java | 5 +---- .../datamapper/DatamapperGeneratorServiceTest.java | 8 ++------ .../flow/file/FileTreeServiceTest.java | 4 ---- 15 files changed, 18 insertions(+), 58 deletions(-) diff --git a/src/main/java/org/frankframework/flow/configuration/ConfigurationController.java b/src/main/java/org/frankframework/flow/configuration/ConfigurationController.java index c8c4a46a..15c038de 100644 --- a/src/main/java/org/frankframework/flow/configuration/ConfigurationController.java +++ b/src/main/java/org/frankframework/flow/configuration/ConfigurationController.java @@ -1,10 +1,11 @@ package org.frankframework.flow.configuration; import java.io.IOException; - import javax.xml.parsers.ParserConfigurationException; import javax.xml.transform.TransformerException; - +import lombok.extern.slf4j.Slf4j; +import org.frankframework.flow.exception.ApiException; +import org.frankframework.flow.xml.XmlDTO; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -16,11 +17,6 @@ import org.springframework.web.bind.annotation.RestController; import org.xml.sax.SAXException; -import lombok.extern.slf4j.Slf4j; - -import org.frankframework.flow.exception.ApiException; -import org.frankframework.flow.xml.XmlDTO; - @Slf4j @RestController @RequestMapping("/projects/{projectName}/configuration") diff --git a/src/main/java/org/frankframework/flow/configuration/ConfigurationService.java b/src/main/java/org/frankframework/flow/configuration/ConfigurationService.java index 5bdda94a..ceac98a6 100644 --- a/src/main/java/org/frankframework/flow/configuration/ConfigurationService.java +++ b/src/main/java/org/frankframework/flow/configuration/ConfigurationService.java @@ -4,21 +4,18 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; - import javax.xml.parsers.ParserConfigurationException; import javax.xml.transform.TransformerException; - -import org.springframework.core.io.ClassPathResource; -import org.springframework.http.HttpStatus; -import org.springframework.stereotype.Service; -import org.w3c.dom.Document; -import org.xml.sax.SAXException; - import org.frankframework.flow.exception.ApiException; import org.frankframework.flow.filesystem.FileSystemStorage; import org.frankframework.flow.project.Project; import org.frankframework.flow.project.ProjectService; import org.frankframework.flow.utility.XmlConfigurationUtils; +import org.springframework.core.io.ClassPathResource; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.w3c.dom.Document; +import org.xml.sax.SAXException; @Service public class ConfigurationService { diff --git a/src/main/java/org/frankframework/flow/datamapper/DatamapperConfigService.java b/src/main/java/org/frankframework/flow/datamapper/DatamapperConfigService.java index 93bb422b..23480984 100644 --- a/src/main/java/org/frankframework/flow/datamapper/DatamapperConfigService.java +++ b/src/main/java/org/frankframework/flow/datamapper/DatamapperConfigService.java @@ -3,13 +3,10 @@ import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; - import javax.naming.ConfigurationException; - import org.frankframework.flow.configuration.ConfigurationNotFoundException; -import org.frankframework.flow.filesystem.FileSystemStorage; import org.frankframework.flow.file.FileTreeService; - +import org.frankframework.flow.filesystem.FileSystemStorage; import org.springframework.stereotype.Service; @Service diff --git a/src/main/java/org/frankframework/flow/datamapper/DatamapperGeneratorService.java b/src/main/java/org/frankframework/flow/datamapper/DatamapperGeneratorService.java index 7de1dd53..35375d21 100644 --- a/src/main/java/org/frankframework/flow/datamapper/DatamapperGeneratorService.java +++ b/src/main/java/org/frankframework/flow/datamapper/DatamapperGeneratorService.java @@ -5,17 +5,13 @@ import java.net.URL; import java.nio.file.Files; import java.nio.file.Path; - import javax.xml.transform.stream.StreamSource; - import lombok.extern.slf4j.Slf4j; import net.sf.saxon.s9api.*; - import org.frankframework.flow.configuration.ConfigurationNotFoundException; import org.frankframework.flow.exception.ApiException; -import org.frankframework.flow.filesystem.FileSystemStorage; import org.frankframework.flow.file.FileTreeService; - +import org.frankframework.flow.filesystem.FileSystemStorage; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; diff --git a/src/main/java/org/frankframework/flow/file/FileController.java b/src/main/java/org/frankframework/flow/file/FileController.java index 96b262fa..c2acad02 100644 --- a/src/main/java/org/frankframework/flow/file/FileController.java +++ b/src/main/java/org/frankframework/flow/file/FileController.java @@ -1,7 +1,7 @@ package org.frankframework.flow.file; +import java.io.IOException; import org.frankframework.flow.exception.ApiException; - import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; @@ -13,8 +13,6 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -import java.io.IOException; - @RestController @RequestMapping("/projects/{projectName}/file") public class FileController { diff --git a/src/main/java/org/frankframework/flow/file/FileService.java b/src/main/java/org/frankframework/flow/file/FileService.java index 1ed6c3eb..71f956ab 100644 --- a/src/main/java/org/frankframework/flow/file/FileService.java +++ b/src/main/java/org/frankframework/flow/file/FileService.java @@ -1,20 +1,17 @@ package org.frankframework.flow.file; +import java.io.IOException; +import java.nio.file.FileAlreadyExistsException; +import java.nio.file.Files; +import java.nio.file.Path; import org.frankframework.flow.exception.ApiException; - import org.frankframework.flow.filesystem.FileSystemStorage; import org.frankframework.flow.project.Project; import org.frankframework.flow.project.ProjectNotFoundException; import org.frankframework.flow.project.ProjectService; - import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; -import java.io.IOException; -import java.nio.file.FileAlreadyExistsException; -import java.nio.file.Files; -import java.nio.file.Path; - @Service public class FileService { diff --git a/src/main/java/org/frankframework/flow/file/FileTreeController.java b/src/main/java/org/frankframework/flow/file/FileTreeController.java index 3a5edc00..dd7a364c 100644 --- a/src/main/java/org/frankframework/flow/file/FileTreeController.java +++ b/src/main/java/org/frankframework/flow/file/FileTreeController.java @@ -1,7 +1,6 @@ package org.frankframework.flow.file; import java.io.IOException; - import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; diff --git a/src/main/java/org/frankframework/flow/file/FileTreeNode.java b/src/main/java/org/frankframework/flow/file/FileTreeNode.java index ae00446a..ceecda78 100644 --- a/src/main/java/org/frankframework/flow/file/FileTreeNode.java +++ b/src/main/java/org/frankframework/flow/file/FileTreeNode.java @@ -1,7 +1,6 @@ package org.frankframework.flow.file; import java.util.List; - import lombok.Getter; import lombok.Setter; diff --git a/src/main/java/org/frankframework/flow/file/FileTreeService.java b/src/main/java/org/frankframework/flow/file/FileTreeService.java index f73fb7a9..f96d2c8e 100644 --- a/src/main/java/org/frankframework/flow/file/FileTreeService.java +++ b/src/main/java/org/frankframework/flow/file/FileTreeService.java @@ -10,15 +10,12 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.stream.Collectors; import java.util.stream.Stream; - import javax.xml.parsers.DocumentBuilder; - import org.frankframework.flow.filesystem.FileSystemStorage; import org.frankframework.flow.project.Project; import org.frankframework.flow.project.ProjectNotFoundException; import org.frankframework.flow.project.ProjectService; import org.frankframework.flow.utility.XmlSecurityUtils; - import org.springframework.stereotype.Service; import org.w3c.dom.Document; import org.w3c.dom.Element; diff --git a/src/main/java/org/frankframework/flow/filesystem/CloudFileSystemStorageService.java b/src/main/java/org/frankframework/flow/filesystem/CloudFileSystemStorageService.java index 43163a93..7fd2a65b 100644 --- a/src/main/java/org/frankframework/flow/filesystem/CloudFileSystemStorageService.java +++ b/src/main/java/org/frankframework/flow/filesystem/CloudFileSystemStorageService.java @@ -11,11 +11,8 @@ import java.util.Collections; import java.util.List; import java.util.stream.Stream; - import lombok.extern.slf4j.Slf4j; - import org.frankframework.flow.security.UserWorkspaceContext; - import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Service; diff --git a/src/main/java/org/frankframework/flow/filesystem/LocalFileSystemStorageService.java b/src/main/java/org/frankframework/flow/filesystem/LocalFileSystemStorageService.java index 5781d1f1..a19a214f 100644 --- a/src/main/java/org/frankframework/flow/filesystem/LocalFileSystemStorageService.java +++ b/src/main/java/org/frankframework/flow/filesystem/LocalFileSystemStorageService.java @@ -10,7 +10,6 @@ import java.util.ArrayList; import java.util.List; import java.util.stream.Stream; - import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Service; diff --git a/src/test/java/org/frankframework/flow/configuration/ConfigurationServiceTest.java b/src/test/java/org/frankframework/flow/configuration/ConfigurationServiceTest.java index 69729d9f..16cc1b79 100644 --- a/src/test/java/org/frankframework/flow/configuration/ConfigurationServiceTest.java +++ b/src/test/java/org/frankframework/flow/configuration/ConfigurationServiceTest.java @@ -8,7 +8,6 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; - import org.frankframework.flow.exception.ApiException; import org.frankframework.flow.filesystem.FileSystemStorage; import org.frankframework.flow.project.Project; diff --git a/src/test/java/org/frankframework/flow/datamapper/DatamapperConfigServiceTest.java b/src/test/java/org/frankframework/flow/datamapper/DatamapperConfigServiceTest.java index 2af59e00..0c736c4f 100644 --- a/src/test/java/org/frankframework/flow/datamapper/DatamapperConfigServiceTest.java +++ b/src/test/java/org/frankframework/flow/datamapper/DatamapperConfigServiceTest.java @@ -13,14 +13,11 @@ import java.nio.file.Path; import java.nio.file.Paths; import java.util.Comparator; - import javax.naming.ConfigurationException; - import org.frankframework.flow.configuration.ConfigurationNotFoundException; -import org.frankframework.flow.filesystem.FileSystemStorage; import org.frankframework.flow.file.FileTreeNode; import org.frankframework.flow.file.FileTreeService; - +import org.frankframework.flow.filesystem.FileSystemStorage; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Disabled; diff --git a/src/test/java/org/frankframework/flow/datamapper/DatamapperGeneratorServiceTest.java b/src/test/java/org/frankframework/flow/datamapper/DatamapperGeneratorServiceTest.java index 0bb4386e..e0939e5c 100644 --- a/src/test/java/org/frankframework/flow/datamapper/DatamapperGeneratorServiceTest.java +++ b/src/test/java/org/frankframework/flow/datamapper/DatamapperGeneratorServiceTest.java @@ -15,7 +15,6 @@ import java.nio.file.Paths; import java.nio.file.StandardCopyOption; import java.util.Comparator; - import javax.xml.parsers.DocumentBuilder; import javax.xml.parsers.DocumentBuilderFactory; import javax.xml.parsers.ParserConfigurationException; @@ -23,15 +22,12 @@ import javax.xml.transform.dom.DOMSource; import javax.xml.transform.stream.StreamResult; import javax.xml.transform.stream.StreamSource; - import net.sf.saxon.s9api.*; - import org.frankframework.flow.exception.ApiException; -import org.frankframework.flow.filesystem.FileOperations; -import org.frankframework.flow.filesystem.FileSystemStorage; import org.frankframework.flow.file.FileTreeNode; import org.frankframework.flow.file.FileTreeService; - +import org.frankframework.flow.filesystem.FileOperations; +import org.frankframework.flow.filesystem.FileSystemStorage; import org.junit.jupiter.api.*; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; diff --git a/src/test/java/org/frankframework/flow/file/FileTreeServiceTest.java b/src/test/java/org/frankframework/flow/file/FileTreeServiceTest.java index 54fefe4a..ad6468f2 100644 --- a/src/test/java/org/frankframework/flow/file/FileTreeServiceTest.java +++ b/src/test/java/org/frankframework/flow/file/FileTreeServiceTest.java @@ -3,17 +3,14 @@ import static org.junit.Assert.assertSame; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.*; import java.io.IOException; -import java.nio.file.FileAlreadyExistsException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.util.Comparator; import java.util.List; - import org.frankframework.flow.configuration.ConfigurationService; import org.frankframework.flow.exception.ApiException; import org.frankframework.flow.filesystem.FileOperations; @@ -21,7 +18,6 @@ import org.frankframework.flow.project.Project; import org.frankframework.flow.project.ProjectNotFoundException; import org.frankframework.flow.project.ProjectService; - import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; From 78dacdaaaaa26a7b2dc07aa8f13b40fdae41af4b Mon Sep 17 00:00:00 2001 From: Vivy <4380412+Matthbo@users.noreply.github.com> Date: Wed, 1 Apr 2026 14:59:53 +0200 Subject: [PATCH 12/18] Fixes & moving tests to FileServiceTest --- .../use-file-tree-context-menu.ts | 5 +- .../file-structure/use-studio-context-menu.ts | 6 +- .../frontend/app/routes/editor/editor.tsx | 49 +-- .../app/services/configuration-service.ts | 4 +- .../app/services/file-tree-service.ts | 6 +- .../flow/file/FileController.java | 7 +- .../frankframework/flow/file/FileService.java | 4 +- .../flow/file/FileTreeController.java | 5 +- .../ConfigurationControllerTest.java | 7 +- .../flow/file/FileServiceTest.java | 290 ++++++++++++++++++ .../flow/file/FileTreeServiceTest.java | 227 +------------- 11 files changed, 346 insertions(+), 264 deletions(-) create mode 100644 src/test/java/org/frankframework/flow/file/FileServiceTest.java 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 ad755557..5aeca618 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,6 +1,6 @@ import { useCallback, useRef, useState } from 'react' import type { TreeItemIndex } from 'react-complex-tree' -import { createFile, deleteFile, renameFile } from '~/services/file-service'; +import { createFile, deleteFile, renameFile } from '~/services/file-service' import { createFolderInProject } from '~/services/file-tree-service' import { clearConfigurationCache } from '~/services/configuration-service' import useTabStore from '~/stores/tab-store' @@ -113,7 +113,6 @@ export function useFileTreeContextMenu({ setNameDialog({ title: 'New File', onSubmit: async (name: string) => { - try { await createFile(projectName, `${parentPath}/${ensureXmlExtension(name)}`) await dataProvider.reloadDirectory(parentItemId) @@ -139,7 +138,7 @@ export function useFileTreeContextMenu({ title: 'New Folder', onSubmit: async (name: string) => { try { - await createFolderInProject(projectName, parentPath, name) + await createFolderInProject(projectName, `${parentPath}/${name}`) await dataProvider.reloadDirectory(parentItemId) } catch (error) { showErrorToastFrom('Failed to create folder', error) 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 af7f4a75..340bbc99 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,9 +1,7 @@ import { useCallback, useRef, useState } from 'react' import type { TreeItemIndex } from 'react-complex-tree' import { deleteFile, renameFile } from '~/services/file-service' -import { - createFolderInProject, -} from '~/services/file-tree-service' +import { createFolderInProject } from '~/services/file-tree-service' import { createAdapter, renameAdapter, deleteAdapter } from '~/services/adapter-service' import { clearConfigurationCache, createConfiguration } from '~/services/configuration-service' import useTabStore from '~/stores/tab-store' @@ -198,7 +196,7 @@ export function useStudioContextMenu({ projectName, dataProvider }: UseStudioCon title: 'New Folder', onSubmit: async (name: string) => { try { - await createFolderInProject(projectName, menu.folderPath, name) + await createFolderInProject(projectName, `${menu.folderPath}/${name}`) await dataProvider.reloadDirectory('root') } catch (error) { showErrorToastFrom('Failed to create folder', error) diff --git a/src/main/frontend/app/routes/editor/editor.tsx b/src/main/frontend/app/routes/editor/editor.tsx index 9e9025c9..bee2992d 100644 --- a/src/main/frontend/app/routes/editor/editor.tsx +++ b/src/main/frontend/app/routes/editor/editor.tsx @@ -1,29 +1,33 @@ +import RulerCrossPenIcon from '/icons/solar/Ruler Cross Pen.svg?react' import Editor, { type Monaco, type OnMount } from '@monaco-editor/react' -import XsdManager from 'monaco-xsd-code-completion/esm/XsdManager' +import clsx from 'clsx' import XsdFeatures from 'monaco-xsd-code-completion/esm/XsdFeatures' import 'monaco-xsd-code-completion/src/style.css' +import XsdManager from 'monaco-xsd-code-completion/esm/XsdManager' +import { useCallback, useEffect, useRef, useState } from 'react' import { validateXML, type XMLValidationError } from 'xmllint-wasm' import { useShallow } from 'zustand/react/shallow' -import SidebarLayout from '~/components/sidebars-layout/sidebar-layout' +import { openInStudio } from '~/actions/navigationActions' +import EditorFileStructure from '~/components/file-structure/editor-file-structure' +import DiffTabView from '~/components/git/diff-tab-view' +import GitPanel from '~/components/git/git-panel' +import Button from '~/components/inputs/button' import SidebarContentClose from '~/components/sidebars-layout/sidebar-content-close' +import SidebarHeader from '~/components/sidebars-layout/sidebar-header' +import SidebarLayout from '~/components/sidebars-layout/sidebar-layout' import { SidebarSide } from '~/components/sidebars-layout/sidebar-layout-store' -import { useTheme } from '~/hooks/use-theme' -import { useCallback, useEffect, useRef, useState } from 'react' -import { fetchFile } from '~/services/file-service' -import { useProjectStore } from '~/stores/project-store' -import EditorFileStructure from '~/components/file-structure/editor-file-structure' -import useEditorTabStore from '~/stores/editor-tab-store' import EditorTabs from '~/components/tabs/editor-tabs' -import { fetchConfiguration, saveConfiguration } from '~/services/configuration-service' -import { fetchFrankConfigXsd } from '~/services/xsd-service' -import RulerCrossPenIcon from '/icons/solar/Ruler Cross Pen.svg?react' -import { openInStudio } from '~/actions/navigationActions' -import Button from '~/components/inputs/button' import { showErrorToastFrom } from '~/components/toast' -import GitPanel from '~/components/git/git-panel' -import DiffTabView from '~/components/git/diff-tab-view' -import clsx from 'clsx' +import { useTheme } from '~/hooks/use-theme' +import { fetchConfiguration, saveConfiguration } from '~/services/configuration-service' +import { fetchFile } from '~/services/file-service' import { refreshOpenDiffs } from '~/services/git-service' +import { fetchFrankConfigXsd } from '~/services/xsd-service' +import useEditorTabStore from '~/stores/editor-tab-store' +import { useProjectStore } from '~/stores/project-store' +import { useSettingsStore } from '~/stores/settings-store' +import { toProjectRelativePath } from '~/utils/path-utils' +import flowXsd from '../../../src/assets/xsd/FlowConfig.xsd?raw' import { extractFlowElements, findAdapterIndexAtOffset, @@ -31,12 +35,8 @@ import { findFlowElementsStartLine, lineToOffset, normalizeFrankElements, - wrapFlowXml, + wrapFlowXml } from './xml-utils' -import { useSettingsStore } from '~/stores/settings-store' -import { toProjectRelativePath } from '~/utils/path-utils' -import SidebarHeader from '~/components/sidebars-layout/sidebar-header' -import flowXsd from '../../../src/assets/xsd/FlowConfig.xsd?raw' type LeftTab = 'files' | 'git' type SaveStatus = 'idle' | 'saving' | 'saved' @@ -150,6 +150,11 @@ function toMarker(e: ValidationError, severity: number) { } } +function toMonacoType(type: string | null) { + if (!type || type === 'text/plain') return 'plaintext' + return type.split('/').pop() ?? '' +} + export default function CodeEditor() { const theme = useTheme() const project = useProjectStore.getState().project @@ -459,7 +464,7 @@ export default function CodeEditor() { } else { fetchFile(project.name, filePath, abortController.signal) .then(({ content, type }) => { - const fileType = type ? type.split('/')[1] : '' + const fileType = toMonacoType(type) setMonacoContent(content, fileType, abortController.signal) }) .catch((error) => { diff --git a/src/main/frontend/app/services/configuration-service.ts b/src/main/frontend/app/services/configuration-service.ts index 8eeea889..84d0a8a3 100644 --- a/src/main/frontend/app/services/configuration-service.ts +++ b/src/main/frontend/app/services/configuration-service.ts @@ -34,12 +34,12 @@ export async function fetchConfiguration(projectName: string, filepath: string, export async function saveConfiguration(projectName: string, filepath: string, content: string): Promise { return apiFetch(`${getBaseUrl(projectName)}?path=${encodeURIComponent(filepath)}`, { method: 'PUT', - body: JSON.stringify({ filepath, content }), + body: content, }) } export async function createConfiguration(projectName: string, filename: string): Promise { - return apiFetch(`${getBaseUrl(projectName)}?path=${encodeURIComponent(filename)}`, { method: 'POST' }) + return apiFetch(`${getBaseUrl(projectName)}?name=${encodeURIComponent(filename)}`, { method: 'POST' }) } function getBaseUrl(projectName: string): string { diff --git a/src/main/frontend/app/services/file-tree-service.ts b/src/main/frontend/app/services/file-tree-service.ts index 8712e000..2a331666 100644 --- a/src/main/frontend/app/services/file-tree-service.ts +++ b/src/main/frontend/app/services/file-tree-service.ts @@ -18,15 +18,15 @@ export async function fetchDirectoryByPath( path: string, signal?: AbortSignal, ): Promise { - return apiFetch(`${getBaseUrl(projectName)}?path=${encodeURIComponent(path)}`, { + return apiFetch(`${getTreeUrl(projectName)}/directory?path=${encodeURIComponent(path)}`, { signal, }) } -export async function createFolderInProject(projectName: string, path: string, name: string): Promise { +export async function createFolderInProject(projectName: string, path: string): Promise { return apiFetch(`${getBaseUrl(projectName)}/folder`, { method: 'POST', - body: JSON.stringify({ path, name }), + body: JSON.stringify({ path }), }) } diff --git a/src/main/java/org/frankframework/flow/file/FileController.java b/src/main/java/org/frankframework/flow/file/FileController.java index c2acad02..bfbb7575 100644 --- a/src/main/java/org/frankframework/flow/file/FileController.java +++ b/src/main/java/org/frankframework/flow/file/FileController.java @@ -1,13 +1,16 @@ package org.frankframework.flow.file; import java.io.IOException; + import org.frankframework.flow.exception.ApiException; + import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; @@ -29,12 +32,12 @@ public ResponseEntity getFile(@PathVariable String projectName, @Reques return ResponseEntity.ok(file); } - @PostMapping() + @PutMapping() public ResponseEntity createOrUpdateFile( @PathVariable String projectName, @RequestParam String path, @RequestBody String fileContent - ) throws IOException, ApiException { + ) throws ApiException { FileTreeNode node = fileService.createOrUpdateFile(projectName, path, fileContent); return ResponseEntity.status(HttpStatus.CREATED.value()).body(node); } diff --git a/src/main/java/org/frankframework/flow/file/FileService.java b/src/main/java/org/frankframework/flow/file/FileService.java index 71f956ab..5956b2bd 100644 --- a/src/main/java/org/frankframework/flow/file/FileService.java +++ b/src/main/java/org/frankframework/flow/file/FileService.java @@ -4,11 +4,13 @@ import java.nio.file.FileAlreadyExistsException; import java.nio.file.Files; import java.nio.file.Path; + import org.frankframework.flow.exception.ApiException; import org.frankframework.flow.filesystem.FileSystemStorage; import org.frankframework.flow.project.Project; import org.frankframework.flow.project.ProjectNotFoundException; import org.frankframework.flow.project.ProjectService; + import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; @@ -116,7 +118,7 @@ protected void validatePath(String path) throws IllegalArgumentException { if (path == null || path.isBlank()) { throw new IllegalArgumentException("File path must not be empty"); } - if (path.contains("..")) { + if (path.contains("..") || path.contains("\0")) { throw new IllegalArgumentException("File path contains invalid characters: " + path); } } diff --git a/src/main/java/org/frankframework/flow/file/FileTreeController.java b/src/main/java/org/frankframework/flow/file/FileTreeController.java index dd7a364c..e85ee3a9 100644 --- a/src/main/java/org/frankframework/flow/file/FileTreeController.java +++ b/src/main/java/org/frankframework/flow/file/FileTreeController.java @@ -1,6 +1,7 @@ package org.frankframework.flow.file; import java.io.IOException; + import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; @@ -47,8 +48,8 @@ public FileTreeNode getDirectoryContent( } @PostMapping("/folder") - public ResponseEntity createFolder(@PathVariable String projectName, @RequestBody FolderCreateDTO folderCreate) throws IOException { - FileTreeNode node = fileTreeService.createFolder(projectName, folderCreate.path()); + public ResponseEntity createFolder(@PathVariable String projectName, @RequestBody FolderCreateDTO dto) throws IOException { + FileTreeNode node = fileTreeService.createFolder(projectName, dto.path()); return ResponseEntity.status(HttpStatus.CREATED.value()).body(node); } } diff --git a/src/test/java/org/frankframework/flow/configuration/ConfigurationControllerTest.java b/src/test/java/org/frankframework/flow/configuration/ConfigurationControllerTest.java index f2266164..d5a32cc0 100644 --- a/src/test/java/org/frankframework/flow/configuration/ConfigurationControllerTest.java +++ b/src/test/java/org/frankframework/flow/configuration/ConfigurationControllerTest.java @@ -8,12 +8,14 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; + import org.frankframework.flow.filesystem.FileSystemStorage; import org.frankframework.flow.project.Project; import org.frankframework.flow.project.ProjectDTO; import org.frankframework.flow.project.ProjectService; import org.frankframework.flow.projectsettings.FilterType; import org.frankframework.flow.projectsettings.ProjectSettings; + import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.Mockito; @@ -149,12 +151,13 @@ void addConfigurationReturnsProjectDto() throws Exception { List.of("config1.xml"), Map.of(FilterType.ADAPTER, true), false, - false)); + false + )); mockMvc.perform(post("/api/projects/" + TEST_PROJECT_NAME + "/configuration?name=NewConfig.xml") .accept(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) - .andExpect(jsonPath("xmlContent").value("")); + .andExpect(jsonPath("$.xmlContent").value("")); verify(configurationService).addConfiguration(TEST_PROJECT_NAME, "NewConfig.xml"); } diff --git a/src/test/java/org/frankframework/flow/file/FileServiceTest.java b/src/test/java/org/frankframework/flow/file/FileServiceTest.java new file mode 100644 index 00000000..82d28c6b --- /dev/null +++ b/src/test/java/org/frankframework/flow/file/FileServiceTest.java @@ -0,0 +1,290 @@ +package org.frankframework.flow.file; + +import org.frankframework.flow.exception.ApiException; +import org.frankframework.flow.filesystem.FileOperations; +import org.frankframework.flow.filesystem.FileSystemStorage; +import org.frankframework.flow.project.Project; +import org.frankframework.flow.project.ProjectNotFoundException; +import org.frankframework.flow.project.ProjectService; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Comparator; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class FileServiceTest { + + @Mock + private ProjectService projectService; + + @Mock + private FileSystemStorage fileSystemStorage; + + private FileService fileService; + private Path tempProjectRoot; + private static final String TEST_PROJECT_NAME = "FrankFlowTestProject"; + + @BeforeEach + public void setUp() throws IOException { + tempProjectRoot = Files.createTempDirectory("flow_unit_test"); + fileService = new FileService(projectService, fileSystemStorage); + } + + @AfterEach + public void tearDown() throws IOException { + if (tempProjectRoot != null && Files.exists(tempProjectRoot)) { + try (var stream = Files.walk(tempProjectRoot)) { + stream.sorted(Comparator.reverseOrder()).forEach(p -> { + try { + Files.delete(p); + } catch (IOException ignored) { + } + }); + } + } + } + + @Test + @DisplayName("Should throw IllegalArgumentException for a null file name") + void createFile_NullName_ThrowsIllegalArgument() { + assertThrows( + IllegalArgumentException.class, + () -> fileService.createOrUpdateFile(TEST_PROJECT_NAME, null, "") + ); + } + + @Test + @DisplayName("Should throw IllegalArgumentException for a blank file name") + void createFile_BlankName_ThrowsIllegalArgument() { + assertThrows( + IllegalArgumentException.class, + () -> fileService.createOrUpdateFile(TEST_PROJECT_NAME, "/some/path/\0", "") + ); + } + + @Test + @DisplayName("Should throw IllegalArgumentException when file name is double dots") + void createFile_NameWithDoubleDots_ThrowsIllegalArgument() { + assertThrows( + IllegalArgumentException.class, + () -> fileService.createOrUpdateFile(TEST_PROJECT_NAME, "/some/path/..", "") + ); + } + + @Test + @DisplayName("Should create a file and return a FileTreeNode with FILE type") + void createFile_Success() throws IOException, ApiException { + stubToAbsolutePath(); + stubCreateFile(); + + Path parentPath = tempProjectRoot.toAbsolutePath(); + Project project = new Project(TEST_PROJECT_NAME, parentPath.toString()); + when(projectService.getProject(TEST_PROJECT_NAME)).thenReturn(project); + + FileTreeNode node = fileService.createOrUpdateFile(TEST_PROJECT_NAME, parentPath.resolve("newFile.json").toString(), ""); + + assertNotNull(node); + assertEquals("newFile.json", node.getName()); + assertEquals(NodeType.FILE, node.getType()); + assertTrue(node.getPath().endsWith("newFile.json")); + assertTrue(Files.exists(tempProjectRoot.resolve("newFile.json")), "File must exist on disk after creation"); + } + + @Test + @DisplayName("Should throw SecurityException when the file path is outside the project directory") + void createFile_OutsideProject_ThrowsSecurityException() throws IOException, ProjectNotFoundException { + stubToAbsolutePath(); + + Project project = + new Project(TEST_PROJECT_NAME, tempProjectRoot.toAbsolutePath().toString()); + when(projectService.getProject(TEST_PROJECT_NAME)).thenReturn(project); + + String outsidePath = tempProjectRoot.getParent().toAbsolutePath().toString(); + assertThrows( + SecurityException.class, + () -> fileService.createOrUpdateFile(TEST_PROJECT_NAME, outsidePath, "escape.json") + ); + } + + @Test + @DisplayName("Should throw IllegalArgumentException when project is not found during createFile") + void createFile_ProjectNotFound_ThrowsIllegalArgument() throws ProjectNotFoundException { + when(projectService.getProject("Unknown")).thenThrow(new ProjectNotFoundException("err")); + + assertThrows( + IllegalArgumentException.class, () -> fileService.createOrUpdateFile("Unknown", "/some/path", "file.json")); + } + + + @Test + @DisplayName("Should rename a file and return a node with FILE type in local environment") + void renameFile_LocalEnvironment_File() throws IOException, ApiException { + stubToAbsolutePath(); + stubRename(); + + Project project = + new Project(TEST_PROJECT_NAME, tempProjectRoot.toAbsolutePath().toString()); + when(projectService.getProject(TEST_PROJECT_NAME)).thenReturn(project); + + Path oldFile = Files.writeString(tempProjectRoot.resolve("old.xml"), "content"); + String oldPath = oldFile.toAbsolutePath().toString(); + String newPath = tempProjectRoot.resolve("new.xml").toAbsolutePath().toString(); + + FileTreeNode node = fileService.renameFile(TEST_PROJECT_NAME, oldPath, newPath); + + assertEquals("new.xml", node.getName()); + assertEquals(NodeType.FILE, node.getType()); + assertTrue(Files.exists(tempProjectRoot.resolve("new.xml"))); + assertFalse(Files.exists(oldFile)); + } + + @Test + @DisplayName("Should rename a directory and return a node with DIRECTORY type") + void renameFile_LocalEnvironment_Directory() throws IOException, ApiException { + stubToAbsolutePath(); + stubRename(); + + Project project = + new Project(TEST_PROJECT_NAME, tempProjectRoot.toAbsolutePath().toString()); + when(projectService.getProject(TEST_PROJECT_NAME)).thenReturn(project); + + Path oldDir = Files.createDirectory(tempProjectRoot.resolve("oldDir")); + String oldPath = oldDir.toAbsolutePath().toString(); + String newPath = tempProjectRoot.resolve("newDir").toAbsolutePath().toString(); + + FileTreeNode node = fileService.renameFile(TEST_PROJECT_NAME, oldPath, newPath); + + assertEquals("newDir", node.getName()); + assertEquals(NodeType.DIRECTORY, node.getType()); + assertTrue(Files.exists(tempProjectRoot.resolve("newDir"))); + } + + @Test + @DisplayName("Should throw FileAlreadyExistsException when the target name already exists") + void renameFile_TargetAlreadyExists_ThrowsFileAlreadyExistsException() + throws IOException, ProjectNotFoundException { + stubToAbsolutePath(); + + Project project = + new Project(TEST_PROJECT_NAME, tempProjectRoot.toAbsolutePath().toString()); + when(projectService.getProject(TEST_PROJECT_NAME)).thenReturn(project); + + Files.writeString(tempProjectRoot.resolve("old.xml"), "content"); + Files.writeString(tempProjectRoot.resolve("existing.xml"), "already here"); + + String oldPath = tempProjectRoot.resolve("old.xml").toAbsolutePath().toString(); + String newPath = tempProjectRoot.resolve("existing.xml").toAbsolutePath().toString(); + assertThrows( + ApiException.class, + () -> fileService.renameFile(TEST_PROJECT_NAME, oldPath, newPath) + ); + } + + @Test + @DisplayName("Should throw IllegalArgumentException for an invalid new name during rename") + void renameFile_InvalidNewName_ThrowsIllegalArgument() { + assertThrows( + IllegalArgumentException.class, + () -> fileService.renameFile(TEST_PROJECT_NAME, "/some/old.xml", "/some/../bad\\name.xml") + ); + } + + @Test + @DisplayName("Should use just the new name as path when old path has no slash in non-local environment") + void renameFile_NonLocalEnvironment_PathWithoutSlash() throws IOException, ApiException { + stubToAbsolutePath(); + when(fileSystemStorage.rename(anyString(), anyString())).thenReturn(null); + + Project project = + new Project(TEST_PROJECT_NAME, tempProjectRoot.toAbsolutePath().toString()); + when(projectService.getProject(TEST_PROJECT_NAME)).thenReturn(project); + + Path oldPath = tempProjectRoot.resolve("old.xml"); + Path newPath = tempProjectRoot.resolve("new.xml"); + Files.writeString(oldPath, "content"); + + FileTreeNode node = fileService.renameFile(TEST_PROJECT_NAME, oldPath.toString(), newPath.toString()); + + assertEquals("new.xml", node.getName()); + assertEquals(newPath.toString(), node.getPath()); + } + + @Test + @DisplayName("Should delete a file without throwing and invalidate the tree cache") + void deleteFile_Success() throws IOException, ProjectNotFoundException { + stubToAbsolutePath(); + stubDelete(); + + Project project = + new Project(TEST_PROJECT_NAME, tempProjectRoot.toAbsolutePath().toString()); + when(projectService.getProject(TEST_PROJECT_NAME)).thenReturn(project); + + Path fileToDelete = Files.writeString(tempProjectRoot.resolve("toDelete.xml"), "content"); + String path = fileToDelete.toAbsolutePath().toString(); + + assertDoesNotThrow(() -> fileService.deleteFile(TEST_PROJECT_NAME, path)); + assertFalse(Files.exists(fileToDelete)); + } + + @Test + @DisplayName("Should throw IllegalArgumentException when project is not found during deleteFile") + void deleteFile_ProjectNotFound_ThrowsIllegalArgument() throws ProjectNotFoundException { + when(projectService.getProject("Unknown")).thenThrow(new ProjectNotFoundException("err")); + + assertThrows( + IllegalArgumentException.class, () -> fileService.deleteFile("Unknown", "/some/path/file.xml")); + } + + private void stubCreateFile() throws IOException { + when(fileSystemStorage.createFile(anyString())).thenAnswer(invocation -> { + String path = invocation.getArgument(0); + return FileOperations.createFile(Paths.get(path)); + }); + } + + private void stubDelete() throws IOException { + doAnswer(invocation -> { + String path = invocation.getArgument(0); + FileOperations.deleteRecursively(Paths.get(path)); + return null; + }) + .when(fileSystemStorage) + .delete(anyString()); + } + + private void stubRename() throws IOException { + when(fileSystemStorage.rename(anyString(), anyString())).thenAnswer(invocation -> { + String src = invocation.getArgument(0); + String dst = invocation.getArgument(1); + return FileOperations.rename(Paths.get(src), Paths.get(dst)); + }); + } + + private void stubToAbsolutePath() { + when(fileSystemStorage.toAbsolutePath(anyString())).thenAnswer(invocation -> { + String path = invocation.getArgument(0); + Path p = Paths.get(path); + return p.isAbsolute() ? p : tempProjectRoot.resolve(p).normalize(); + }); + } +} diff --git a/src/test/java/org/frankframework/flow/file/FileTreeServiceTest.java b/src/test/java/org/frankframework/flow/file/FileTreeServiceTest.java index ad6468f2..f81b270e 100644 --- a/src/test/java/org/frankframework/flow/file/FileTreeServiceTest.java +++ b/src/test/java/org/frankframework/flow/file/FileTreeServiceTest.java @@ -11,13 +11,13 @@ import java.nio.file.Paths; import java.util.Comparator; import java.util.List; -import org.frankframework.flow.configuration.ConfigurationService; -import org.frankframework.flow.exception.ApiException; + import org.frankframework.flow.filesystem.FileOperations; import org.frankframework.flow.filesystem.FileSystemStorage; import org.frankframework.flow.project.Project; import org.frankframework.flow.project.ProjectNotFoundException; import org.frankframework.flow.project.ProjectService; + import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -35,18 +35,14 @@ public class FileTreeServiceTest { @Mock private FileSystemStorage fileSystemStorage; - @Mock - private ConfigurationService configurationService; - - private FileService fileService; private FileTreeService fileTreeService; private Path tempProjectRoot; private static final String TEST_PROJECT_NAME = "FrankFlowTestProject"; @BeforeEach public void setUp() throws IOException { + FileService fileService = new FileService(projectService, fileSystemStorage); tempProjectRoot = Files.createTempDirectory("flow_unit_test"); - fileService = new FileService(projectService, fileSystemStorage); fileTreeService = new FileTreeService(projectService, fileSystemStorage, fileService); } @@ -64,7 +60,7 @@ public void tearDown() throws IOException { } } - private void stubToAbsolutePath() throws IOException { + private void stubToAbsolutePath() { when(fileSystemStorage.toAbsolutePath(anyString())).thenAnswer(invocation -> { String path = invocation.getArgument(0); Path p = Paths.get(path); @@ -299,77 +295,6 @@ public void getConfigurationsDirectoryTreeThrowsIfProjectDoesNotExist() throws P assertTrue(ex.getMessage().contains("Configurations directory does not exist")); } - @Test - @DisplayName("Should throw IllegalArgumentException for a null file name") - void createFile_NullName_ThrowsIllegalArgument() { - assertThrows( - IllegalArgumentException.class, - () -> fileService.createOrUpdateFile(TEST_PROJECT_NAME, null, "") - ); - } - - @Test - @DisplayName("Should throw IllegalArgumentException for a blank file name") - void createFile_BlankName_ThrowsIllegalArgument() { - assertThrows( - IllegalArgumentException.class, - () -> fileService.createOrUpdateFile(TEST_PROJECT_NAME, "/some/path/ ", "") - ); - } - - @Test - @DisplayName("Should throw IllegalArgumentException when file name is double dots") - void createFile_NameWithDoubleDots_ThrowsIllegalArgument() { - assertThrows( - IllegalArgumentException.class, - () -> fileService.createOrUpdateFile(TEST_PROJECT_NAME, "/some/path/..", "") - ); - } - - @Test - @DisplayName("Should create a file and return a FileTreeNode with FILE type") - void createFile_Success() throws IOException, ApiException { - stubToAbsolutePath(); - stubCreateFile(); - - Path parentPath = tempProjectRoot.toAbsolutePath(); - Project project = new Project(TEST_PROJECT_NAME, parentPath.toString()); - when(projectService.getProject(TEST_PROJECT_NAME)).thenReturn(project); - - FileTreeNode node = fileService.createOrUpdateFile(TEST_PROJECT_NAME, parentPath.resolve("newFile.json").toString(), ""); - - assertNotNull(node); - assertEquals("newFile.json", node.getName()); - assertEquals(NodeType.FILE, node.getType()); - assertTrue(node.getPath().endsWith("newFile.json")); - assertTrue(Files.exists(tempProjectRoot.resolve("newFile.json")), "File must exist on disk after creation"); - } - - @Test - @DisplayName("Should throw SecurityException when the file path is outside the project directory") - void createFile_OutsideProject_ThrowsSecurityException() throws IOException, ProjectNotFoundException { - stubToAbsolutePath(); - - Project project = - new Project(TEST_PROJECT_NAME, tempProjectRoot.toAbsolutePath().toString()); - when(projectService.getProject(TEST_PROJECT_NAME)).thenReturn(project); - - String outsidePath = tempProjectRoot.getParent().toAbsolutePath().toString(); - assertThrows( - SecurityException.class, - () -> fileService.createOrUpdateFile(TEST_PROJECT_NAME, outsidePath, "escape.json") - ); - } - - @Test - @DisplayName("Should throw IllegalArgumentException when project is not found during createFile") - void createFile_ProjectNotFound_ThrowsIllegalArgument() throws ProjectNotFoundException { - when(projectService.getProject("Unknown")).thenThrow(new ProjectNotFoundException("err")); - - assertThrows( - IllegalArgumentException.class, () -> fileService.createOrUpdateFile("Unknown", "/some/path", "file.json")); - } - @Test @DisplayName("Should delegate to ConfigurationService when creating an .xml file") void createFile_ShouldDelegateToConfigurationService_WhenXml() throws Exception { @@ -417,125 +342,6 @@ void createFolder_NullName_ThrowsIllegalArgument() { ); } - @Test - @DisplayName("Should rename a file and return a node with FILE type in local environment") - void renameFile_LocalEnvironment_File() throws IOException, ApiException { - stubToAbsolutePath(); - stubRename(); - - Project project = - new Project(TEST_PROJECT_NAME, tempProjectRoot.toAbsolutePath().toString()); - when(projectService.getProject(TEST_PROJECT_NAME)).thenReturn(project); - - Path oldFile = Files.writeString(tempProjectRoot.resolve("old.xml"), "content"); - String oldPath = oldFile.toAbsolutePath().toString(); - String newPath = tempProjectRoot.resolve("new.xml").toAbsolutePath().toString(); - - FileTreeNode node = fileService.renameFile(TEST_PROJECT_NAME, oldPath, newPath); - - assertEquals("new.xml", node.getName()); - assertEquals(NodeType.FILE, node.getType()); - assertTrue(Files.exists(tempProjectRoot.resolve("new.xml"))); - assertFalse(Files.exists(oldFile)); - } - - @Test - @DisplayName("Should rename a directory and return a node with DIRECTORY type") - void renameFile_LocalEnvironment_Directory() throws IOException, ApiException { - stubToAbsolutePath(); - stubRename(); - - Project project = - new Project(TEST_PROJECT_NAME, tempProjectRoot.toAbsolutePath().toString()); - when(projectService.getProject(TEST_PROJECT_NAME)).thenReturn(project); - - Path oldDir = Files.createDirectory(tempProjectRoot.resolve("oldDir")); - String oldPath = oldDir.toAbsolutePath().toString(); - String newPath = tempProjectRoot.resolve("newDir").toAbsolutePath().toString(); - - FileTreeNode node = fileService.renameFile(TEST_PROJECT_NAME, oldPath, newPath); - - assertEquals("newDir", node.getName()); - assertEquals(NodeType.DIRECTORY, node.getType()); - assertTrue(Files.exists(tempProjectRoot.resolve("newDir"))); - } - - @Test - @DisplayName("Should throw FileAlreadyExistsException when the target name already exists") - void renameFile_TargetAlreadyExists_ThrowsFileAlreadyExistsException() - throws IOException, ProjectNotFoundException { - stubToAbsolutePath(); - - Project project = - new Project(TEST_PROJECT_NAME, tempProjectRoot.toAbsolutePath().toString()); - when(projectService.getProject(TEST_PROJECT_NAME)).thenReturn(project); - - Files.writeString(tempProjectRoot.resolve("old.xml"), "content"); - Files.writeString(tempProjectRoot.resolve("existing.xml"), "already here"); - - String oldPath = tempProjectRoot.resolve("old.xml").toAbsolutePath().toString(); - String newPath = tempProjectRoot.resolve("existing.xml").toAbsolutePath().toString(); - assertThrows( - ApiException.class, - () -> fileService.renameFile(TEST_PROJECT_NAME, oldPath, newPath) - ); - } - - @Test - @DisplayName("Should throw IllegalArgumentException for an invalid new name during rename") - void renameFile_InvalidNewName_ThrowsIllegalArgument() { - assertThrows( - IllegalArgumentException.class, - () -> fileService.renameFile(TEST_PROJECT_NAME, "/some/old.xml", "/some/../bad\\name.xml") - ); - } - - @Test - @DisplayName("Should use just the new name as path when old path has no slash in non-local environment") - void renameFile_NonLocalEnvironment_PathWithoutSlash() throws IOException, ApiException { - stubToAbsolutePath(); - when(fileSystemStorage.rename(anyString(), anyString())).thenReturn(null); - - Project project = - new Project(TEST_PROJECT_NAME, tempProjectRoot.toAbsolutePath().toString()); - when(projectService.getProject(TEST_PROJECT_NAME)).thenReturn(project); - - Path oldPath = tempProjectRoot.resolve("old.xml"); - Path newPath = tempProjectRoot.resolve("new.xml"); - Files.writeString(oldPath, "content"); - - FileTreeNode node = fileService.renameFile(TEST_PROJECT_NAME, oldPath.toString(), newPath.toString()); - - assertEquals("new.xml", node.getName()); - assertEquals(newPath.toString(), node.getPath()); - } - - @Test - @DisplayName("Should delete a file without throwing and invalidate the tree cache") - void deleteFile_Success() throws IOException, ProjectNotFoundException { - stubToAbsolutePath(); - stubDelete(); - - Project project = - new Project(TEST_PROJECT_NAME, tempProjectRoot.toAbsolutePath().toString()); - when(projectService.getProject(TEST_PROJECT_NAME)).thenReturn(project); - - Path fileToDelete = Files.writeString(tempProjectRoot.resolve("toDelete.xml"), "content"); - String path = fileToDelete.toAbsolutePath().toString(); - - assertDoesNotThrow(() -> fileService.deleteFile(TEST_PROJECT_NAME, path)); - assertFalse(Files.exists(fileToDelete)); - } - - @Test - @DisplayName("Should throw IllegalArgumentException when project is not found during deleteFile") - void deleteFile_ProjectNotFound_ThrowsIllegalArgument() throws ProjectNotFoundException { - when(projectService.getProject("Unknown")).thenThrow(new ProjectNotFoundException("err")); - - assertThrows( - IllegalArgumentException.class, () -> fileService.deleteFile("Unknown", "/some/path/file.xml")); - } - @Test @DisplayName("Should clear all cache entries so the tree is fully rebuilt on the next call") void invalidateTreeCache_AllEntries_ForcesRebuild() throws IOException, ProjectNotFoundException { @@ -654,29 +460,4 @@ private void stubCreateProjectDirectory() throws IOException { return dir; }); } - - private void stubCreateFile() throws IOException { - when(fileSystemStorage.createFile(anyString())).thenAnswer(invocation -> { - String path = invocation.getArgument(0); - return FileOperations.createFile(Paths.get(path)); - }); - } - - private void stubDelete() throws IOException { - doAnswer(invocation -> { - String path = invocation.getArgument(0); - FileOperations.deleteRecursively(Paths.get(path)); - return null; - }) - .when(fileSystemStorage) - .delete(anyString()); - } - - private void stubRename() throws IOException { - when(fileSystemStorage.rename(anyString(), anyString())).thenAnswer(invocation -> { - String src = invocation.getArgument(0); - String dst = invocation.getArgument(1); - return FileOperations.rename(Paths.get(src), Paths.get(dst)); - }); - } } From 1e7720df46994439b565deec886df289f23ed3c6 Mon Sep 17 00:00:00 2001 From: Vivy <4380412+Matthbo@users.noreply.github.com> Date: Wed, 1 Apr 2026 15:00:41 +0200 Subject: [PATCH 13/18] More spotless apply --- .../flow/file/FileController.java | 3 -- .../frankframework/flow/file/FileService.java | 2 -- .../flow/file/FileTreeController.java | 1 - .../ConfigurationControllerTest.java | 2 -- .../flow/file/FileServiceTest.java | 32 +++++++++---------- .../flow/file/FileTreeServiceTest.java | 3 -- 6 files changed, 15 insertions(+), 28 deletions(-) diff --git a/src/main/java/org/frankframework/flow/file/FileController.java b/src/main/java/org/frankframework/flow/file/FileController.java index bfbb7575..f6b9839a 100644 --- a/src/main/java/org/frankframework/flow/file/FileController.java +++ b/src/main/java/org/frankframework/flow/file/FileController.java @@ -1,9 +1,6 @@ package org.frankframework.flow.file; -import java.io.IOException; - import org.frankframework.flow.exception.ApiException; - import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; diff --git a/src/main/java/org/frankframework/flow/file/FileService.java b/src/main/java/org/frankframework/flow/file/FileService.java index 5956b2bd..8d41ede3 100644 --- a/src/main/java/org/frankframework/flow/file/FileService.java +++ b/src/main/java/org/frankframework/flow/file/FileService.java @@ -4,13 +4,11 @@ import java.nio.file.FileAlreadyExistsException; import java.nio.file.Files; import java.nio.file.Path; - import org.frankframework.flow.exception.ApiException; import org.frankframework.flow.filesystem.FileSystemStorage; import org.frankframework.flow.project.Project; import org.frankframework.flow.project.ProjectNotFoundException; import org.frankframework.flow.project.ProjectService; - import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; diff --git a/src/main/java/org/frankframework/flow/file/FileTreeController.java b/src/main/java/org/frankframework/flow/file/FileTreeController.java index e85ee3a9..cc34fc44 100644 --- a/src/main/java/org/frankframework/flow/file/FileTreeController.java +++ b/src/main/java/org/frankframework/flow/file/FileTreeController.java @@ -1,7 +1,6 @@ package org.frankframework.flow.file; import java.io.IOException; - import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; diff --git a/src/test/java/org/frankframework/flow/configuration/ConfigurationControllerTest.java b/src/test/java/org/frankframework/flow/configuration/ConfigurationControllerTest.java index d5a32cc0..15e8e7dc 100644 --- a/src/test/java/org/frankframework/flow/configuration/ConfigurationControllerTest.java +++ b/src/test/java/org/frankframework/flow/configuration/ConfigurationControllerTest.java @@ -8,14 +8,12 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; - import org.frankframework.flow.filesystem.FileSystemStorage; import org.frankframework.flow.project.Project; import org.frankframework.flow.project.ProjectDTO; import org.frankframework.flow.project.ProjectService; import org.frankframework.flow.projectsettings.FilterType; import org.frankframework.flow.projectsettings.ProjectSettings; - import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.Mockito; diff --git a/src/test/java/org/frankframework/flow/file/FileServiceTest.java b/src/test/java/org/frankframework/flow/file/FileServiceTest.java index 82d28c6b..64ce9eda 100644 --- a/src/test/java/org/frankframework/flow/file/FileServiceTest.java +++ b/src/test/java/org/frankframework/flow/file/FileServiceTest.java @@ -1,12 +1,26 @@ package org.frankframework.flow.file; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Comparator; import org.frankframework.flow.exception.ApiException; import org.frankframework.flow.filesystem.FileOperations; import org.frankframework.flow.filesystem.FileSystemStorage; import org.frankframework.flow.project.Project; import org.frankframework.flow.project.ProjectNotFoundException; import org.frankframework.flow.project.ProjectService; - import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -15,22 +29,6 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.util.Comparator; - -import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.Mockito.doAnswer; -import static org.mockito.Mockito.when; - @ExtendWith(MockitoExtension.class) class FileServiceTest { diff --git a/src/test/java/org/frankframework/flow/file/FileTreeServiceTest.java b/src/test/java/org/frankframework/flow/file/FileTreeServiceTest.java index f81b270e..37febaac 100644 --- a/src/test/java/org/frankframework/flow/file/FileTreeServiceTest.java +++ b/src/test/java/org/frankframework/flow/file/FileTreeServiceTest.java @@ -11,13 +11,10 @@ import java.nio.file.Paths; import java.util.Comparator; import java.util.List; - -import org.frankframework.flow.filesystem.FileOperations; import org.frankframework.flow.filesystem.FileSystemStorage; import org.frankframework.flow.project.Project; import org.frankframework.flow.project.ProjectNotFoundException; import org.frankframework.flow.project.ProjectService; - import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; From 3d5dcfa7d2240801368ff59566547fce1ce5b858 Mon Sep 17 00:00:00 2001 From: Vivy <4380412+Matthbo@users.noreply.github.com> Date: Wed, 1 Apr 2026 16:11:56 +0200 Subject: [PATCH 14/18] More improvements --- .../use-file-tree-context-menu.ts | 2 +- .../frontend/app/routes/editor/editor.tsx | 53 +++++++++++++------ .../frankframework/flow/file/FileService.java | 12 +++-- .../flow/file/FileTreeController.java | 3 +- .../flow/file/FileTreeService.java | 3 +- .../flow/file/FileServiceTest.java | 4 +- .../flow/file/FileTreeServiceTest.java | 3 +- 7 files changed, 53 insertions(+), 27 deletions(-) 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 5aeca618..d19ab122 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 @@ -168,7 +168,7 @@ export function useFileTreeContextMenu({ return } try { - await renameFile(projectName, oldPath, newName) + await renameFile(projectName, `${oldPath}/${oldName}`, `${oldPath}/${newName}`) clearConfigurationCache(projectName, oldPath) const newPath = buildNewPath(oldPath, newName) useTabStore.getState().renameTabsForConfig(oldPath, newPath) diff --git a/src/main/frontend/app/routes/editor/editor.tsx b/src/main/frontend/app/routes/editor/editor.tsx index bee2992d..e6901778 100644 --- a/src/main/frontend/app/routes/editor/editor.tsx +++ b/src/main/frontend/app/routes/editor/editor.tsx @@ -20,7 +20,7 @@ import EditorTabs from '~/components/tabs/editor-tabs' import { showErrorToastFrom } from '~/components/toast' import { useTheme } from '~/hooks/use-theme' import { fetchConfiguration, saveConfiguration } from '~/services/configuration-service' -import { fetchFile } from '~/services/file-service' +import { fetchFile, updateFile } from '~/services/file-service' import { refreshOpenDiffs } from '~/services/git-service' import { fetchFrankConfigXsd } from '~/services/xsd-service' import useEditorTabStore from '~/stores/editor-tab-store' @@ -155,6 +155,10 @@ function toMonacoType(type: string | null) { return type.split('/').pop() ?? '' } +function isConfigurationFile(fileExtension: string) { + return fileExtension === 'xml' +} + export default function CodeEditor() { const theme = useTheme() const project = useProjectStore.getState().project @@ -198,23 +202,38 @@ export default function CodeEditor() { const updatedContent = content ?? editorReference.current?.getValue?.() if (!updatedContent) return - const configPath = useEditorTabStore.getState().getTab(activeTabFilePath)?.configurationPath + const activeTab = useEditorTabStore.getState().getTab(activeTabFilePath) + const fileExtension = activeTab?.name.split('.').pop()?.toLowerCase() + const configPath = activeTab?.configurationPath if (!configPath) return + function finishSaving() { + setSaveStatus('saved') + if (savedTimerRef.current) clearTimeout(savedTimerRef.current) + savedTimerRef.current = setTimeout(() => setSaveStatus('idle'), SAVED_DISPLAY_DURATION) + } + setSaveStatus('saving') - saveConfiguration(project.name, configPath, updatedContent) - .then(({ xmlContent }) => { - setFileContent(xmlContent) - contentCacheRef.current.set(activeTabFilePath, { type: 'xml', content: xmlContent }) - setSaveStatus('saved') - if (savedTimerRef.current) clearTimeout(savedTimerRef.current) - savedTimerRef.current = setTimeout(() => setSaveStatus('idle'), SAVED_DISPLAY_DURATION) - if (project.isGitRepository) refreshOpenDiffs(project.name) - }) - .catch((error) => { - showErrorToastFrom('Error saving', error) - setSaveStatus('idle') - }) + if (isConfigurationFile(fileExtension ?? '')) { + saveConfiguration(project.name, configPath, updatedContent) + .then(({ xmlContent }) => { + setFileContent(xmlContent) + contentCacheRef.current.set(activeTabFilePath, { type: 'xml', content: xmlContent }) + finishSaving() + if (project.isGitRepository) refreshOpenDiffs(project.name) + }) + .catch((error) => { + showErrorToastFrom('Error saving', error) + setSaveStatus('idle') + }) + } else { + updateFile(project.name, configPath, updatedContent) + .then(() => finishSaving()) + .catch((error) => { + showErrorToastFrom('Error saving', error) + setSaveStatus('idle') + }) + } }, [project, activeTabFilePath, isDiffTab], ) @@ -453,7 +472,7 @@ export default function CodeEditor() { } } - if (fileExtension === 'xml') { + if (isConfigurationFile(fileExtension ?? '')) { fetchConfiguration(project.name, filePath, abortController.signal) .then((content) => setMonacoContent(content, 'xml', abortController.signal)) .catch((error) => { @@ -469,8 +488,8 @@ export default function CodeEditor() { }) .catch((error) => { if (!abortController.signal.aborted) { + setMonacoContent('', 'plaintext', abortController.signal) console.error('Failed to load file:', error) - setMonacoContent('', '') } }) } diff --git a/src/main/java/org/frankframework/flow/file/FileService.java b/src/main/java/org/frankframework/flow/file/FileService.java index 8d41ede3..4f0e8213 100644 --- a/src/main/java/org/frankframework/flow/file/FileService.java +++ b/src/main/java/org/frankframework/flow/file/FileService.java @@ -40,11 +40,14 @@ public FileDTO readFile(String projectName, String path) throws ApiException { public FileTreeNode createOrUpdateFile(String projectName, String path, String fileContent) throws ApiException { validatePath(path); - String fileName = Path.of(path).getFileName().toString(); + Path filePath = Path.of(path); + String fileName = filePath.getFileName().toString(); try { validateWithinProject(projectName, path); - fileSystemStorage.createFile(path); + if (!Files.exists(filePath)) { + fileSystemStorage.createFile(path); + } fileSystemStorage.writeFile(path, fileContent); } catch (IOException exception) { throw new ApiException("Failed to write file: " + exception.getMessage(), HttpStatus.INTERNAL_SERVER_ERROR); @@ -60,6 +63,7 @@ public FileTreeNode createOrUpdateFile(String projectName, String path, String f } public FileTreeNode renameFile(String projectName, String oldPath, String newPath) throws ApiException { + validatePath(oldPath); validatePath(newPath); String newFileName = Path.of(newPath).getFileName().toString(); Path absoluteNewPath = fileSystemStorage.toAbsolutePath(newPath); @@ -98,14 +102,14 @@ public void deleteFile(String projectName, String path) throws ApiException { // invalidateTreeCache(projectName); } - public void validateWithinProject(String projectName, String path) throws IOException { + public void validateWithinProject(String projectName, String path) throws IOException, ApiException { try { Project project = projectService.getProject(projectName); Path projectPath = fileSystemStorage.toAbsolutePath(project.getRootPath()); Path targetPath = fileSystemStorage.toAbsolutePath(path).normalize(); if (!targetPath.startsWith(projectPath)) { - throw new SecurityException("Path is outside project directory"); + throw new ApiException("Path is outside project directory", HttpStatus.FORBIDDEN); } } catch (ProjectNotFoundException e) { throw new IllegalArgumentException("Project does not exist: " + projectName); diff --git a/src/main/java/org/frankframework/flow/file/FileTreeController.java b/src/main/java/org/frankframework/flow/file/FileTreeController.java index cc34fc44..4654ad68 100644 --- a/src/main/java/org/frankframework/flow/file/FileTreeController.java +++ b/src/main/java/org/frankframework/flow/file/FileTreeController.java @@ -1,6 +1,7 @@ package org.frankframework.flow.file; import java.io.IOException; +import org.frankframework.flow.exception.ApiException; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; @@ -47,7 +48,7 @@ public FileTreeNode getDirectoryContent( } @PostMapping("/folder") - public ResponseEntity createFolder(@PathVariable String projectName, @RequestBody FolderCreateDTO dto) throws IOException { + public ResponseEntity createFolder(@PathVariable String projectName, @RequestBody FolderCreateDTO dto) throws IOException, ApiException { FileTreeNode node = fileTreeService.createFolder(projectName, dto.path()); return ResponseEntity.status(HttpStatus.CREATED.value()).body(node); } diff --git a/src/main/java/org/frankframework/flow/file/FileTreeService.java b/src/main/java/org/frankframework/flow/file/FileTreeService.java index f96d2c8e..b7142e0e 100644 --- a/src/main/java/org/frankframework/flow/file/FileTreeService.java +++ b/src/main/java/org/frankframework/flow/file/FileTreeService.java @@ -11,6 +11,7 @@ import java.util.stream.Collectors; import java.util.stream.Stream; import javax.xml.parsers.DocumentBuilder; +import org.frankframework.flow.exception.ApiException; import org.frankframework.flow.filesystem.FileSystemStorage; import org.frankframework.flow.project.Project; import org.frankframework.flow.project.ProjectNotFoundException; @@ -124,7 +125,7 @@ public FileTreeNode getConfigurationsDirectoryTree(String projectName) throws IO } } - public FileTreeNode createFolder(String projectName, String path) throws IOException { + public FileTreeNode createFolder(String projectName, String path) throws IOException, ApiException { validatePath(path); fileService.validateWithinProject(projectName, path); fileSystemStorage.createProjectDirectory(path); diff --git a/src/test/java/org/frankframework/flow/file/FileServiceTest.java b/src/test/java/org/frankframework/flow/file/FileServiceTest.java index 64ce9eda..bfc986e2 100644 --- a/src/test/java/org/frankframework/flow/file/FileServiceTest.java +++ b/src/test/java/org/frankframework/flow/file/FileServiceTest.java @@ -110,7 +110,7 @@ void createFile_Success() throws IOException, ApiException { @Test @DisplayName("Should throw SecurityException when the file path is outside the project directory") - void createFile_OutsideProject_ThrowsSecurityException() throws IOException, ProjectNotFoundException { + void createFile_OutsideProject_ThrowsSecurityException() throws ProjectNotFoundException { stubToAbsolutePath(); Project project = @@ -119,7 +119,7 @@ void createFile_OutsideProject_ThrowsSecurityException() throws IOException, Pro String outsidePath = tempProjectRoot.getParent().toAbsolutePath().toString(); assertThrows( - SecurityException.class, + ApiException.class, () -> fileService.createOrUpdateFile(TEST_PROJECT_NAME, outsidePath, "escape.json") ); } diff --git a/src/test/java/org/frankframework/flow/file/FileTreeServiceTest.java b/src/test/java/org/frankframework/flow/file/FileTreeServiceTest.java index 37febaac..38740ec0 100644 --- a/src/test/java/org/frankframework/flow/file/FileTreeServiceTest.java +++ b/src/test/java/org/frankframework/flow/file/FileTreeServiceTest.java @@ -11,6 +11,7 @@ import java.nio.file.Paths; import java.util.Comparator; import java.util.List; +import org.frankframework.flow.exception.ApiException; import org.frankframework.flow.filesystem.FileSystemStorage; import org.frankframework.flow.project.Project; import org.frankframework.flow.project.ProjectNotFoundException; @@ -313,7 +314,7 @@ void createFile_ShouldDelegateToConfigurationService_WhenXml() throws Exception @Test @DisplayName("Should create a folder and return a FileTreeNode with DIRECTORY type") - void createFolder_Success() throws IOException, ProjectNotFoundException { + void createFolder_Success() throws IOException, ApiException { stubToAbsolutePath(); stubCreateProjectDirectory(); From 728d958845c474d24437b13f86bbf1c7e4372d33 Mon Sep 17 00:00:00 2001 From: Vivy <4380412+Matthbo@users.noreply.github.com> Date: Thu, 2 Apr 2026 12:52:54 +0200 Subject: [PATCH 15/18] Cleanup using sonarcloud comments --- .../frontend/app/routes/editor/editor.tsx | 2 +- .../frankframework/flow/file/FileService.java | 4 ++- .../flow/file/FileTreeNode.java | 3 +- .../flow/file/FileTreeService.java | 5 ++- .../ConfigurationServiceTest.java | 32 +++++++++++-------- .../DatamapperConfigServiceTest.java | 8 +++-- .../DatamapperGeneratorServiceTest.java | 14 ++++++-- .../flow/file/FileServiceTest.java | 3 ++ .../flow/file/FileTreeServiceTest.java | 23 ++++++------- 9 files changed, 57 insertions(+), 37 deletions(-) diff --git a/src/main/frontend/app/routes/editor/editor.tsx b/src/main/frontend/app/routes/editor/editor.tsx index e6901778..6962942b 100644 --- a/src/main/frontend/app/routes/editor/editor.tsx +++ b/src/main/frontend/app/routes/editor/editor.tsx @@ -35,7 +35,7 @@ import { findFlowElementsStartLine, lineToOffset, normalizeFrankElements, - wrapFlowXml + wrapFlowXml, } from './xml-utils' type LeftTab = 'files' | 'git' diff --git a/src/main/java/org/frankframework/flow/file/FileService.java b/src/main/java/org/frankframework/flow/file/FileService.java index 4f0e8213..3ce76940 100644 --- a/src/main/java/org/frankframework/flow/file/FileService.java +++ b/src/main/java/org/frankframework/flow/file/FileService.java @@ -4,11 +4,13 @@ import java.nio.file.FileAlreadyExistsException; import java.nio.file.Files; import java.nio.file.Path; + import org.frankframework.flow.exception.ApiException; import org.frankframework.flow.filesystem.FileSystemStorage; import org.frankframework.flow.project.Project; import org.frankframework.flow.project.ProjectNotFoundException; import org.frankframework.flow.project.ProjectService; + import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; @@ -102,7 +104,7 @@ public void deleteFile(String projectName, String path) throws ApiException { // invalidateTreeCache(projectName); } - public void validateWithinProject(String projectName, String path) throws IOException, ApiException { + public void validateWithinProject(String projectName, String path) throws ApiException { try { Project project = projectService.getProject(projectName); Path projectPath = fileSystemStorage.toAbsolutePath(project.getRootPath()); diff --git a/src/main/java/org/frankframework/flow/file/FileTreeNode.java b/src/main/java/org/frankframework/flow/file/FileTreeNode.java index ceecda78..a50cb574 100644 --- a/src/main/java/org/frankframework/flow/file/FileTreeNode.java +++ b/src/main/java/org/frankframework/flow/file/FileTreeNode.java @@ -1,6 +1,7 @@ package org.frankframework.flow.file; import java.util.List; + import lombok.Getter; import lombok.Setter; @@ -13,6 +14,4 @@ public class FileTreeNode { private boolean projectRoot; private List children; private List adapterNames; - - public FileTreeNode() {} } diff --git a/src/main/java/org/frankframework/flow/file/FileTreeService.java b/src/main/java/org/frankframework/flow/file/FileTreeService.java index b7142e0e..42d20e79 100644 --- a/src/main/java/org/frankframework/flow/file/FileTreeService.java +++ b/src/main/java/org/frankframework/flow/file/FileTreeService.java @@ -10,13 +10,16 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.stream.Collectors; import java.util.stream.Stream; + import javax.xml.parsers.DocumentBuilder; + import org.frankframework.flow.exception.ApiException; import org.frankframework.flow.filesystem.FileSystemStorage; import org.frankframework.flow.project.Project; import org.frankframework.flow.project.ProjectNotFoundException; import org.frankframework.flow.project.ProjectService; import org.frankframework.flow.utility.XmlSecurityUtils; + import org.springframework.stereotype.Service; import org.w3c.dom.Document; import org.w3c.dom.Element; @@ -163,7 +166,7 @@ private FileTreeNode buildTree(Path path, Path relativizeRoot, boolean useRelati throw new UncheckedIOException(e); } }) - .collect(Collectors.toList()); + .toList(); node.setChildren(children); } diff --git a/src/test/java/org/frankframework/flow/configuration/ConfigurationServiceTest.java b/src/test/java/org/frankframework/flow/configuration/ConfigurationServiceTest.java index 16cc1b79..587c06af 100644 --- a/src/test/java/org/frankframework/flow/configuration/ConfigurationServiceTest.java +++ b/src/test/java/org/frankframework/flow/configuration/ConfigurationServiceTest.java @@ -8,11 +8,13 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; + import org.frankframework.flow.exception.ApiException; import org.frankframework.flow.filesystem.FileSystemStorage; import org.frankframework.flow.project.Project; import org.frankframework.flow.project.ProjectNotFoundException; import org.frankframework.flow.project.ProjectService; + import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -56,13 +58,13 @@ private void stubReadFile() throws IOException { private void stubWriteFile() throws IOException { doAnswer(invocation -> { - String path = invocation.getArgument(0); - String content = invocation.getArgument(1); - Path filePath = Path.of(path); - Files.createDirectories(filePath.getParent()); - Files.writeString(filePath, content, StandardCharsets.UTF_8); - return null; - }) + String path = invocation.getArgument(0); + String content = invocation.getArgument(1); + Path filePath = Path.of(path); + Files.createDirectories(filePath.getParent()); + Files.writeString(filePath, content, StandardCharsets.UTF_8); + return null; + }) .when(fileSystemStorage) .writeFile(anyString(), anyString()); } @@ -81,7 +83,7 @@ void getConfigurationContent_Success() throws Exception { } @Test - void getConfigurationContent_FileNotFound_ThrowsConfigurationNotFoundException() throws IOException { + void getConfigurationContent_FileNotFound_ThrowsConfigurationNotFoundException() { stubToAbsolutePath(); String path = tempDir.resolve("missing.xml").toString(); @@ -97,7 +99,8 @@ void getConfigurationContent_IsDirectory_ThrowsConfigurationNotFoundException() assertThrows( ApiException.class, - () -> configurationService.getConfigurationContent("test", dir.toString())); + () -> configurationService.getConfigurationContent("test", dir.toString()) + ); } @Test @@ -115,14 +118,15 @@ void updateConfiguration_Success() throws Exception { } @Test - void updateConfiguration_FileNotFound_ThrowsConfigurationNotFoundException() throws IOException { + void updateConfiguration_FileNotFound_ThrowsConfigurationNotFoundException() { stubToAbsolutePath(); String path = tempDir.resolve("missing.xml").toString(); assertThrows( ApiException.class, - () -> configurationService.updateConfiguration("test", path, "")); + () -> configurationService.updateConfiguration("test", path, "") + ); } @Test @@ -199,7 +203,8 @@ void addConfigurationToFolder_AlreadyExists_ThrowsException() throws Exception { assertThrows( ConfigurationAlreadyExistsException.class, () -> configurationService.addConfigurationToFolder( - "myproject", "existing.xml", projectDir.toString())); + "myproject", "existing.xml", projectDir.toString()) + ); } @Test @@ -216,6 +221,7 @@ void addConfigurationToFolder_OutsideProject_ThrowsSecurityException() throws Ex assertThrows( SecurityException.class, - () -> configurationService.addConfigurationToFolder("myproject", "evil.xml", outsidePath)); + () -> configurationService.addConfigurationToFolder("myproject", "evil.xml", outsidePath) + ); } } diff --git a/src/test/java/org/frankframework/flow/datamapper/DatamapperConfigServiceTest.java b/src/test/java/org/frankframework/flow/datamapper/DatamapperConfigServiceTest.java index 0c736c4f..a0ab98cd 100644 --- a/src/test/java/org/frankframework/flow/datamapper/DatamapperConfigServiceTest.java +++ b/src/test/java/org/frankframework/flow/datamapper/DatamapperConfigServiceTest.java @@ -13,11 +13,14 @@ import java.nio.file.Path; import java.nio.file.Paths; import java.util.Comparator; + import javax.naming.ConfigurationException; + import org.frankframework.flow.configuration.ConfigurationNotFoundException; import org.frankframework.flow.file.FileTreeNode; import org.frankframework.flow.file.FileTreeService; import org.frankframework.flow.filesystem.FileSystemStorage; + import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Disabled; @@ -56,7 +59,7 @@ public void tearDown() throws IOException { } } - private void stubToAbsolutePath() throws IOException { + private void stubToAbsolutePath() { when(fileSystemStorage.toAbsolutePath(anyString())).thenAnswer(invocation -> { String path = invocation.getArgument(0); Path p = Paths.get(path); @@ -110,9 +113,8 @@ public void readFileContent_Success() throws IOException, ConfigurationNotFoundE assertEquals(content, result); } - /* Disabled due to it never throwing NoSuchFileException and creating the file itself when needed */ @Test - @Disabled + @Disabled("Disabled due to it never throwing NoSuchFileException and creating the file itself when needed") @DisplayName("Should throw NoSuchFileException when file does not exist") public void readFileContent_FileNotFound() throws IOException { stubReadFile(); diff --git a/src/test/java/org/frankframework/flow/datamapper/DatamapperGeneratorServiceTest.java b/src/test/java/org/frankframework/flow/datamapper/DatamapperGeneratorServiceTest.java index e0939e5c..0bb530fb 100644 --- a/src/test/java/org/frankframework/flow/datamapper/DatamapperGeneratorServiceTest.java +++ b/src/test/java/org/frankframework/flow/datamapper/DatamapperGeneratorServiceTest.java @@ -15,6 +15,7 @@ import java.nio.file.Paths; import java.nio.file.StandardCopyOption; import java.util.Comparator; + import javax.xml.parsers.DocumentBuilder; import javax.xml.parsers.DocumentBuilderFactory; import javax.xml.parsers.ParserConfigurationException; @@ -22,12 +23,15 @@ import javax.xml.transform.dom.DOMSource; import javax.xml.transform.stream.StreamResult; import javax.xml.transform.stream.StreamSource; + import net.sf.saxon.s9api.*; + import org.frankframework.flow.exception.ApiException; import org.frankframework.flow.file.FileTreeNode; import org.frankframework.flow.file.FileTreeService; import org.frankframework.flow.filesystem.FileOperations; import org.frankframework.flow.filesystem.FileSystemStorage; + import org.junit.jupiter.api.*; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; @@ -51,7 +55,7 @@ public class DatamapperGeneratorServiceTest { private Path tempProjectRoot; - private void stubToAbsolutePath() throws IOException { + private void stubToAbsolutePath() { when(fileSystemStorage.toAbsolutePath(anyString())).thenAnswer(invocation -> { String path = invocation.getArgument(0); return Paths.get(path); @@ -202,7 +206,8 @@ public void testXMLtoXMLWithAttributesGeneratedMapping() stubToAbsolutePath(); service.generate( "src/test/resources/datamapper/inputXmlToXmlWithArrayWithAttributes.json", - tempProjectRoot.toAbsolutePath() + "/output.xslt"); + tempProjectRoot.toAbsolutePath() + "/output.xslt" + ); XsltExecutable executable = compiler.compile(new StreamSource(new File(tempProjectRoot.toAbsolutePath() + "/output.xslt"))); @@ -220,6 +225,7 @@ public void testXMLtoXMLWithAttributesGeneratedMapping() Assertions.assertEquals( toString(expectedResult).trim(), writer.toString().trim()); } + @Test @DisplayName("Test All functions") public void testAllFunctionsGeneratedMapping() @@ -228,7 +234,8 @@ public void testAllFunctionsGeneratedMapping() stubToAbsolutePath(); service.generate( "src/test/resources/datamapper/generationFileTestAllFunctions.json", - tempProjectRoot.toAbsolutePath() + "/output.xslt"); + tempProjectRoot.toAbsolutePath() + "/output.xslt" + ); XsltExecutable executable = compiler.compile(new StreamSource(new File(tempProjectRoot.toAbsolutePath() + "/output.xslt"))); @@ -246,6 +253,7 @@ public void testAllFunctionsGeneratedMapping() Assertions.assertEquals( toString(expectedResult).trim(), writer.toString().trim()); } + @Test @DisplayName("Test XML to Json mapping") public void testXMLtoJSONGeneratedMapping() throws SaxonApiException, IOException, ApiException { diff --git a/src/test/java/org/frankframework/flow/file/FileServiceTest.java b/src/test/java/org/frankframework/flow/file/FileServiceTest.java index bfc986e2..996b76df 100644 --- a/src/test/java/org/frankframework/flow/file/FileServiceTest.java +++ b/src/test/java/org/frankframework/flow/file/FileServiceTest.java @@ -15,12 +15,14 @@ import java.nio.file.Path; import java.nio.file.Paths; import java.util.Comparator; + import org.frankframework.flow.exception.ApiException; import org.frankframework.flow.filesystem.FileOperations; import org.frankframework.flow.filesystem.FileSystemStorage; import org.frankframework.flow.project.Project; import org.frankframework.flow.project.ProjectNotFoundException; import org.frankframework.flow.project.ProjectService; + import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -56,6 +58,7 @@ public void tearDown() throws IOException { try { Files.delete(p); } catch (IOException ignored) { + // Ignored to ensure cleanup continues even if some files couldn't be removed } }); } diff --git a/src/test/java/org/frankframework/flow/file/FileTreeServiceTest.java b/src/test/java/org/frankframework/flow/file/FileTreeServiceTest.java index 38740ec0..ea8d3ad3 100644 --- a/src/test/java/org/frankframework/flow/file/FileTreeServiceTest.java +++ b/src/test/java/org/frankframework/flow/file/FileTreeServiceTest.java @@ -11,11 +11,13 @@ import java.nio.file.Paths; import java.util.Comparator; import java.util.List; + import org.frankframework.flow.exception.ApiException; import org.frankframework.flow.filesystem.FileSystemStorage; import org.frankframework.flow.project.Project; import org.frankframework.flow.project.ProjectNotFoundException; import org.frankframework.flow.project.ProjectService; + import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -52,6 +54,7 @@ public void tearDown() throws IOException { try { Files.delete(p); } catch (IOException ignored) { + // Ignored to ensure cleanup continues even if some files couldn't be removed } }); } @@ -141,7 +144,7 @@ public void getShallowDirectoryTreeReturnsTreeForValidDirectory() throws IOExcep } @Test - void getShallowDirectoryTreeThrowsSecurityExceptionForPathTraversal() throws ProjectNotFoundException, IOException { + void getShallowDirectoryTreeThrowsSecurityExceptionForPathTraversal() throws ProjectNotFoundException { stubToAbsolutePath(); Project project = @@ -155,8 +158,7 @@ void getShallowDirectoryTreeThrowsSecurityExceptionForPathTraversal() throws Pro } @Test - void getShallowDirectoryTreeThrowsIllegalArgumentExceptionIfDirectoryDoesNotExist() - throws ProjectNotFoundException, IOException { + void getShallowDirectoryTreeThrowsIllegalArgumentExceptionIfDirectoryDoesNotExist() throws ProjectNotFoundException { stubToAbsolutePath(); Project project = @@ -172,8 +174,7 @@ void getShallowDirectoryTreeThrowsIllegalArgumentExceptionIfDirectoryDoesNotExis } @Test - public void getShallowConfigurationsDirectoryTreeReturnsTreeForExistingDirectory() - throws IOException, ProjectNotFoundException { + public void getShallowConfigurationsDirectoryTreeReturnsTreeForExistingDirectory() throws IOException, ProjectNotFoundException { stubToAbsolutePath(); Path configsDir = tempProjectRoot.resolve("src/main/configurations"); @@ -198,12 +199,10 @@ public void getShallowConfigurationsDirectoryTreeReturnsTreeForExistingDirectory } @Test - public void getShallowConfigurationsDirectoryTreeThrowsIfDirectoryDoesNotExist() - throws ProjectNotFoundException, IOException { + public void getShallowConfigurationsDirectoryTreeThrowsIfDirectoryDoesNotExist() throws ProjectNotFoundException { stubToAbsolutePath(); - Project project = - new Project(TEST_PROJECT_NAME, tempProjectRoot.toAbsolutePath().toString()); + Project project = new Project(TEST_PROJECT_NAME, tempProjectRoot.toAbsolutePath().toString()); when(projectService.getProject(TEST_PROJECT_NAME)).thenReturn(project); IllegalArgumentException ex = assertThrows( @@ -227,8 +226,7 @@ public void getShallowConfigurationsDirectoryTreeThrowsIfProjectDoesNotExist() t } @Test - public void getConfigurationsDirectoryTreeReturnsFullTreeForExistingDirectory() - throws IOException, ProjectNotFoundException { + public void getConfigurationsDirectoryTreeReturnsFullTreeForExistingDirectory() throws IOException, ProjectNotFoundException { stubToAbsolutePath(); when(fileSystemStorage.isLocalEnvironment()).thenReturn(true); @@ -265,8 +263,7 @@ public void getConfigurationsDirectoryTreeReturnsFullTreeForExistingDirectory() } @Test - public void getConfigurationsDirectoryTreeThrowsIfDirectoryDoesNotExist() - throws ProjectNotFoundException, IOException { + public void getConfigurationsDirectoryTreeThrowsIfDirectoryDoesNotExist() throws ProjectNotFoundException { stubToAbsolutePath(); Project project = From 186a93f1479633ab9a1ef0dfedc584cb4f52e3e3 Mon Sep 17 00:00:00 2001 From: Vivy <4380412+Matthbo@users.noreply.github.com> Date: Thu, 2 Apr 2026 12:56:04 +0200 Subject: [PATCH 16/18] Spotlessbut status! --- src/main/java/org/frankframework/flow/file/FileService.java | 2 -- src/main/java/org/frankframework/flow/file/FileTreeNode.java | 1 - .../java/org/frankframework/flow/file/FileTreeService.java | 4 ---- .../flow/configuration/ConfigurationServiceTest.java | 2 -- .../flow/datamapper/DatamapperConfigServiceTest.java | 3 --- .../flow/datamapper/DatamapperGeneratorServiceTest.java | 4 ---- .../java/org/frankframework/flow/file/FileServiceTest.java | 2 -- .../org/frankframework/flow/file/FileTreeServiceTest.java | 2 -- 8 files changed, 20 deletions(-) diff --git a/src/main/java/org/frankframework/flow/file/FileService.java b/src/main/java/org/frankframework/flow/file/FileService.java index 3ce76940..a912873a 100644 --- a/src/main/java/org/frankframework/flow/file/FileService.java +++ b/src/main/java/org/frankframework/flow/file/FileService.java @@ -4,13 +4,11 @@ import java.nio.file.FileAlreadyExistsException; import java.nio.file.Files; import java.nio.file.Path; - import org.frankframework.flow.exception.ApiException; import org.frankframework.flow.filesystem.FileSystemStorage; import org.frankframework.flow.project.Project; import org.frankframework.flow.project.ProjectNotFoundException; import org.frankframework.flow.project.ProjectService; - import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; diff --git a/src/main/java/org/frankframework/flow/file/FileTreeNode.java b/src/main/java/org/frankframework/flow/file/FileTreeNode.java index a50cb574..ad90fcda 100644 --- a/src/main/java/org/frankframework/flow/file/FileTreeNode.java +++ b/src/main/java/org/frankframework/flow/file/FileTreeNode.java @@ -1,7 +1,6 @@ package org.frankframework.flow.file; import java.util.List; - import lombok.Getter; import lombok.Setter; diff --git a/src/main/java/org/frankframework/flow/file/FileTreeService.java b/src/main/java/org/frankframework/flow/file/FileTreeService.java index 42d20e79..2fbc2f36 100644 --- a/src/main/java/org/frankframework/flow/file/FileTreeService.java +++ b/src/main/java/org/frankframework/flow/file/FileTreeService.java @@ -8,18 +8,14 @@ import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; -import java.util.stream.Collectors; import java.util.stream.Stream; - import javax.xml.parsers.DocumentBuilder; - import org.frankframework.flow.exception.ApiException; import org.frankframework.flow.filesystem.FileSystemStorage; import org.frankframework.flow.project.Project; import org.frankframework.flow.project.ProjectNotFoundException; import org.frankframework.flow.project.ProjectService; import org.frankframework.flow.utility.XmlSecurityUtils; - import org.springframework.stereotype.Service; import org.w3c.dom.Document; import org.w3c.dom.Element; diff --git a/src/test/java/org/frankframework/flow/configuration/ConfigurationServiceTest.java b/src/test/java/org/frankframework/flow/configuration/ConfigurationServiceTest.java index 587c06af..57fc7ef0 100644 --- a/src/test/java/org/frankframework/flow/configuration/ConfigurationServiceTest.java +++ b/src/test/java/org/frankframework/flow/configuration/ConfigurationServiceTest.java @@ -8,13 +8,11 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; - import org.frankframework.flow.exception.ApiException; import org.frankframework.flow.filesystem.FileSystemStorage; import org.frankframework.flow.project.Project; import org.frankframework.flow.project.ProjectNotFoundException; import org.frankframework.flow.project.ProjectService; - import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; diff --git a/src/test/java/org/frankframework/flow/datamapper/DatamapperConfigServiceTest.java b/src/test/java/org/frankframework/flow/datamapper/DatamapperConfigServiceTest.java index a0ab98cd..2be7a096 100644 --- a/src/test/java/org/frankframework/flow/datamapper/DatamapperConfigServiceTest.java +++ b/src/test/java/org/frankframework/flow/datamapper/DatamapperConfigServiceTest.java @@ -13,14 +13,11 @@ import java.nio.file.Path; import java.nio.file.Paths; import java.util.Comparator; - import javax.naming.ConfigurationException; - import org.frankframework.flow.configuration.ConfigurationNotFoundException; import org.frankframework.flow.file.FileTreeNode; import org.frankframework.flow.file.FileTreeService; import org.frankframework.flow.filesystem.FileSystemStorage; - import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Disabled; diff --git a/src/test/java/org/frankframework/flow/datamapper/DatamapperGeneratorServiceTest.java b/src/test/java/org/frankframework/flow/datamapper/DatamapperGeneratorServiceTest.java index 0bb530fb..17316212 100644 --- a/src/test/java/org/frankframework/flow/datamapper/DatamapperGeneratorServiceTest.java +++ b/src/test/java/org/frankframework/flow/datamapper/DatamapperGeneratorServiceTest.java @@ -15,7 +15,6 @@ import java.nio.file.Paths; import java.nio.file.StandardCopyOption; import java.util.Comparator; - import javax.xml.parsers.DocumentBuilder; import javax.xml.parsers.DocumentBuilderFactory; import javax.xml.parsers.ParserConfigurationException; @@ -23,15 +22,12 @@ import javax.xml.transform.dom.DOMSource; import javax.xml.transform.stream.StreamResult; import javax.xml.transform.stream.StreamSource; - import net.sf.saxon.s9api.*; - import org.frankframework.flow.exception.ApiException; import org.frankframework.flow.file.FileTreeNode; import org.frankframework.flow.file.FileTreeService; import org.frankframework.flow.filesystem.FileOperations; import org.frankframework.flow.filesystem.FileSystemStorage; - import org.junit.jupiter.api.*; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; diff --git a/src/test/java/org/frankframework/flow/file/FileServiceTest.java b/src/test/java/org/frankframework/flow/file/FileServiceTest.java index 996b76df..ba28ca7c 100644 --- a/src/test/java/org/frankframework/flow/file/FileServiceTest.java +++ b/src/test/java/org/frankframework/flow/file/FileServiceTest.java @@ -15,14 +15,12 @@ import java.nio.file.Path; import java.nio.file.Paths; import java.util.Comparator; - import org.frankframework.flow.exception.ApiException; import org.frankframework.flow.filesystem.FileOperations; import org.frankframework.flow.filesystem.FileSystemStorage; import org.frankframework.flow.project.Project; import org.frankframework.flow.project.ProjectNotFoundException; import org.frankframework.flow.project.ProjectService; - import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; diff --git a/src/test/java/org/frankframework/flow/file/FileTreeServiceTest.java b/src/test/java/org/frankframework/flow/file/FileTreeServiceTest.java index ea8d3ad3..368de38a 100644 --- a/src/test/java/org/frankframework/flow/file/FileTreeServiceTest.java +++ b/src/test/java/org/frankframework/flow/file/FileTreeServiceTest.java @@ -11,13 +11,11 @@ import java.nio.file.Paths; import java.util.Comparator; import java.util.List; - import org.frankframework.flow.exception.ApiException; import org.frankframework.flow.filesystem.FileSystemStorage; import org.frankframework.flow.project.Project; import org.frankframework.flow.project.ProjectNotFoundException; import org.frankframework.flow.project.ProjectService; - import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; From 4d1d0d71418f33151f641bace9f1614e8ecf307f Mon Sep 17 00:00:00 2001 From: Vivy <4380412+Matthbo@users.noreply.github.com> Date: Thu, 2 Apr 2026 17:18:01 +0200 Subject: [PATCH 17/18] Fix createFile endpoint being improperly called --- .../file-structure/use-file-tree-context-menu.ts | 10 ++++++---- src/main/frontend/app/services/file-service.ts | 5 ++++- 2 files changed, 10 insertions(+), 5 deletions(-) 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 d19ab122..74320772 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 @@ -49,9 +49,11 @@ export function getParentItemId(itemId: TreeItemIndex): TreeItemIndex { return lastSlash > 0 ? str.slice(0, Math.max(0, lastSlash)) : 'root' } -function ensureXmlExtension(name: string): string { - if (name.includes('.')) return name - return `${name}.xml` +function ensureHasExtension(name: string): string { + const dotIndex = name.lastIndexOf('.') + if (dotIndex < name.length - 1) return name + if (dotIndex === -1) return `${name}.txt` + return `${name.slice(0, Math.max(0, dotIndex))}.txt` } function buildNewPath(oldPath: string, newName: string): string { @@ -114,7 +116,7 @@ export function useFileTreeContextMenu({ title: 'New File', onSubmit: async (name: string) => { try { - await createFile(projectName, `${parentPath}/${ensureXmlExtension(name)}`) + await createFile(projectName, `${parentPath}/${ensureHasExtension(name)}`) await dataProvider.reloadDirectory(parentItemId) } catch (error) { showErrorToastFrom('Failed to create file', error) diff --git a/src/main/frontend/app/services/file-service.ts b/src/main/frontend/app/services/file-service.ts index 987fbf36..841ad9ff 100644 --- a/src/main/frontend/app/services/file-service.ts +++ b/src/main/frontend/app/services/file-service.ts @@ -7,7 +7,7 @@ export interface FileDTO { } export async function createFile(projectName: string, filePath: string): Promise { - await updateFile(projectName, filePath, '') + await updateFile(projectName, filePath, '\n') } export function fetchFile(projectName: string, path: string, signal?: AbortSignal): Promise { @@ -17,6 +17,9 @@ export function fetchFile(projectName: string, path: string, signal?: AbortSigna export function updateFile(projectName: string, path: string, content: string): Promise { return apiFetch(`${getBaseUrl(projectName)}?path=${encodeURIComponent(path)}`, { method: 'PUT', + headers: { + 'Content-Type': 'text/plain', // override default json content type + }, body: content, }) } From c7d6aea6007797dfda7a3374b90032be1d198dba Mon Sep 17 00:00:00 2001 From: Vivy <4380412+Matthbo@users.noreply.github.com> Date: Fri, 3 Apr 2026 18:14:10 +0200 Subject: [PATCH 18/18] Add allowed file extensions --- .../use-file-tree-context-menu.ts | 27 ++++++--- .../configuration/ConfigurationService.java | 42 -------------- .../frankframework/flow/file/FileService.java | 26 ++++++++- .../flow/file/FileTreeService.java | 13 +---- .../ConfigurationControllerTest.java | 2 +- .../ConfigurationServiceTest.java | 56 ------------------- .../flow/file/FileServiceTest.java | 39 +++++++++++++ .../flow/file/FileTreeServiceTest.java | 19 ------- 8 files changed, 85 insertions(+), 139 deletions(-) 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 74320772..20218ebd 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,11 +1,11 @@ -import { useCallback, useRef, useState } from 'react' +import React, { useCallback, useRef, useState } from 'react' import type { TreeItemIndex } from 'react-complex-tree' import { createFile, deleteFile, renameFile } from '~/services/file-service' import { createFolderInProject } from '~/services/file-tree-service' import { clearConfigurationCache } from '~/services/configuration-service' import useTabStore from '~/stores/tab-store' import useEditorTabStore from '~/stores/editor-tab-store' -import { showErrorToastFrom } from '~/components/toast' +import { showErrorToast, showErrorToastFrom } from '~/components/toast' export interface ContextMenuState { position: { x: number; y: number } @@ -43,17 +43,19 @@ interface UseFileTreeContextMenuOptions { onAfterDelete?: (path: string) => void } +const ALLOWED_EXTENSIONS = ['.xml', '.json', '.yaml', '.yml', '.properties'] + export function getParentItemId(itemId: TreeItemIndex): TreeItemIndex { const str = String(itemId) const lastSlash = str.lastIndexOf('/') return lastSlash > 0 ? str.slice(0, Math.max(0, lastSlash)) : 'root' } -function ensureHasExtension(name: string): string { +function ensureHasCorrectExtension(name: string): boolean { const dotIndex = name.lastIndexOf('.') - if (dotIndex < name.length - 1) return name - if (dotIndex === -1) return `${name}.txt` - return `${name.slice(0, Math.max(0, dotIndex))}.txt` + if (dotIndex === -1) return false + const extension = name.slice(dotIndex) + return ALLOWED_EXTENSIONS.includes(extension.toLowerCase()) } function buildNewPath(oldPath: string, newName: string): string { @@ -115,8 +117,13 @@ export function useFileTreeContextMenu({ setNameDialog({ title: 'New File', onSubmit: async (name: string) => { + if (!ensureHasCorrectExtension(name)) { + showErrorToast(`Filename must have one of the following extensions: ${ALLOWED_EXTENSIONS.join(', ')}`) + return + } + try { - await createFile(projectName, `${parentPath}/${ensureHasExtension(name)}`) + await createFile(projectName, `${parentPath}/${name}`) await dataProvider.reloadDirectory(parentItemId) } catch (error) { showErrorToastFrom('Failed to create file', error) @@ -168,9 +175,13 @@ export function useFileTreeContextMenu({ if (newName === oldName) { setNameDialog(null) return + } else if (!ensureHasCorrectExtension(newName)) { + showErrorToast(`Filename must have one of the following extensions: ${ALLOWED_EXTENSIONS.join(', ')}`) + return } + try { - await renameFile(projectName, `${oldPath}/${oldName}`, `${oldPath}/${newName}`) + await renameFile(projectName, `${oldPath}`, `${oldPath}`.replace(oldName, newName)) clearConfigurationCache(projectName, oldPath) const newPath = buildNewPath(oldPath, newName) useTabStore.getState().renameTabsForConfig(oldPath, newPath) diff --git a/src/main/java/org/frankframework/flow/configuration/ConfigurationService.java b/src/main/java/org/frankframework/flow/configuration/ConfigurationService.java index ceac98a6..e3d204b2 100644 --- a/src/main/java/org/frankframework/flow/configuration/ConfigurationService.java +++ b/src/main/java/org/frankframework/flow/configuration/ConfigurationService.java @@ -37,7 +37,6 @@ public ConfigurationDTO getConfigurationContent(String projectName, String filep throw new ApiException("Invalid configuration path: " + filepath, HttpStatus.NOT_FOUND); } - // TODO check if filepath is part of configuration files String content = fileSystemStorage.readFile(filePath.toString()); return new ConfigurationDTO(filepath, content); } @@ -47,11 +46,9 @@ public String updateConfiguration(String projectName, String filepath, String co Path absolutePath = fileSystemStorage.toAbsolutePath(filepath); if (!Files.exists(absolutePath) || Files.isDirectory(absolutePath)) { - // TODO should be a custom FileSystem/IO Exception throw new ApiException("Invalid file path: " + filepath, HttpStatus.NOT_FOUND); } - // TODO check if filepath is part of configuration files Document updatedDocument = XmlConfigurationUtils.insertFlowNamespace(content); String updatedContent = XmlConfigurationUtils.convertNodeToString(updatedDocument); @@ -70,53 +67,14 @@ public String addConfiguration(String projectName, String configurationName) thr Path filePath = configDir.resolve(configurationName).normalize(); if (!filePath.startsWith(configDir)) { - // TODO should be a custom FileSystem/IO Exception throw new ApiException("Invalid configuration name: " + configurationName, HttpStatus.BAD_REQUEST); } - // TODO check if filepath is part of configuration files - String defaultXml = loadDefaultConfigurationXml(); fileSystemStorage.writeFile(filePath.toString(), defaultXml); return defaultXml; } - - /* - * Gets called from FileTreeService, maybe this should be part of that there? - * TODO see if this should be reworked - * */ - public Project addConfigurationToFolder(String projectName, String configurationName, String folderPath) - throws IOException, ApiException { - Project project = projectService.getProject(projectName); - - Path absProjectPath = fileSystemStorage.toAbsolutePath(project.getRootPath()); - Path targetDir = fileSystemStorage.toAbsolutePath(folderPath); - - if (!targetDir.startsWith(absProjectPath)) { - // TODO should be a custom FileSystem/IO Exception - throw new SecurityException("Configuration location must be within the project directory"); - } - - if (!Files.exists(targetDir)) { - Files.createDirectories(targetDir); - } - - Path filePath = targetDir.resolve(configurationName).normalize(); - if (!filePath.startsWith(targetDir)) { - throw new SecurityException("Invalid configuration name: " + configurationName); - } - - if (Files.exists(filePath)) { - throw new ConfigurationAlreadyExistsException(configurationName + " already exists at: " + filePath); - } - - String defaultXml = loadDefaultConfigurationXml(); - fileSystemStorage.writeFile(filePath.toString(), defaultXml); - - return project; - } - private String loadDefaultConfigurationXml() throws IOException { return new String( new ClassPathResource("templates/default-configuration.xml") diff --git a/src/main/java/org/frankframework/flow/file/FileService.java b/src/main/java/org/frankframework/flow/file/FileService.java index a912873a..ed8da074 100644 --- a/src/main/java/org/frankframework/flow/file/FileService.java +++ b/src/main/java/org/frankframework/flow/file/FileService.java @@ -15,6 +15,7 @@ @Service public class FileService { + public static final String[] ALLOWED_EXTENSIONS = { "", ".xml", ".json", ".yaml", ".yml", ".properties" }; private final ProjectService projectService; private final FileSystemStorage fileSystemStorage; @@ -45,7 +46,9 @@ public FileTreeNode createOrUpdateFile(String projectName, String path, String f try { validateWithinProject(projectName, path); - if (!Files.exists(filePath)) { + if (!hasAllowedExtension(fileName)) { + throw new ApiException("Unsupported extension type for file: " + fileName, HttpStatus.BAD_REQUEST); + } else if (!Files.exists(filePath)) { fileSystemStorage.createFile(path); } fileSystemStorage.writeFile(path, fileContent); @@ -66,8 +69,13 @@ public FileTreeNode renameFile(String projectName, String oldPath, String newPat validatePath(oldPath); validatePath(newPath); String newFileName = Path.of(newPath).getFileName().toString(); + String newFileExtension = getFileExtension(newFileName); Path absoluteNewPath = fileSystemStorage.toAbsolutePath(newPath); + if (!hasAllowedExtension(newFileExtension)) { + throw new ApiException("Unsupported extension type for file: " + newFileName, HttpStatus.BAD_REQUEST); + } + try { validateWithinProject(projectName, oldPath); @@ -116,7 +124,7 @@ public void validateWithinProject(String projectName, String path) throws ApiExc } } - protected void validatePath(String path) throws IllegalArgumentException { + public void validatePath(String path) throws IllegalArgumentException { if (path == null || path.isBlank()) { throw new IllegalArgumentException("File path must not be empty"); } @@ -125,4 +133,18 @@ protected void validatePath(String path) throws IllegalArgumentException { } } + public boolean hasAllowedExtension(String fileName) { + String fileExtension = getFileExtension(fileName); + for (String allowedExtension : ALLOWED_EXTENSIONS) { + if (allowedExtension.equalsIgnoreCase(fileExtension)) { + return true; + } + } + return false; + } + + private String getFileExtension(String fileName) { + return fileName.contains(".") ? fileName.substring(fileName.lastIndexOf(".")) : ""; + } + } diff --git a/src/main/java/org/frankframework/flow/file/FileTreeService.java b/src/main/java/org/frankframework/flow/file/FileTreeService.java index 2fbc2f36..1a175d0b 100644 --- a/src/main/java/org/frankframework/flow/file/FileTreeService.java +++ b/src/main/java/org/frankframework/flow/file/FileTreeService.java @@ -125,7 +125,7 @@ public FileTreeNode getConfigurationsDirectoryTree(String projectName) throws IO } public FileTreeNode createFolder(String projectName, String path) throws IOException, ApiException { - validatePath(path); + fileService.validatePath(path); fileService.validateWithinProject(projectName, path); fileSystemStorage.createProjectDirectory(path); invalidateTreeCache(projectName); @@ -169,7 +169,7 @@ private FileTreeNode buildTree(Path path, Path relativizeRoot, boolean useRelati } else { node.setType(NodeType.FILE); node.setChildren(null); - if (path.getFileName().toString().toLowerCase().endsWith(".xml")) { + if (fileService.hasAllowedExtension(path.getFileName().toString())) { node.setAdapterNames(extractAdapterNames(path)); } } @@ -237,13 +237,4 @@ private FileTreeNode buildShallowTree(Path path, Path relativizeRoot, boolean us return node; } - - protected void validatePath(String path) throws IllegalArgumentException { - if (path == null || path.isBlank()) { - throw new IllegalArgumentException("File path must not be empty"); - } - if (path.contains("..")) { - throw new IllegalArgumentException("File path contains invalid characters: " + path); - } - } } diff --git a/src/test/java/org/frankframework/flow/configuration/ConfigurationControllerTest.java b/src/test/java/org/frankframework/flow/configuration/ConfigurationControllerTest.java index 15e8e7dc..e935e8f8 100644 --- a/src/test/java/org/frankframework/flow/configuration/ConfigurationControllerTest.java +++ b/src/test/java/org/frankframework/flow/configuration/ConfigurationControllerTest.java @@ -127,7 +127,7 @@ void updateConfigurationNotFoundReturns404() throws Exception { } @Test - void addConfigurationReturnsProjectDto() throws Exception { + void addConfigurationReturnsDefaultContent() throws Exception { Project project = mock(Project.class); when(project.getName()).thenReturn(TEST_PROJECT_NAME); when(project.getRootPath()).thenReturn("/path/to/" + TEST_PROJECT_NAME); diff --git a/src/test/java/org/frankframework/flow/configuration/ConfigurationServiceTest.java b/src/test/java/org/frankframework/flow/configuration/ConfigurationServiceTest.java index 57fc7ef0..2e4ef0f6 100644 --- a/src/test/java/org/frankframework/flow/configuration/ConfigurationServiceTest.java +++ b/src/test/java/org/frankframework/flow/configuration/ConfigurationServiceTest.java @@ -166,60 +166,4 @@ void addConfiguration_PathTraversal_ThrowsSecurityException() throws Exception { assertThrows( ApiException.class, () -> configurationService.addConfiguration("myproject", "../../../evil.xml")); } - - @Test - void addConfigurationToFolder_Success() throws Exception { - stubToAbsolutePath(); - stubWriteFile(); - - Path projectDir = tempDir.resolve("myproject"); - Files.createDirectories(projectDir); - Project project = new Project("myproject", projectDir.toString()); - - when(projectService.getProject("myproject")).thenReturn(project); - - Project result = - configurationService.addConfigurationToFolder("myproject", "Nested.xml", projectDir.toString()); - - assertNotNull(result); - - assertTrue(Files.exists(projectDir.resolve("Nested.xml")), "Nested.xml should be created on disk"); - } - - @Test - void addConfigurationToFolder_AlreadyExists_ThrowsException() throws Exception { - stubToAbsolutePath(); - - Path projectDir = tempDir.resolve("myproject"); - Files.createDirectories(projectDir); - Path existingFile = projectDir.resolve("existing.xml"); - Files.writeString(existingFile, ""); - Project project = new Project("myproject", projectDir.toString()); - - when(projectService.getProject("myproject")).thenReturn(project); - - assertThrows( - ConfigurationAlreadyExistsException.class, - () -> configurationService.addConfigurationToFolder( - "myproject", "existing.xml", projectDir.toString()) - ); - } - - @Test - void addConfigurationToFolder_OutsideProject_ThrowsSecurityException() throws Exception { - stubToAbsolutePath(); - - Path projectDir = tempDir.resolve("myproject"); - Files.createDirectories(projectDir); - Project project = new Project("myproject", projectDir.toString()); - - when(projectService.getProject("myproject")).thenReturn(project); - - String outsidePath = tempDir.getParent().toString(); - - assertThrows( - SecurityException.class, - () -> configurationService.addConfigurationToFolder("myproject", "evil.xml", outsidePath) - ); - } } diff --git a/src/test/java/org/frankframework/flow/file/FileServiceTest.java b/src/test/java/org/frankframework/flow/file/FileServiceTest.java index ba28ca7c..9c88e7ea 100644 --- a/src/test/java/org/frankframework/flow/file/FileServiceTest.java +++ b/src/test/java/org/frankframework/flow/file/FileServiceTest.java @@ -28,6 +28,7 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; @ExtendWith(MockitoExtension.class) class FileServiceTest { @@ -90,6 +91,26 @@ void createFile_NameWithDoubleDots_ThrowsIllegalArgument() { ); } + @Test + @DisplayName("Should throw ApiException when extension is not allowed") + void createOrUpdateFileUnsupportedExtension() throws ProjectNotFoundException { + stubToAbsolutePath(); + + Project project = new Project(TEST_PROJECT_NAME, tempProjectRoot.toAbsolutePath().toString()); + when(projectService.getProject(TEST_PROJECT_NAME)).thenReturn(project); + + String path = tempProjectRoot.resolve("notAllowed.txt").toAbsolutePath().toString(); + + ApiException exception = assertThrows( + ApiException.class, + () -> fileService.createOrUpdateFile(TEST_PROJECT_NAME, path, "content") + ); + + assertEquals(HttpStatus.BAD_REQUEST, exception.getStatusCode()); + assertEquals("Unsupported extension type for file: notAllowed.txt", exception.getMessage()); + } + + @Test @DisplayName("Should create a file and return a FileTreeNode with FILE type") void createFile_Success() throws IOException, ApiException { @@ -157,6 +178,24 @@ void renameFile_LocalEnvironment_File() throws IOException, ApiException { assertFalse(Files.exists(oldFile)); } + @Test + @DisplayName("Should throw ApiException when rename file extension is not allowed") + void renameFileUnsupportedExtension() throws ProjectNotFoundException, IOException { + stubToAbsolutePath(); + + Path oldFile = Files.writeString(tempProjectRoot.resolve("old.xml"), "content"); + String oldPath = oldFile.toAbsolutePath().toString(); + String newPath = tempProjectRoot.resolve("notAllowed.txt").toAbsolutePath().toString(); + + ApiException exception = assertThrows( + ApiException.class, + () -> fileService.renameFile(TEST_PROJECT_NAME, oldPath, newPath) + ); + + assertEquals(HttpStatus.BAD_REQUEST, exception.getStatusCode()); + assertEquals("Unsupported extension type for file: notAllowed.txt", exception.getMessage()); + } + @Test @DisplayName("Should rename a directory and return a node with DIRECTORY type") void renameFile_LocalEnvironment_Directory() throws IOException, ApiException { diff --git a/src/test/java/org/frankframework/flow/file/FileTreeServiceTest.java b/src/test/java/org/frankframework/flow/file/FileTreeServiceTest.java index 368de38a..0168fffb 100644 --- a/src/test/java/org/frankframework/flow/file/FileTreeServiceTest.java +++ b/src/test/java/org/frankframework/flow/file/FileTreeServiceTest.java @@ -288,25 +288,6 @@ public void getConfigurationsDirectoryTreeThrowsIfProjectDoesNotExist() throws P assertTrue(ex.getMessage().contains("Configurations directory does not exist")); } - @Test - @DisplayName("Should delegate to ConfigurationService when creating an .xml file") - void createFile_ShouldDelegateToConfigurationService_WhenXml() throws Exception { - stubToAbsolutePath(); - - Project project = - new Project(TEST_PROJECT_NAME, tempProjectRoot.toAbsolutePath().toString()); - - when(configurationService.addConfigurationToFolder(eq(TEST_PROJECT_NAME), eq("config.xml"), anyString())) - .thenReturn(project); - when(projectService.getProject(TEST_PROJECT_NAME)).thenReturn(project); - - FileTreeNode node = fileService.createOrUpdateFile( - TEST_PROJECT_NAME, tempProjectRoot.toAbsolutePath().toString(), "config.xml"); - - assertNotNull(node); - verify(configurationService).addConfigurationToFolder(eq(TEST_PROJECT_NAME), eq("config.xml"), anyString()); - } - @Test @DisplayName("Should create a folder and return a FileTreeNode with DIRECTORY type") void createFolder_Success() throws IOException, ApiException {