Skip to content

Commit 150fead

Browse files
committed
Enhance configuration saving with optional XML formatting and improve attribute ordering
1 parent 4bdd3f4 commit 150fead

11 files changed

Lines changed: 295 additions & 177 deletions

File tree

src/main/frontend/app/routes/studio/canvas/flow.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,7 @@ function FlowCanvas() {
165165

166166
const updatedConfigXml = replaceAdapterInXml(fullConfigXml, adapterIndex, newAdapterXml.trim())
167167

168-
await saveConfiguration(currentProject.name, configurationPath, updatedConfigXml)
168+
await saveConfiguration(currentProject.name, configurationPath, updatedConfigXml, true)
169169
clearConfigurationCache(currentProject.name, configurationPath)
170170
useEditorTabStore.getState().refreshAllTabs()
171171
if (currentProject.isGitRepository) await refreshOpenDiffs(currentProject.name)

src/main/frontend/app/routes/studio/flow-to-xml-parser.ts

Lines changed: 20 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,6 @@ import { getAdapter } from '~/services/adapter-service'
55
import { FlowConfig } from './canvas/flow.config'
66
import { isGroupNode, isStickyNote } from '~/stores/flow-store'
77
import type { GroupNode } from './canvas/nodetypes/group-node'
8-
import { fetchFrankConfigXsd } from '~/services/xsd-service'
9-
import { getMandatoryAttributeNames, parseXsd } from '~/utils/xsd-utils'
10-
import { sortAttributes } from '~/utils/xml-attribute-sort'
118

129
interface ReactFlowJson {
1310
nodes: FlowNode[]
@@ -45,14 +42,6 @@ export async function exportFlowToXml(
4542
: existingAdapterXml
4643
const adapterAttributes = getAdapterAttributes(adapterXml)
4744

48-
let xsdDoc: Document | null = null
49-
50-
try {
51-
xsdDoc = parseXsd(await fetchFrankConfigXsd())
52-
} catch {
53-
console.warn('Could not fetch FrankConfig XSD; attribute order may not be optimal.')
54-
}
55-
5645
const { nodes, edges } = json
5746
const validNodes = nodes.filter((node) => hasDataProperty(node))
5847
const nodeMap = new Map(validNodes.map((n) => [n.id, n]))
@@ -87,13 +76,13 @@ export async function exportFlowToXml(
8776

8877
const type = node.data.type?.toLowerCase()
8978
if (type === 'receiver') {
90-
receivers.push(generateXmlElement(node, edgeMap, exitNodeIds, nodeMap, xsdDoc))
79+
receivers.push(generateXmlElement(node, edgeMap, exitNodeIds, nodeMap))
9180
} else if (type === 'pipe') {
92-
pipelineParts.push(generateXmlElement(node, edgeMap, exitNodeIds, nodeMap, xsdDoc))
81+
pipelineParts.push(generateXmlElement(node, edgeMap, exitNodeIds, nodeMap))
9382
}
9483
}
9584

96-
const exitsXml = exitNodes.length > 0 ? ` <Exits>\n${generateExitsXml(exitNodes, xsdDoc)}\n </Exits>` : ''
85+
const exitsXml = exitNodes.length > 0 ? ` <Exits>\n${generateExitsXml(exitNodes)}\n </Exits>` : ''
9786
const flowXml = generateFlowElementsXml(nodes)
9887

9988
return `
@@ -108,18 +97,17 @@ ${pipelineParts.join('\n')}
10897
}
10998

11099
export function replaceAdapterInXml(configXml: string, adapterIndex: number, newAdapterXml: string): string {
111-
const starts = [...configXml.matchAll(/<[Aa]dapter\b/g)].map((m) => m.index)
100+
const matches = [...configXml.matchAll(/<(Adapter|adapter)\b/g)]
112101

113-
if (adapterIndex >= starts.length) return configXml
102+
if (adapterIndex >= matches.length) return configXml
114103

115-
const start = starts[adapterIndex]
116-
const closeRegex = /<\/[Aa]dapter>/g
117-
closeRegex.lastIndex = start
104+
const match = matches[adapterIndex]
105+
const start = match.index
106+
const closingTag = `</${match[1]}>`
107+
const closeIndex = configXml.indexOf(closingTag, start)
108+
if (closeIndex === -1) return configXml
118109

119-
const closeMatch = closeRegex.exec(configXml)
120-
if (!closeMatch) return configXml
121-
122-
return configXml.slice(0, start) + newAdapterXml + configXml.slice(closeMatch.index + closeMatch[0].length)
110+
return configXml.slice(0, start) + newAdapterXml + configXml.slice(closeIndex + closingTag.length)
123111
}
124112

125113
function buildEdgeMaps(edges: Edge[]) {
@@ -178,7 +166,6 @@ function generateXmlElement(
178166
edgeMap: Map<string, { targetId: string; label: string }[]>,
179167
exitNodeIds: Set<string>,
180168
nodeMap: Map<string, FlowNode>,
181-
xsdDoc: Document | null,
182169
): string {
183170
const { subtype, name } = node.data as NodeData
184171
const { x, y } = node.position
@@ -190,7 +177,7 @@ function generateXmlElement(
190177
width = node.measured.width
191178
}
192179

193-
const height: number | undefined = node.height ?? undefined
180+
const height: number | null = node.height ?? null
194181
const attributes = (node.data as NodeData).attributes || {}
195182
const children = (node.data as NodeData).children || []
196183

@@ -200,14 +187,13 @@ function generateXmlElement(
200187
'flow:x': String(roundedX),
201188
'flow:y': String(roundedY),
202189
'flow:width': String(width),
203-
...(height === undefined ? {} : { 'flow:height': String(height) }),
190+
...(height === null ? {} : { 'flow:height': String(height) }),
204191
}
205-
const mandatory = xsdDoc ? getMandatoryAttributeNames(xsdDoc, subtype) : new Set<string>()
206-
const attrStr = sortAttributes(allAttrs, mandatory)
192+
const attrStr = Object.entries(allAttrs)
207193
.map(([k, v]) => `${k}="${escapeXml(v)}"`)
208194
.join(' ')
209195

210-
const childXml = children.map((child: ChildNode) => generateChildXml(child, 4, xsdDoc)).join('\n')
196+
const childXml = children.map((child: ChildNode) => generateChildXml(child, 4)).join('\n')
211197

212198
const type = (node.data as NodeData).type?.toLowerCase()
213199

@@ -232,16 +218,15 @@ function generateXmlElement(
232218
return content ? ` <${subtype} ${attrStr} >\n${content}\n </${subtype}>` : ` <${subtype} ${attrStr} />`
233219
}
234220

235-
function generateChildXml(child: ChildNode, indent: number, xsdDoc: Document | null): string {
221+
function generateChildXml(child: ChildNode, indent: number): string {
236222
const spaces = ' '.repeat(indent)
237223

238224
const childAttrs: Record<string, string> = {
239225
...(child.name ? { name: child.name } : {}),
240226
...child.attributes,
241227
}
242228

243-
const mandatory = xsdDoc ? getMandatoryAttributeNames(xsdDoc, child.subtype) : new Set<string>()
244-
const attrStr = sortAttributes(childAttrs, mandatory)
229+
const attrStr = Object.entries(childAttrs)
245230
.map(([k, v]) => `${k}="${escapeXml(v)}"`)
246231
.join(' ')
247232

@@ -252,16 +237,14 @@ function generateChildXml(child: ChildNode, indent: number, xsdDoc: Document | n
252237
return `${spaces}<${child.subtype}${attrs}/>`
253238
}
254239

255-
const childXmlStrings = child.children!.map((nested) => generateChildXml(nested, indent + 2, xsdDoc))
240+
const childXmlStrings = child.children!.map((nested) => generateChildXml(nested, indent + 2))
256241

257242
return `${spaces}<${child.subtype}${attrs}>
258243
${childXmlStrings.join('\n')}
259244
${spaces}</${child.subtype}>`
260245
}
261246

262-
function generateExitsXml(exitNodes: FlowNode[], xsdDoc: Document | null): string {
263-
const mandatory = xsdDoc ? getMandatoryAttributeNames(xsdDoc, 'Exit') : new Set<string>()
264-
247+
function generateExitsXml(exitNodes: FlowNode[]): string {
265248
return exitNodes
266249
.map((node) => {
267250
const { name } = node.data as NodeData
@@ -284,7 +267,7 @@ function generateExitsXml(exitNodes: FlowNode[], xsdDoc: Document | null): strin
284267
'flow:width': String(width),
285268
'flow:height': String(height),
286269
}
287-
const attrStr = sortAttributes(allAttrs, mandatory)
270+
const attrStr = Object.entries(allAttrs)
288271
.map(([k, v]) => `${k}="${escapeXml(v)}"`)
289272
.join(' ')
290273

src/main/frontend/app/services/configuration-service.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,14 @@ export async function fetchConfiguration(projectName: string, filepath: string,
3131
return content
3232
}
3333

34-
export async function saveConfiguration(projectName: string, filepath: string, content: string): Promise<XmlResponse> {
35-
return apiFetch<XmlResponse>(`${getBaseUrl(projectName)}?path=${encodeURIComponent(filepath)}`, {
34+
export async function saveConfiguration(
35+
projectName: string,
36+
filepath: string,
37+
content: string,
38+
format = false,
39+
): Promise<XmlResponse> {
40+
const formatParam = format ? '&format=true' : ''
41+
return apiFetch<XmlResponse>(`${getBaseUrl(projectName)}?path=${encodeURIComponent(filepath)}${formatParam}`, {
3642
method: 'PUT',
3743
body: content,
3844
})

src/main/frontend/app/utils/xml-attribute-sort.ts

Lines changed: 0 additions & 35 deletions
This file was deleted.

src/main/frontend/app/utils/xsd-utils.ts

Lines changed: 0 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -386,57 +386,6 @@ function getRequiredOnly(requirement: Requirement): Requirement | null {
386386
return null
387387
}
388388

389-
/**
390-
* Returns the set of attribute names that are required (`use="required"`) for the
391-
* given element name in the XSD. Walks the type hierarchy via `xs:extension` so
392-
* attributes inherited from base types are included.
393-
*/
394-
export function getMandatoryAttributeNames(doc: Document, elementName: string): Set<string> {
395-
const typeNode = getComplexTypeByName(doc, `${elementName}Type`)
396-
if (!typeNode) return new Set()
397-
return collectRequiredAttributes(doc, typeNode, new Set())
398-
}
399-
400-
/**
401-
* Recursively walks a complex-type node (and any base types it extends) to collect
402-
* all mandatory attribute names`.
403-
*/
404-
function collectRequiredAttributes(doc: Document, node: Element, visited: Set<string>): Set<string> {
405-
const result = new Set<string>()
406-
407-
for (const child of node.children) {
408-
switch (child.localName) {
409-
case 'attribute': {
410-
const name = child.getAttribute('name')
411-
if (name && child.getAttribute('use') === 'required') result.add(name)
412-
break
413-
}
414-
case 'extension': {
415-
const base = child.getAttribute('base')
416-
if (base && !visited.has(base)) {
417-
visited.add(base)
418-
const baseType = getComplexTypeByName(doc, base)
419-
if (baseType) {
420-
for (const a of collectRequiredAttributes(doc, baseType, visited)) result.add(a)
421-
}
422-
}
423-
for (const a of collectRequiredAttributes(doc, child, visited)) result.add(a)
424-
break
425-
}
426-
case 'complexContent':
427-
case 'simpleContent':
428-
case 'sequence':
429-
case 'all':
430-
case 'choice': {
431-
for (const a of collectRequiredAttributes(doc, child, visited)) result.add(a)
432-
break
433-
}
434-
}
435-
}
436-
437-
return result
438-
}
439-
440389
interface RequirementBase {
441390
kind: 'element' | 'group'
442391
}

src/main/java/org/frankframework/flow/configuration/ConfigurationController.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,10 @@ public ResponseEntity<ConfigurationDTO> getConfigurationByPath(
4141
public ResponseEntity<XmlDTO> updateConfiguration(
4242
@PathVariable String projectName,
4343
@RequestParam String path,
44+
@RequestParam(defaultValue = "false") boolean format,
4445
@RequestBody String content
4546
) throws ApiException {
46-
String updatedContent = configurationService.updateConfiguration(projectName, path, content);
47+
String updatedContent = configurationService.updateConfiguration(projectName, path, content, format);
4748
XmlDTO xmlDTO = new XmlDTO(updatedContent);
4849
return ResponseEntity.ok(xmlDTO);
4950
}

src/main/java/org/frankframework/flow/configuration/ConfigurationService.java

Lines changed: 51 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,76 @@
11
package org.frankframework.flow.configuration;
22

33
import java.io.IOException;
4+
import java.io.StringReader;
45
import java.nio.charset.StandardCharsets;
56
import java.nio.file.Files;
67
import java.nio.file.Path;
78
import javax.xml.parsers.ParserConfigurationException;
89
import javax.xml.transform.TransformerException;
10+
import lombok.extern.slf4j.Slf4j;
911
import org.frankframework.flow.exception.ApiException;
1012
import org.frankframework.flow.filesystem.FileSystemStorage;
13+
import org.frankframework.flow.frankconfig.FrankConfigXsdNotFoundException;
14+
import org.frankframework.flow.frankconfig.FrankConfigXsdService;
1115
import org.frankframework.flow.project.Project;
1216
import org.frankframework.flow.project.ProjectService;
1317
import org.frankframework.flow.utility.XmlConfigurationUtils;
18+
import org.frankframework.flow.utility.XmlFormatterUtils;
19+
import org.frankframework.flow.utility.XmlSecurityUtils;
20+
import org.frankframework.flow.utility.XsdAttributeOrdererUtils;
1421
import org.springframework.core.io.ClassPathResource;
1522
import org.springframework.http.HttpStatus;
1623
import org.springframework.stereotype.Service;
1724
import org.w3c.dom.Document;
25+
import org.xml.sax.InputSource;
1826
import org.xml.sax.SAXException;
1927

28+
@Slf4j
2029
@Service
2130
public class ConfigurationService {
2231

2332
private static final String CONFIGURATIONS_DIR = "src/main/configurations";
2433

2534
private final FileSystemStorage fileSystemStorage;
2635
private final ProjectService projectService;
36+
private final FrankConfigXsdService frankConfigXsdService;
2737

28-
public ConfigurationService(FileSystemStorage fileSystemStorage, ProjectService projectService) {
38+
private volatile XsdAttributeOrdererUtils xsdOrderer;
39+
private volatile boolean xsdLoadAttempted = false;
40+
41+
public ConfigurationService(
42+
FileSystemStorage fileSystemStorage,
43+
ProjectService projectService,
44+
FrankConfigXsdService frankConfigXsdService) {
2945
this.fileSystemStorage = fileSystemStorage;
3046
this.projectService = projectService;
47+
this.frankConfigXsdService = frankConfigXsdService;
48+
}
49+
50+
private XsdAttributeOrdererUtils getXsdOrderer() {
51+
if (!xsdLoadAttempted) {
52+
synchronized (this) {
53+
if (!xsdLoadAttempted) {
54+
xsdLoadAttempted = true;
55+
xsdOrderer = loadXsdOrderer();
56+
}
57+
}
58+
}
59+
return xsdOrderer;
60+
}
61+
62+
private XsdAttributeOrdererUtils loadXsdOrderer() {
63+
try {
64+
String xsdContent = frankConfigXsdService.getFrankConfigXsd();
65+
Document doc = XmlSecurityUtils.createSecureDocumentBuilder()
66+
.parse(new InputSource(new StringReader(xsdContent)));
67+
return new XsdAttributeOrdererUtils(doc);
68+
} catch (FrankConfigXsdNotFoundException e) {
69+
log.warn("FrankConfig XSD unavailable; attribute ordering will be skipped: {}", e.getMessage());
70+
} catch (Exception e) {
71+
log.warn("Failed to parse FrankConfig XSD; attribute ordering will be skipped: {}", e.getMessage());
72+
}
73+
return null;
3174
}
3275

3376
public ConfigurationDTO getConfigurationContent(String projectName, String filepath) throws IOException, ApiException {
@@ -41,7 +84,7 @@ public ConfigurationDTO getConfigurationContent(String projectName, String filep
4184
return new ConfigurationDTO(filepath, content);
4285
}
4386

44-
public String updateConfiguration(String projectName, String filepath, String content)
87+
public String updateConfiguration(String projectName, String filepath, String content, boolean format)
4588
throws ApiException {
4689
Path absolutePath = fileSystemStorage.toAbsolutePath(filepath);
4790

@@ -55,11 +98,15 @@ public String updateConfiguration(String projectName, String filepath, String co
5598

5699
try {
57100
String withNamespace = ensureFlowNamespace(content);
58-
String formatted = XmlConfigurationUtils.formatPreservingAttributeOrder(withNamespace);
101+
if (!format) {
102+
fileSystemStorage.writeFile(absolutePath.toString(), withNamespace);
103+
return withNamespace;
104+
}
105+
String formatted = XmlFormatterUtils.format(withNamespace, getXsdOrderer());
59106
fileSystemStorage.writeFile(absolutePath.toString(), formatted);
60107
return formatted;
61108
} catch (Exception e) {
62-
throw new ApiException("Failed to format configuration: " + e.getMessage(), HttpStatus.BAD_REQUEST);
109+
throw new ApiException("Failed to save configuration: " + e.getMessage(), HttpStatus.BAD_REQUEST);
63110
}
64111
}
65112

0 commit comments

Comments
 (0)