diff --git a/src/main/frontend/app/routes/editor/editor.tsx b/src/main/frontend/app/routes/editor/editor.tsx index bb85b71d..711c3566 100644 --- a/src/main/frontend/app/routes/editor/editor.tsx +++ b/src/main/frontend/app/routes/editor/editor.tsx @@ -1,7 +1,5 @@ import RulerCrossPenIcon from '/icons/solar/Ruler Cross Pen.svg?react' import Editor, { type Monaco, type OnMount } from '@monaco-editor/react' -import prettier from 'prettier/standalone' -import prettierPluginXml from '@prettier/plugin-xml' type ITextModel = Monaco['editor']['ITextModel'] type FindMatch = Monaco['editor']['FindMatch'] type IModelDeltaDecoration = Monaco['editor']['IModelDeltaDecoration'] @@ -164,14 +162,6 @@ function isConfigurationFile(fileExtension: string) { return fileExtension === 'xml' } -function prettierFormat(xml: string): Promise { - return prettier.format(xml, { - parser: 'xml', - plugins: [prettierPluginXml], - tabWidth: 2, - }) -} - async function validateFlow(content: string, model: ITextModel): Promise { const flowFragment = extractFlowElements(content) if (!flowFragment) return [] @@ -393,27 +383,31 @@ export default function CodeEditor() { ) }, []) - const runPrettierReformat = async () => { + const runReformat = useCallback(async () => { const editor = editorReference.current - if (!editor) return - const model = editor.getModel() - if (!model) return + if (!editor || !project || !activeTabFilePath) return + + const activeTab = useEditorTabStore.getState().getTab(activeTabFilePath) + const configPath = activeTab?.configurationPath + if (!configPath) return + try { - const formattedValue = await prettierFormat(model.getValue()) - if (formattedValue === model.getValue()) return + const current = editor.getValue() + const { xmlContent } = await saveConfiguration(project.name, configPath, current, true) + contentCacheRef.current.set(activeTabFilePath, { type: 'xml', content: xmlContent }) const selection = editor.getSelection() editor.pushUndoStop() editor.executeEdits( - 'prettier-reformat', - [{ range: model.getFullModelRange(), text: formattedValue, forceMoveMarkers: true }], + 'reformat', + [{ range: editor.getModel()!.getFullModelRange(), text: xmlContent, forceMoveMarkers: true }], selection ? [selection] : undefined, ) editor.pushUndoStop() } catch (error) { console.error('Failed to reformat XML:', error) } - } + }, [project, activeTabFilePath]) const runSchemaValidation = useCallback( async (content: string) => { @@ -513,7 +507,7 @@ export default function CodeEditor() { }) editor.addAction({ - id: 'reformat-xml-prettier', + id: 'reformat-xml', label: 'Reformat', contextMenuGroupId: 'navigation', contextMenuOrder: 3, @@ -522,7 +516,7 @@ export default function CodeEditor() { monacoReference.current.KeyMod.Shift | monacoReference.current.KeyCode.KeyF, ], - run: runPrettierReformat, + run: runReformat, }) } diff --git a/src/main/frontend/app/routes/studio/canvas/flow.tsx b/src/main/frontend/app/routes/studio/canvas/flow.tsx index 51883109..ea6af33b 100644 --- a/src/main/frontend/app/routes/studio/canvas/flow.tsx +++ b/src/main/frontend/app/routes/studio/canvas/flow.tsx @@ -28,7 +28,7 @@ import { NodeContextMenuContext, useNodeContextMenu } from './node-context-menu- import StickyNoteComponent, { type StickyNote } from '~/routes/studio/canvas/nodetypes/sticky-note' import useTabStore, { type TabData } from '~/stores/tab-store' import { convertAdapterXmlToJson, getAdapterFromConfiguration } from '~/routes/studio/xml-to-json-parser' -import { exportFlowToXml } from '~/routes/studio/flow-to-xml-parser' +import { exportFlowToXml, replaceAdapterInXml } from '~/routes/studio/flow-to-xml-parser' import useNodeContextStore from '~/stores/node-context-store' import CreateNodeModal from '~/components/flow/create-node-modal' import { useFFDoc } from '@frankframework/doc-library-react' @@ -160,18 +160,12 @@ function FlowCanvas() { existingAdapterXml, ) - const newAdapterDoc = new DOMParser().parseFromString( - `${newAdapterXml}`, - 'text/xml', - ) - const newAdapterEl = newAdapterDoc.querySelector('Adapter, adapter') - if (!newAdapterEl) throw new Error('Failed to parse generated adapter XML') - - existingAdapter.parentNode!.replaceChild(configDoc.importNode(newAdapterEl, true), existingAdapter) + const adapterIndex = allAdapters.indexOf(existingAdapter) + if (adapterIndex === -1) showErrorToast('Could not determine adapter position for replacement') - const updatedConfigXml = new XMLSerializer().serializeToString(configDoc).replace(/^<\?xml[^?]*\?>\s*/, '') + const updatedConfigXml = replaceAdapterInXml(fullConfigXml, adapterIndex, newAdapterXml.trim()) - await saveConfiguration(currentProject.name, configurationPath, updatedConfigXml) + await saveConfiguration(currentProject.name, configurationPath, updatedConfigXml, true) clearConfigurationCache(currentProject.name, configurationPath) useEditorTabStore.getState().refreshAllTabs() if (currentProject.isGitRepository) await refreshOpenDiffs(currentProject.name) diff --git a/src/main/frontend/app/routes/studio/flow-to-xml-parser.ts b/src/main/frontend/app/routes/studio/flow-to-xml-parser.ts index 495fc1a2..4f069341 100644 --- a/src/main/frontend/app/routes/studio/flow-to-xml-parser.ts +++ b/src/main/frontend/app/routes/studio/flow-to-xml-parser.ts @@ -96,6 +96,20 @@ ${pipelineParts.join('\n')} ` } +export function replaceAdapterInXml(configXml: string, adapterIndex: number, newAdapterXml: string): string { + const matches = [...configXml.matchAll(/<(Adapter|adapter)\b/g)] + + if (adapterIndex >= matches.length) return configXml + + const match = matches[adapterIndex] + const start = match.index + const closingTag = `` + const closeIndex = configXml.indexOf(closingTag, start) + if (closeIndex === -1) return configXml + + return configXml.slice(0, start) + newAdapterXml + configXml.slice(closeIndex + closingTag.length) +} + function buildEdgeMaps(edges: Edge[]) { const outgoing: Record = {} const incoming: Record = {} @@ -163,15 +177,21 @@ function generateXmlElement( width = node.measured.width } - const height: number | undefined = node.height ?? undefined + const height: number | null = node.height ?? null const attributes = (node.data as NodeData).attributes || {} const children = (node.data as NodeData).children || [] - const attributeString = ` name="${escapeXml(name)}"${Object.entries(attributes) - .map(([key, value]) => ` ${key}="${escapeXml(value)}"`) - .join('')}` - - const flowNamespaceString = `flow:y="${roundedY}" flow:x="${roundedX}" flow:width="${width}"${height === undefined ? '' : ` flow:height="${height}"`}` + const allAttrs: Record = { + ...attributes, + name, + 'flow:x': String(roundedX), + 'flow:y': String(roundedY), + 'flow:width': String(width), + ...(height === null ? {} : { 'flow:height': String(height) }), + } + const attrStr = Object.entries(allAttrs) + .map(([k, v]) => `${k}="${escapeXml(v)}"`) + .join(' ') const childXml = children.map((child: ChildNode) => generateChildXml(child, 4)).join('\n') @@ -195,30 +215,31 @@ function generateXmlElement( .join('\n') const content = [childXml, forwards].filter(Boolean).join('\n') - return content - ? ` <${subtype}${attributeString} ${flowNamespaceString} >\n${content}\n ` - : ` <${subtype}${attributeString} ${flowNamespaceString} />` + return content ? ` <${subtype} ${attrStr} >\n${content}\n ` : ` <${subtype} ${attrStr} />` } function generateChildXml(child: ChildNode, indent: number): string { const spaces = ' '.repeat(indent) - const attributes = - (child.name ? ` name="${escapeXml(child.name)}"` : '') + - Object.entries(child.attributes || {}) - .map(([key, value]) => ` ${key}="${escapeXml(value)}"`) - .join('') + const childAttrs: Record = { + ...(child.name ? { name: child.name } : {}), + ...child.attributes, + } + + const attrStr = Object.entries(childAttrs) + .map(([k, v]) => `${k}="${escapeXml(v)}"`) + .join(' ') + const attrs = attrStr ? ` ${attrStr}` : '' const hasChildren = child.children && child.children.length > 0 if (!hasChildren) { - return `${spaces}<${child.subtype}${attributes}/>` + return `${spaces}<${child.subtype}${attrs}/>` } - // Recursive case const childXmlStrings = child.children!.map((nested) => generateChildXml(nested, indent + 2)) - return `${spaces}<${child.subtype}${attributes}> + return `${spaces}<${child.subtype}${attrs}> ${childXmlStrings.join('\n')} ${spaces}` } @@ -237,13 +258,20 @@ function generateExitsXml(exitNodes: FlowNode[]): string { width = node.measured.width height = node.measured.height } - const attributes = data.attributes || {} - const flowNamespaceString = `flow:y="${roundedY}" flow:x="${roundedX}" flow:width="${width}" flow:height="${height}"` - const attributeString = ` name="${escapeXml(name)}"${Object.entries(attributes) - .map(([key, value]) => ` ${key}="${escapeXml(value)}"`) - .join('')}` - return ` ` + const allAttrs: Record = { + ...data.attributes, + name, + 'flow:x': String(roundedX), + 'flow:y': String(roundedY), + 'flow:width': String(width), + 'flow:height': String(height), + } + const attrStr = Object.entries(allAttrs) + .map(([k, v]) => `${k}="${escapeXml(v)}"`) + .join(' ') + + return ` ` }) .join('\n') } diff --git a/src/main/frontend/app/services/configuration-service.ts b/src/main/frontend/app/services/configuration-service.ts index 84d0a8a3..65214c94 100644 --- a/src/main/frontend/app/services/configuration-service.ts +++ b/src/main/frontend/app/services/configuration-service.ts @@ -31,8 +31,14 @@ export async function fetchConfiguration(projectName: string, filepath: string, return content } -export async function saveConfiguration(projectName: string, filepath: string, content: string): Promise { - return apiFetch(`${getBaseUrl(projectName)}?path=${encodeURIComponent(filepath)}`, { +export async function saveConfiguration( + projectName: string, + filepath: string, + content: string, + format = false, +): Promise { + const formatParam = format ? '&format=true' : '' + return apiFetch(`${getBaseUrl(projectName)}?path=${encodeURIComponent(filepath)}${formatParam}`, { method: 'PUT', body: content, }) diff --git a/src/main/java/org/frankframework/flow/configuration/ConfigurationController.java b/src/main/java/org/frankframework/flow/configuration/ConfigurationController.java index 40f0913c..204ee4c3 100644 --- a/src/main/java/org/frankframework/flow/configuration/ConfigurationController.java +++ b/src/main/java/org/frankframework/flow/configuration/ConfigurationController.java @@ -41,9 +41,10 @@ public ResponseEntity getConfigurationByPath( public ResponseEntity updateConfiguration( @PathVariable String projectName, @RequestParam String path, + @RequestParam(defaultValue = "false") boolean format, @RequestBody String content - ) throws ApiException, IOException, ParserConfigurationException, SAXException, TransformerException { - String updatedContent = configurationService.updateConfiguration(projectName, path, content); + ) throws ApiException { + String updatedContent = configurationService.updateConfiguration(projectName, path, content, format); XmlDTO xmlDTO = new XmlDTO(updatedContent); return ResponseEntity.ok(xmlDTO); } diff --git a/src/main/java/org/frankframework/flow/configuration/ConfigurationService.java b/src/main/java/org/frankframework/flow/configuration/ConfigurationService.java index ff32a9e9..7701c3b4 100644 --- a/src/main/java/org/frankframework/flow/configuration/ConfigurationService.java +++ b/src/main/java/org/frankframework/flow/configuration/ConfigurationService.java @@ -1,23 +1,33 @@ package org.frankframework.flow.configuration; +import jakarta.annotation.PostConstruct; import java.io.IOException; +import java.io.StringReader; 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 lombok.extern.slf4j.Slf4j; import org.frankframework.flow.exception.ApiException; import org.frankframework.flow.file.FileTreeService; import org.frankframework.flow.filesystem.FileSystemStorage; +import org.frankframework.flow.frankconfig.FrankConfigXsdNotFoundException; +import org.frankframework.flow.frankconfig.FrankConfigXsdService; import org.frankframework.flow.project.Project; import org.frankframework.flow.project.ProjectService; import org.frankframework.flow.utility.XmlConfigurationUtils; +import org.frankframework.flow.utility.XmlFormatterUtils; +import org.frankframework.flow.utility.XmlSecurityUtils; +import org.frankframework.flow.utility.XsdAttributeOrdererUtils; 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.InputSource; import org.xml.sax.SAXException; +@Slf4j @Service public class ConfigurationService { @@ -26,13 +36,25 @@ public class ConfigurationService { private final FileSystemStorage fileSystemStorage; private final ProjectService projectService; private final FileTreeService fileTreeService; - - public ConfigurationService(FileSystemStorage fileSystemStorage, ProjectService projectService, FileTreeService fileTreeService) { + private final FrankConfigXsdService frankConfigXsdService; + private XsdAttributeOrdererUtils xsdOrderer; + + public ConfigurationService( + FileSystemStorage fileSystemStorage, + ProjectService projectService, + FrankConfigXsdService frankConfigXsdService, + FileTreeService fileTreeService) { this.fileSystemStorage = fileSystemStorage; this.projectService = projectService; + this.frankConfigXsdService = frankConfigXsdService; this.fileTreeService = fileTreeService; } + @PostConstruct + public void init() { + this.xsdOrderer = loadXsdOrderer(); + } + public ConfigurationDTO getConfigurationContent(String projectName, String filepath) throws IOException, ApiException { Path filePath = fileSystemStorage.toAbsolutePath(filepath); @@ -44,23 +66,32 @@ public ConfigurationDTO getConfigurationContent(String projectName, String filep return new ConfigurationDTO(filepath, content); } - public String updateConfiguration(String projectName, String filepath, String content) - throws IOException, ApiException, ParserConfigurationException, SAXException, TransformerException { + public String updateConfiguration(String projectName, String filepath, String content, boolean format) + throws ApiException { Path absolutePath = fileSystemStorage.toAbsolutePath(filepath); if (!Files.exists(absolutePath) || Files.isDirectory(absolutePath)) { throw new ApiException("Invalid file path: " + filepath, HttpStatus.NOT_FOUND); } - - Document document = XmlConfigurationUtils.insertFlowNamespace(content); - if (document == null) { + if (content == null || content.isBlank()) { throw new ApiException("Configuration content must not be blank", HttpStatus.BAD_REQUEST); } - String formatted = XmlConfigurationUtils.convertNodeToString(document); - fileSystemStorage.writeFile(absolutePath.toString(), formatted); - return formatted; + try { + String withNamespace = ensureFlowNamespace(content); + + if (!format) { + fileSystemStorage.writeFile(absolutePath.toString(), withNamespace); + return withNamespace; + } + + String formatted = XmlFormatterUtils.format(withNamespace, getXsdOrderer()); + fileSystemStorage.writeFile(absolutePath.toString(), formatted); + return formatted; + } catch (Exception e) { + throw new ApiException("Failed to save configuration: " + e.getMessage(), HttpStatus.BAD_REQUEST); + } } public String addConfiguration(String projectName, String configurationName) throws IOException, ApiException, TransformerException, ParserConfigurationException, SAXException { @@ -87,6 +118,14 @@ public String addConfiguration(String projectName, String configurationName) thr return updatedContent; } + private String ensureFlowNamespace(String xml) { + if (xml.contains("xmlns:flow")) { + return xml; + } + + return xml.replaceFirst("( 0) { + List ordered = orderer != null ? orderer.reorder(qName, attrs) : toList(attrs); + sb.append(' '); + appendAttribute(ordered.getFirst()); + if (ordered.size() > 1) { + String continuationPad = " ".repeat(elementIndent + qName.length() + INDENT_SIZE); + for (int i = 1; i < ordered.size(); i++) { + sb.append('\n').append(continuationPad); + appendAttribute(ordered.get(i)); + } + } + } + + startTagOpen = true; + depth++; + } + + @Override + public void endElement(String uri, String localName, String qName) { + depth--; + + if (startTagOpen) { + sb.append("/>"); + startTagOpen = false; + } else { + sb.append('\n'); + sb.append(" ".repeat(depth * INDENT_SIZE)); + sb.append("'); + } + } + + @Override + public void characters(char[] ch, int start, int length) { + String text = new String(ch, start, length).trim(); + if (!text.isEmpty()) { + closeStartTagIfOpen(); + sb.append(escapeText(text)); + } + } + + @Override + public void comment(char[] ch, int start, int length) { + closeStartTagIfOpen(); + appendNewlineIfNeeded(); + + sb.append(" ".repeat(depth * INDENT_SIZE)); + sb.append(""); + } + + @Override + public String toString() { + return sb.toString(); + } + + private void appendAttribute(String[] nameValue) { + sb.append(nameValue[0]).append("=\"").append(escapeAttr(nameValue[1])).append('"'); + } + + private static List toList(Attributes attrs) { + List list = new ArrayList<>(attrs.getLength()); + for (int i = 0; i < attrs.getLength(); i++) { + list.add(new String[]{attrs.getQName(i), attrs.getValue(i)}); + } + return list; + } + + private void appendNewlineIfNeeded() { + if (!sb.isEmpty() && sb.charAt(sb.length() - 1) != '\n') { + sb.append('\n'); + } + } + + private void closeStartTagIfOpen() { + if (startTagOpen) { + sb.append('>'); + startTagOpen = false; + } + } + + private static String escapeAttr(String val) { + return val.replace("&", "&").replace("<", "<").replace("\"", """); + } + + private static String escapeText(String val) { + return val.replace("&", "&").replace("<", "<"); + } + + private static XMLReader createSecureXmlReader(XmlFormatterUtils handler) + throws ParserConfigurationException, SAXException { + SAXParserFactory spf = SAXParserFactory.newInstance(); + spf.setNamespaceAware(false); + spf.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true); + spf.setFeature("http://xml.org/sax/features/external-general-entities", false); + spf.setFeature("http://xml.org/sax/features/external-parameter-entities", false); + spf.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false); + + XMLReader reader = spf.newSAXParser().getXMLReader(); + reader.setFeature("http://xml.org/sax/features/external-general-entities", false); + reader.setFeature("http://xml.org/sax/features/external-parameter-entities", false); + reader.setContentHandler(handler); + reader.setProperty("http://xml.org/sax/properties/lexical-handler", handler); + return reader; + } +} diff --git a/src/main/java/org/frankframework/flow/utility/XsdAttributeOrdererUtils.java b/src/main/java/org/frankframework/flow/utility/XsdAttributeOrdererUtils.java new file mode 100644 index 00000000..d1889693 --- /dev/null +++ b/src/main/java/org/frankframework/flow/utility/XsdAttributeOrdererUtils.java @@ -0,0 +1,172 @@ +package org.frankframework.flow.utility; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import javax.xml.XMLConstants; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; +import org.xml.sax.Attributes; + +public class XsdAttributeOrdererUtils { + private static final String XSD_ATTRIBUTE = "attribute"; + private static final String XSD_EXTENSION = "extension"; + + private final Map> index; + private final Map cache = new HashMap<>(); + + private record OrderInfo(List xsdOrder, Set required) {} + + public XsdAttributeOrdererUtils(Document xsdDoc) { + this.index = buildIndex(xsdDoc); + } + + private static Map> buildIndex(Document doc) { + Map> idx = new HashMap<>(); + NodeList all = doc.getElementsByTagNameNS(XMLConstants.W3C_XML_SCHEMA_NS_URI, "*"); + for (int i = 0; i < all.getLength(); i++) { + Element elem = (Element) all.item(i); + String name = elem.getAttribute("name"); + if (!name.isEmpty()) { + idx.computeIfAbsent(elem.getLocalName(), k -> new HashMap<>()).put(name, elem); + } + } + return idx; + } + + List reorder(String elementName, Attributes attrs) { + OrderInfo info = cache.computeIfAbsent(elementName, this::computeOrderInfo); + Map attrMap = buildAttrMap(attrs); + Set xsdSet = new HashSet<>(info.xsdOrder()); + + List result = new ArrayList<>(); + result.addAll(namespacedAttrs(attrMap)); + result.addAll(inXsdOrderFiltered(info.xsdOrder(), info.required(), attrMap, true)); + result.addAll(inXsdOrderFiltered(info.xsdOrder(), info.required(), attrMap, false)); + result.addAll(unknownAttrs(attrMap, xsdSet)); + return result; + } + + private static Map buildAttrMap(Attributes attrs) { + Map map = new LinkedHashMap<>(); + for (int i = 0; i < attrs.getLength(); i++) { + map.put(attrs.getQName(i), attrs.getValue(i)); + } + return map; + } + + private static List inXsdOrderFiltered(List xsdOrder, Set required, + Map attrMap, boolean requiredOnly) { + List result = new ArrayList<>(); + for (String name : xsdOrder) { + if (required.contains(name) != requiredOnly) continue; + String value = attrMap.get(name); + if (value != null) result.add(pair(name, value)); + } + return result; + } + + private static List unknownAttrs(Map attrMap, Set xsdSet) { + return attrMap.entrySet().stream() + .filter(e -> !xsdSet.contains(e.getKey()) && !e.getKey().contains(":")) + .map(e -> pair(e.getKey(), e.getValue())) + .sorted(Comparator.comparing(a -> a[0])) + .collect(Collectors.toList()); + } + + private static List namespacedAttrs(Map attrMap) { + return attrMap.entrySet().stream() + .filter(e -> e.getKey().contains(":")) + .map(e -> pair(e.getKey(), e.getValue())) + .sorted(Comparator.comparing(a -> a[0])) + .collect(Collectors.toList()); + } + + private static String[] pair(String name, String value) { + return new String[]{name, value}; + } + + private OrderInfo computeOrderInfo(String elementName) { + Element typeNode = findComplexType(elementName + "Type"); + if (typeNode == null) return new OrderInfo(Collections.emptyList(), Collections.emptySet()); + Set requiredSet = new HashSet<>(); + List ordered = new ArrayList<>(new LinkedHashSet<>(collect(typeNode, new HashSet<>(), requiredSet))); + return new OrderInfo(ordered, requiredSet); + } + + private List collect(Element node, Set visited, Set requiredSet) { + List baseAttrs = Collections.emptyList(); + List ownAttrs = new ArrayList<>(); + + NodeList children = node.getChildNodes(); + for (int i = 0; i < children.getLength(); i++) { + Node child = children.item(i); + if (child.getNodeType() != Node.ELEMENT_NODE) continue; + Element elem = (Element) child; + String local = elem.getLocalName(); + + if (XSD_ATTRIBUTE.equals(local)) { + collectAttribute(elem, ownAttrs, requiredSet); + } else if (XSD_EXTENSION.equals(local)) { + baseAttrs = resolveBaseAttributes(elem, visited, requiredSet); + ownAttrs.addAll(collect(elem, visited, requiredSet)); + } else { + ownAttrs.addAll(collectGeneric(elem, visited, requiredSet)); + } + } + + List result = new ArrayList<>(baseAttrs.size() + ownAttrs.size()); + result.addAll(baseAttrs); + result.addAll(ownAttrs); + return result; + } + + private static void collectAttribute(Element attributeElem, List target, Set requiredSet) { + String name = attributeElem.getAttribute("name"); + if (!name.isEmpty()) { + target.add(name); + if ("required".equals(attributeElem.getAttribute("use"))) { + requiredSet.add(name); + } + } + } + + private List collectGeneric(Element elem, Set visited, Set requiredSet) { + String ref = elem.getAttribute("ref"); + if (ref.isEmpty()) { + return collect(elem, visited, requiredSet); + } + if (!visited.add(ref)) { + return Collections.emptyList(); + } + Element refDef = findDefinition(ref, elem.getLocalName()); + return refDef != null ? collect(refDef, visited, requiredSet) : Collections.emptyList(); + } + + private Element findDefinition(String ref, String localName) { + Map defs = index.get(localName); + return defs != null ? defs.get(ref) : null; + } + + private List resolveBaseAttributes(Element extensionElem, Set visited, Set requiredSet) { + String base = extensionElem.getAttribute("base"); + if (base.isEmpty() || !visited.add(base)) return Collections.emptyList(); + Element baseType = findComplexType(base); + return baseType != null ? collect(baseType, visited, requiredSet) : Collections.emptyList(); + } + + private Element findComplexType(String name) { + Map types = index.get("complexType"); + return types != null ? types.get(name) : null; + } +} diff --git a/src/test/java/org/frankframework/flow/configuration/ConfigurationControllerTest.java b/src/test/java/org/frankframework/flow/configuration/ConfigurationControllerTest.java index e935e8f8..8bc4ebe6 100644 --- a/src/test/java/org/frankframework/flow/configuration/ConfigurationControllerTest.java +++ b/src/test/java/org/frankframework/flow/configuration/ConfigurationControllerTest.java @@ -94,7 +94,7 @@ void updateConfigurationSuccessReturns200() throws Exception { String filepath = "config1.xml"; String xmlContent = "updated"; - when(configurationService.updateConfiguration(TEST_PROJECT_NAME, filepath, xmlContent)) + when(configurationService.updateConfiguration(TEST_PROJECT_NAME, filepath, xmlContent, false)) .thenReturn(xmlContent); mockMvc.perform( @@ -103,7 +103,7 @@ void updateConfigurationSuccessReturns200() throws Exception { .content("updated")) .andExpect(status().isOk()); - verify(configurationService).updateConfiguration(TEST_PROJECT_NAME, filepath, xmlContent); + verify(configurationService).updateConfiguration(TEST_PROJECT_NAME, filepath, xmlContent, false); } @Test @@ -113,7 +113,7 @@ void updateConfigurationNotFoundReturns404() throws Exception { doThrow(new ConfigurationNotFoundException("Invalid file path: " + filepath)) .when(configurationService) - .updateConfiguration(TEST_PROJECT_NAME, filepath, xmlContent); + .updateConfiguration(TEST_PROJECT_NAME, filepath, xmlContent, false); mockMvc.perform( put("/api/projects/" + TEST_PROJECT_NAME + "/configuration?path=" + filepath) @@ -123,7 +123,7 @@ void updateConfigurationNotFoundReturns404() throws Exception { .andExpect(jsonPath("$.httpStatus").value(404)) .andExpect(jsonPath("$.messages[0]").value("Invalid file path: " + filepath)); - verify(configurationService).updateConfiguration(TEST_PROJECT_NAME, filepath, xmlContent); + verify(configurationService).updateConfiguration(TEST_PROJECT_NAME, filepath, xmlContent, false); } @Test diff --git a/src/test/java/org/frankframework/flow/configuration/ConfigurationServiceTest.java b/src/test/java/org/frankframework/flow/configuration/ConfigurationServiceTest.java index f13c4902..ed3669df 100644 --- a/src/test/java/org/frankframework/flow/configuration/ConfigurationServiceTest.java +++ b/src/test/java/org/frankframework/flow/configuration/ConfigurationServiceTest.java @@ -12,14 +12,17 @@ import org.frankframework.flow.exception.ApiException; import org.frankframework.flow.file.FileTreeService; import org.frankframework.flow.filesystem.FileSystemStorage; +import org.frankframework.flow.frankconfig.FrankConfigXsdService; import org.frankframework.flow.project.Project; import org.frankframework.flow.project.ProjectNotFoundException; import org.frankframework.flow.project.ProjectService; +import org.frankframework.flow.utility.XmlFormatterUtils; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.io.TempDir; import org.mockito.Mock; +import org.mockito.MockedStatic; import org.mockito.junit.jupiter.MockitoExtension; @ExtendWith(MockitoExtension.class) @@ -31,6 +34,9 @@ class ConfigurationServiceTest { @Mock private ProjectService projectService; + @Mock + private FrankConfigXsdService frankConfigXsdService; + @Mock private FileTreeService fileTreeService; @@ -41,7 +47,7 @@ class ConfigurationServiceTest { @BeforeEach void setUp() { - configurationService = new ConfigurationService(fileSystemStorage, projectService, fileTreeService); + configurationService = new ConfigurationService(fileSystemStorage, projectService, frankConfigXsdService, fileTreeService); } private void stubToAbsolutePath() { @@ -114,7 +120,7 @@ void updateConfiguration_Success() throws Exception { Path file = tempDir.resolve("config.xml"); Files.writeString(file, "", StandardCharsets.UTF_8); - configurationService.updateConfiguration("test", file.toString(), ""); + configurationService.updateConfiguration("test", file.toString(), "", false); String result = Files.readString(file, StandardCharsets.UTF_8).trim(); assertEquals("", result); @@ -129,7 +135,7 @@ void updateConfiguration_FileNotFound_ThrowsConfigurationNotFoundException() { assertThrows( ApiException.class, - () -> configurationService.updateConfiguration("test", path, "") + () -> configurationService.updateConfiguration("test", path, "", false) ); } @@ -172,4 +178,43 @@ void addConfiguration_PathTraversal_ThrowsSecurityException() throws Exception { assertThrows( ApiException.class, () -> configurationService.addConfiguration("myproject", "../../../evil.xml")); } + + @Test + void updateConfiguration_FormatTrue_CallsFormatter() throws Exception { + stubToAbsolutePath(); + stubWriteFile(); + + Path file = tempDir.resolve("config.xml"); + Files.writeString(file, "", StandardCharsets.UTF_8); + + try (MockedStatic mockedFormatter = mockStatic(XmlFormatterUtils.class)) { + mockedFormatter.when(() -> XmlFormatterUtils.format(anyString(), any())) + .thenReturn(""); + + String result = configurationService.updateConfiguration("test", file.toString(), "", true); + + assertEquals("", result); + mockedFormatter.verify(() -> XmlFormatterUtils.format(anyString(), any())); + verify(fileSystemStorage).writeFile(eq(file.toString()), eq("")); + } + } + + @Test + void updateConfiguration_FormatTrue_AddsNamespaceIfMissing() throws Exception { + stubToAbsolutePath(); + stubWriteFile(); + + Path file = tempDir.resolve("config.xml"); + Files.writeString(file, "", StandardCharsets.UTF_8); + + try (MockedStatic mockedFormatter = mockStatic(XmlFormatterUtils.class)) { + mockedFormatter.when(() -> XmlFormatterUtils.format(contains("xmlns:flow"), any())) + .thenReturn(""); + + String result = configurationService.updateConfiguration("test", file.toString(), "", true); + + assertTrue(result.contains("xmlns:flow")); + mockedFormatter.verify(() -> XmlFormatterUtils.format(contains("xmlns:flow"), any())); + } + } } diff --git a/src/test/java/org/frankframework/flow/utility/XmlFormatterUtilsTest.java b/src/test/java/org/frankframework/flow/utility/XmlFormatterUtilsTest.java new file mode 100644 index 00000000..d718ed05 --- /dev/null +++ b/src/test/java/org/frankframework/flow/utility/XmlFormatterUtilsTest.java @@ -0,0 +1,101 @@ +package org.frankframework.flow.utility; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.Test; + +class XmlFormatterUtilsTest { + + @Test + void singleEmptyElement() throws Exception { + assertEquals("", XmlFormatterUtils.format("")); + } + + @Test + void singleElementWithText() throws Exception { + assertEquals("hello\n", XmlFormatterUtils.format("hello")); + } + + @Test + void singleAttribute() throws Exception { + assertEquals("", XmlFormatterUtils.format("")); + } + + @Test + void multipleAttributesAlignedUnderFirst() throws Exception { + String result = XmlFormatterUtils.format(""); + String expected = ""; + assertEquals(expected, result); + } + + @Test + void nestedElements() throws Exception { + String expected = "\n \n"; + assertEquals(expected, XmlFormatterUtils.format("")); + } + + @Test + void deeplyNestedElements() throws Exception { + String expected = "\n \n \n \n"; + assertEquals(expected, XmlFormatterUtils.format("")); + } + + @Test + void siblingsProduceNoBlankLines() throws Exception { + String result = XmlFormatterUtils.format(""); + assertFalse(result.contains("\n\n"), "Blank lines should not appear between siblings"); + assertTrue(result.contains(" ")); + assertTrue(result.contains(" ")); + assertTrue(result.contains(" ")); + } + + @Test + void commentIsPreserved() throws Exception { + String result = XmlFormatterUtils.format(""); + assertTrue(result.contains("")); + } + + @Test + void commentIndentMatchesDepth() throws Exception { + String result = XmlFormatterUtils.format(""); + assertTrue(result.contains(" ")); + } + + @Test + void attributeWithSpecialCharsEscaped() throws Exception { + String result = XmlFormatterUtils.format(""); + assertTrue(result.contains("val=\"a&b\"")); + } + + @Test + void textWithAmpersandEscaped() throws Exception { + String result = XmlFormatterUtils.format("a&b"); + assertTrue(result.contains("a&b")); + } + + @Test + void whitespaceOnlyTextIsIgnored() throws Exception { + assertEquals("", XmlFormatterUtils.format(" ")); + } + + @Test + void alreadyFormattedInputIsIdempotent() throws Exception { + String input = "\n \n"; + assertEquals(input, XmlFormatterUtils.format(input)); + } + + @Test + void multipleTopLevelSiblingChildrenNoBlankLines() throws Exception { + String result = XmlFormatterUtils.format(""); + assertFalse(result.contains("\n\n")); + } + + @Test + void xxeExternalEntityIsNotExpanded() throws Exception { + String xxe = "" + + "]>" + + "&xxe;"; + String result = XmlFormatterUtils.format(xxe); + assertFalse(result.contains("root:"), "External entity content should not be expanded"); + } +} diff --git a/src/test/java/org/frankframework/flow/utility/XsdAttributeOrdererUtilsTest.java b/src/test/java/org/frankframework/flow/utility/XsdAttributeOrdererUtilsTest.java new file mode 100644 index 00000000..d420df9d --- /dev/null +++ b/src/test/java/org/frankframework/flow/utility/XsdAttributeOrdererUtilsTest.java @@ -0,0 +1,210 @@ +package org.frankframework.flow.utility; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.List; +import javax.xml.parsers.DocumentBuilderFactory; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.xml.sax.helpers.AttributesImpl; + +public class XsdAttributeOrdererUtilsTest { + private XsdAttributeOrdererUtils orderer; + private Document doc; + + @BeforeEach + public void setUp() throws Exception { + doc = DocumentBuilderFactory.newInstance().newDocumentBuilder().newDocument(); + Element root = doc.createElementNS("http://www.w3.org/2001/XMLSchema", "schema"); + doc.appendChild(root); + orderer = new XsdAttributeOrdererUtils(doc); + } + + @Test + public void testReorder_EmptyAttributes_ReturnsEmpty() { + List result = orderer.reorder("unknown", new AttributesImpl()); + assertTrue(result.isEmpty()); + } + + @Test + public void testReorder_UnknownElement_ReturnsAlphabetical() { + AttributesImpl attrs = new AttributesImpl(); + attrs.addAttribute("", "", "z", "", "valZ"); + attrs.addAttribute("", "", "a", "", "valA"); + + List result = orderer.reorder("missingType", attrs); + + assertEquals("a", result.get(0)[0]); + assertEquals("z", result.get(1)[0]); + } + + @Test + public void testReorder_MixedKnownAndUnknown_OrderMaintained() { + addComplexType("known"); + AttributesImpl attrs = new AttributesImpl(); + attrs.addAttribute("", "", "unknown", "", "valU"); + attrs.addAttribute("", "", "known", "", "valK"); + + List result = orderer.reorder("my", attrs); + + assertEquals("known", result.get(0)[0]); + assertEquals("unknown", result.get(1)[0]); + } + + @Test + public void testReorder_WithNamespacedAttributes() { + AttributesImpl attrs = new AttributesImpl(); + attrs.addAttribute("", "", "flow:id", "", "1"); + attrs.addAttribute("", "", "normal", "", "value"); + + List result = orderer.reorder("any", attrs); + + assertEquals("flow:id", result.get(0)[0]); + assertEquals("normal", result.get(1)[0]); + } + + @Test + public void testReorder_WithCircularDependency_DoesNotStackOverflow() { + Element ct = doc.createElementNS("http://www.w3.org/2001/XMLSchema", "complexType"); + ct.setAttribute("name", "loopType"); + doc.getDocumentElement().appendChild(ct); + + Element ext = doc.createElementNS("http://www.w3.org/2001/XMLSchema", "extension"); + ext.setAttribute("base", "loopType"); + ct.appendChild(ext); + + AttributesImpl attrs = new AttributesImpl(); + attrs.addAttribute("", "", "test", "", "val"); + + assertDoesNotThrow(() -> orderer.reorder("loop", attrs)); + } + + @Test + public void testReorder_AttributesInXsdButMissingInXml_AreIgnored() { + addComplexType("attrA", "attrB"); + + AttributesImpl attrs = new AttributesImpl(); + attrs.addAttribute("", "", "attrB", "", "valB"); + + List result = orderer.reorder("my", attrs); + + assertEquals(1, result.size()); + assertEquals("attrB", result.getFirst()[0]); + } + + @Test + void testReorder_RequiredBeforeOptional_RegardlessOfInputOrder() { + // XSD defines optional first, then required - required must still come first in output + Element ct = addComplexTypeElement("my"); + addAttrElement(ct, "optionalAttr", false); + addAttrElement(ct, "requiredAttr", true); + orderer = new XsdAttributeOrdererUtils(doc); + + AttributesImpl attrs = new AttributesImpl(); + attrs.addAttribute("", "", "optionalAttr", "", "valOpt"); + attrs.addAttribute("", "", "requiredAttr", "", "valReq"); + + List result = orderer.reorder("my", attrs); + + assertEquals(2, result.size()); + assertEquals("requiredAttr", result.get(0)[0], "Required attr must come before optional"); + assertEquals("optionalAttr", result.get(1)[0], "Optional attr comes after required"); + } + + @Test + void testReorder_RequiredAttrsInXsdOrder_NotAlphabetical() { + // XSD order: z_required, a_required - both required, must keep XSD order (z before a) + Element ct = addComplexTypeElement("my"); + addAttrElement(ct, "z_required", true); + addAttrElement(ct, "a_required", true); + orderer = new XsdAttributeOrdererUtils(doc); + + AttributesImpl attrs = new AttributesImpl(); + attrs.addAttribute("", "", "a_required", "", "val"); + attrs.addAttribute("", "", "z_required", "", "val"); + + List result = orderer.reorder("my", attrs); + + assertEquals(2, result.size()); + assertEquals("z_required", result.get(0)[0], "First required in XSD order (z comes before a in XSD)"); + assertEquals("a_required", result.get(1)[0], "Second required in XSD order"); + } + + @Test + void testReorder_OptionalAttrsInXsdOrder_NotAlphabetical() { + // XSD order: z_opt, a_opt - both optional, must keep XSD order (z before a) + Element ct = addComplexTypeElement("my"); + addAttrElement(ct, "z_opt", false); + addAttrElement(ct, "a_opt", false); + orderer = new XsdAttributeOrdererUtils(doc); + + AttributesImpl attrs = new AttributesImpl(); + attrs.addAttribute("", "", "a_opt", "", "val"); + attrs.addAttribute("", "", "z_opt", "", "val"); + + List result = orderer.reorder("my", attrs); + + assertEquals(2, result.size()); + assertEquals("z_opt", result.get(0)[0], "First optional in XSD order (z comes before a in XSD)"); + assertEquals("a_opt", result.get(1)[0], "Second optional in XSD order"); + } + + @Test + void testReorder_MixedRequiredOptional_XsdOrderWithinGroups() { + // XSD order: opt1, req1, opt2, req2 - required group: req1, req2; optional group: opt1, opt2 + Element ct = addComplexTypeElement("my"); + addAttrElement(ct, "opt1", false); + addAttrElement(ct, "req1", true); + addAttrElement(ct, "opt2", false); + addAttrElement(ct, "req2", true); + orderer = new XsdAttributeOrdererUtils(doc); + + AttributesImpl attrs = new AttributesImpl(); + attrs.addAttribute("", "", "opt2", "", "v"); + attrs.addAttribute("", "", "req2", "", "v"); + attrs.addAttribute("", "", "opt1", "", "v"); + attrs.addAttribute("", "", "req1", "", "v"); + + List result = orderer.reorder("my", attrs); + + assertEquals(4, result.size()); + assertEquals("req1", result.get(0)[0], "First required in XSD order"); + assertEquals("req2", result.get(1)[0], "Second required in XSD order"); + assertEquals("opt1", result.get(2)[0], "First optional in XSD order"); + assertEquals("opt2", result.get(3)[0], "Second optional in XSD order"); + } + + @Test + public void testReorder_HandlesEmptyNamespaceAttributes() { + AttributesImpl attrs = new AttributesImpl(); + attrs.addAttribute(null, null, "attr", "", "val"); + + List result = orderer.reorder("any", attrs); + + assertEquals(1, result.size()); + assertEquals("attr", result.getFirst()[0]); + } + + private void addComplexType(String... attributes) { + Element ct = addComplexTypeElement("my"); + for (String attr : attributes) { + addAttrElement(ct, attr, false); + } + } + + private Element addComplexTypeElement(String elementName) { + Element ct = doc.createElementNS("http://www.w3.org/2001/XMLSchema", "complexType"); + ct.setAttribute("name", elementName + "Type"); + doc.getDocumentElement().appendChild(ct); + return ct; + } + + private void addAttrElement(Element parent, String name, boolean required) { + Element a = doc.createElementNS("http://www.w3.org/2001/XMLSchema", "attribute"); + a.setAttribute("name", name); + if (required) a.setAttribute("use", "required"); + parent.appendChild(a); + } +}