diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 073500b1..859abb4e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,6 +44,9 @@ importers: isbot: specifier: ^5.1.17 version: 5.1.32 + monaco-xsd-code-completion: + specifier: ^0.13.0 + version: 0.13.0 react: specifier: ^19.0.0 version: 19.2.1 @@ -65,6 +68,9 @@ importers: tailwind-merge: specifier: ^3.3.0 version: 3.4.0 + xmllint-wasm: + specifier: ^5.1.0 + version: 5.1.0(@types/node@20.19.26) zustand: specifier: ^5.0.3 version: 5.0.9(@types/react@19.2.7)(react@19.2.1)(use-sync-external-store@1.6.0(react@19.2.1)) @@ -729,6 +735,9 @@ packages: peerDependencies: rollup: '>=2' + '@mixmark-io/domino@2.2.0': + resolution: {integrity: sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==} + '@mjackson/node-fetch-server@0.2.0': resolution: {integrity: sha512-EMlH1e30yzmTpGLQjlFmaDAjyOeZhng1/XCd7DExR8PNAnG/G1tyruZxEoUe11ClnwGhGrtsdnyyUx1frSzjng==} @@ -756,6 +765,9 @@ packages: '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + '@prettier/plugin-xml@0.12.0': + resolution: {integrity: sha512-196oXlmim2SiqeG1jQO5aS/nChI85DvyfIQTBkOiVHHev2j15x4TVxOMGAWHkqdlD8pmCg/KmX8SfFIyT2L7tA==} + '@radix-ui/number@1.1.1': resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} @@ -1483,6 +1495,9 @@ packages: '@types/parse-json@4.0.2': resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==} + '@types/prettier@2.7.3': + resolution: {integrity: sha512-+68kP9yzs4LMp7VNh8gdzMSPZFL44MLGqiHWvttYJe+6qnuVr4Ek9wSBQoveqY/r+LwjCcU29kNVkidwim+kYA==} + '@types/react-dom@19.2.3': resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} peerDependencies: @@ -1508,12 +1523,18 @@ packages: '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@types/turndown@5.0.6': + resolution: {integrity: sha512-ru00MoyeeouE5BX4gRL+6m/BsDfbRayOskWqUvh7CLGW+UXxHQItqALa38kKnOiZPqJrtzJUgAC2+F0rL1S4Pg==} + '@types/unist@2.0.11': resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@types/xmldom@0.1.34': + resolution: {integrity: sha512-7eZFfxI9XHYjJJuugddV6N5YNeXgQE1lArWOcd1eCOKWb/FGs5SIjacSYuEJuwhsGS3gy4RuZ5EUIcqYscuPDA==} + '@types/yauzl@2.10.3': resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} @@ -1619,6 +1640,9 @@ packages: '@vitest/utils@4.0.15': resolution: {integrity: sha512-HXjPW2w5dxhTD0dLwtYHDnelK3j8sR8cWIaLxr22evTyY6q8pRCjZSmhRWVjBaOVXChQd6AwMzi9pucorXCPZA==} + '@xml-tools/parser@1.0.11': + resolution: {integrity: sha512-aKqQ077XnR+oQtHJlrAflaZaL7qZsulWc/i/ZEooar5JiWj1eLt0+Wg28cpa+XLney107wXqneC+oG1IZvxkTA==} + '@xyflow/react@12.10.0': resolution: {integrity: sha512-eOtz3whDMWrB4KWVatIBrKuxECHqip6PfA8fTpaS2RUGVpiEAe+nqDKsLqkViVWxDGreq0lWX71Xth/SPAzXiw==} peerDependencies: @@ -1933,6 +1957,9 @@ packages: character-reference-invalid@2.0.1: resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} + chevrotain@7.1.1: + resolution: {integrity: sha512-wy3mC1x4ye+O+QkEinVJkPf5u2vsrDIYW9G7ZuwFl6v/Yu0LwUuT2POsb+NUWApebyxfkQq6+yDfRExbnI5rcw==} + chokidar@4.0.3: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} @@ -3577,6 +3604,9 @@ packages: monaco-editor@0.55.1: resolution: {integrity: sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==} + monaco-xsd-code-completion@0.13.0: + resolution: {integrity: sha512-/nnxK3NfD7WN8fig9+MTCPXnZBusbf8Wl1XS8WpgNbiC8h+Va9+Q2UVfcq5n26BYv0pQG4G+khZZuE1JTBvCVg==} + morgan@1.10.1: resolution: {integrity: sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A==} engines: {node: '>= 0.8.0'} @@ -3859,6 +3889,11 @@ packages: prettier-plugin-svelte: optional: true + prettier@2.8.8: + resolution: {integrity: sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==} + engines: {node: '>=10.13.0'} + hasBin: true + prettier@3.7.4: resolution: {integrity: sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==} engines: {node: '>=14'} @@ -4058,6 +4093,9 @@ packages: resolution: {integrity: sha512-sZuz1dYW/ZsfG17WSAG7eS85r5a0dDsvg+7BiiYR5o6lKCAtUrEwdmRmaGF6rwVj3LcmAeYkOWKEPlbPzN3Y3A==} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + regexp-to-ast@0.5.0: + resolution: {integrity: sha512-tlbJqcMHnPKI9zSrystikWKwHkBqu2a/Sgw01h3zFjvYrMxEDYHzzoMZnUrbIfpTFEsoRnnviOXNCzFiSc54Qw==} + regexp-tree@0.1.27: resolution: {integrity: sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA==} hasBin: true @@ -4492,6 +4530,9 @@ packages: peerDependencies: typescript: '>=4.8.4' + ts-debounce@2.3.0: + resolution: {integrity: sha512-j63IP7/unAzovrhVHE7U+fNkvDKwIaLH11dCO9TcRbYOZw1chPL054poqq3ZloyRJ5KwJMeB8csN/vGPPpQJjw==} + tsconfck@3.1.6: resolution: {integrity: sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==} engines: {node: ^18 || >=20} @@ -4508,6 +4549,9 @@ packages: tunnel-agent@0.6.0: resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + turndown@7.2.2: + resolution: {integrity: sha512-1F7db8BiExOKxjSMU2b7if62D/XOyQyZbPKq/nUwopfgnHlqXHqQ0lvfUTeUIr1lZJzOPFn43dODyMSIfvWRKQ==} + tweetnacl@0.14.5: resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==} @@ -4854,6 +4898,21 @@ packages: xmlchars@2.2.0: resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + xmldom@0.3.0: + resolution: {integrity: sha512-z9s6k3wxE+aZHgXYxSTpGDo7BYOUfJsIRyoZiX6HTjwpwfS2wpQBQKa2fD+ShLyPkqDYo5ud7KitmLZ2Cd6r0g==} + engines: {node: '>=10.0.0'} + deprecated: Deprecated due to CVE-2021-21366 resolved in 0.5.0 + + xmllint-wasm@5.1.0: + resolution: {integrity: sha512-6HCIJKAJWt96UzA2dgPXsnMuYQihD7U1DU9Tu3BdXqVruha1KV8nUofOxbw8f5ULgQGdNsJMwtX3dyaTHd9hQQ==} + engines: {node: '>=16'} + peerDependencies: + '@types/node': '>=16' + + xpath@0.0.29: + resolution: {integrity: sha512-W6vSxu0tmHCW01EwDXx45/BAAl8lBJjcRB6eSswMuycOVbUkYskG3W1LtCxcesVel/RaNe/pxtd3FWLiqHGweA==} + engines: {node: '>=0.6.0'} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -5534,6 +5593,8 @@ snapshots: transitivePeerDependencies: - supports-color + '@mixmark-io/domino@2.2.0': {} + '@mjackson/node-fetch-server@0.2.0': {} '@monaco-editor/loader@1.7.0': @@ -5556,6 +5617,11 @@ snapshots: '@polka/url@1.0.0-next.29': {} + '@prettier/plugin-xml@0.12.0': + dependencies: + '@xml-tools/parser': 1.0.11 + prettier: 3.7.4 + '@radix-ui/number@1.1.1': {} '@radix-ui/primitive@1.1.3': {} @@ -6232,6 +6298,8 @@ snapshots: '@types/parse-json@4.0.2': {} + '@types/prettier@2.7.3': {} + '@types/react-dom@19.2.3(@types/react@19.2.7)': dependencies: '@types/react': 19.2.7 @@ -6253,10 +6321,14 @@ snapshots: '@types/trusted-types@2.0.7': optional: true + '@types/turndown@5.0.6': {} + '@types/unist@2.0.11': {} '@types/unist@3.0.3': {} + '@types/xmldom@0.1.34': {} + '@types/yauzl@2.10.3': dependencies: '@types/node': 20.19.26 @@ -6417,6 +6489,10 @@ snapshots: '@vitest/pretty-format': 4.0.15 tinyrainbow: 3.0.3 + '@xml-tools/parser@1.0.11': + dependencies: + chevrotain: 7.1.1 + '@xyflow/react@12.10.0(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)': dependencies: '@xyflow/system': 0.0.74 @@ -6752,6 +6828,10 @@ snapshots: character-reference-invalid@2.0.1: {} + chevrotain@7.1.1: + dependencies: + regexp-to-ast: 0.5.0 + chokidar@4.0.3: dependencies: readdirp: 4.1.2 @@ -8893,6 +8973,18 @@ snapshots: dompurify: 3.2.7 marked: 14.0.0 + monaco-xsd-code-completion@0.13.0: + dependencies: + '@prettier/plugin-xml': 0.12.0 + '@types/prettier': 2.7.3 + '@types/turndown': 5.0.6 + '@types/xmldom': 0.1.34 + prettier: 2.8.8 + ts-debounce: 2.3.0 + turndown: 7.2.2 + xmldom: 0.3.0 + xpath: 0.0.29 + morgan@1.10.1: dependencies: basic-auth: 2.0.1 @@ -9111,6 +9203,8 @@ snapshots: dependencies: prettier: 3.7.4 + prettier@2.8.8: {} + prettier@3.7.4: {} pretty-bytes@5.6.0: {} @@ -9360,6 +9454,8 @@ snapshots: '@eslint-community/regexpp': 4.12.1 refa: 0.12.1 + regexp-to-ast@0.5.0: {} + regexp-tree@0.1.27: {} regexp.prototype.flags@1.5.4: @@ -9890,6 +9986,8 @@ snapshots: dependencies: typescript: 5.9.3 + ts-debounce@2.3.0: {} + tsconfck@3.1.6(typescript@5.9.3): optionalDependencies: typescript: 5.9.3 @@ -9900,6 +9998,10 @@ snapshots: dependencies: safe-buffer: 5.2.1 + turndown@7.2.2: + dependencies: + '@mixmark-io/domino': 2.2.0 + tweetnacl@0.14.5: {} type-check@0.4.0: @@ -10274,6 +10376,14 @@ snapshots: xmlchars@2.2.0: {} + xmldom@0.3.0: {} + + xmllint-wasm@5.1.0(@types/node@20.19.26): + dependencies: + '@types/node': 20.19.26 + + xpath@0.0.29: {} + y18n@5.0.8: {} yallist@3.1.1: {} diff --git a/src/main/frontend/app/app.css b/src/main/frontend/app/app.css index da4331a0..176bfb49 100644 --- a/src/main/frontend/app/app.css +++ b/src/main/frontend/app/app.css @@ -148,6 +148,13 @@ body { @apply border-l-4 border-yellow-400 bg-yellow-200/30 transition-colors; } +/* XSD validation inline error underline. + The monaco-xsd-code-completion library defines .xml-lint--fetal (typo), not .xml-lint--fatal-error, + so we define the red color ourselves here. */ +.monaco-editor .xml-lint.xml-lint--fatal-error { + border-color: #ff2424; +} + .monaco-editor .hunk-glyph-unchecked, .monaco-editor .hunk-glyph-checked { cursor: pointer !important; diff --git a/src/main/frontend/app/providers/frankconfig-xsd-provider.tsx b/src/main/frontend/app/providers/frankconfig-xsd-provider.tsx new file mode 100644 index 00000000..e17759cb --- /dev/null +++ b/src/main/frontend/app/providers/frankconfig-xsd-provider.tsx @@ -0,0 +1,28 @@ +import { createContext, useContext, useEffect, useState, type ReactNode } from 'react' +import { fetchFrankConfigXsd } from '~/services/xsd-service' + +interface FrankConfigXsdContextValue { + xsdContent: string | null +} + +const FrankConfigXsdContext = createContext(null) + +export function FrankConfigXsdProvider({ children }: { children: ReactNode }) { + const [xsdContent, setXsdContent] = useState(null) + + useEffect(() => { + fetchFrankConfigXsd() + .then(setXsdContent) + .catch((error) => console.error('Failed to load FrankConfig XSD:', error)) + }, []) + + return {children} +} + +export function useFrankConfigXsd(): FrankConfigXsdContextValue { + const context = useContext(FrankConfigXsdContext) + if (!context) { + throw new Error('useFrankConfigXsd must be used within a FrankConfigXsdProvider') + } + return context +} diff --git a/src/main/frontend/app/routes/app-layout.tsx b/src/main/frontend/app/routes/app-layout.tsx index ad91b665..185197bd 100644 --- a/src/main/frontend/app/routes/app-layout.tsx +++ b/src/main/frontend/app/routes/app-layout.tsx @@ -1,5 +1,6 @@ import Navbar from '~/components/navbar/navbar' import { FrankDocProvider } from '~/providers/frankdoc-provider' +import { FrankConfigXsdProvider } from '~/providers/frankconfig-xsd-provider' import AppContent from '~/components/app-content' import { useEffect, useState } from 'react' import { useProjectStore, getStoredProjectName } from '~/stores/project-store' @@ -39,12 +40,14 @@ export default function AppLayout() { return ( -
- -
- -
-
+ +
+ +
+ +
+
+
) } diff --git a/src/main/frontend/app/routes/editor/editor.tsx b/src/main/frontend/app/routes/editor/editor.tsx index c6dfdc2a..f455b368 100644 --- a/src/main/frontend/app/routes/editor/editor.tsx +++ b/src/main/frontend/app/routes/editor/editor.tsx @@ -1,4 +1,8 @@ import Editor, { type Monaco, type OnMount } from '@monaco-editor/react' +import XsdManager from 'monaco-xsd-code-completion/esm/XsdManager' +import XsdFeatures from 'monaco-xsd-code-completion/esm/XsdFeatures' +import 'monaco-xsd-code-completion/src/style.css' +import { validateXML, type XMLValidationError } from 'xmllint-wasm' import { useShallow } from 'zustand/react/shallow' import SidebarLayout from '~/components/sidebars-layout/sidebar-layout' import SidebarContentClose from '~/components/sidebars-layout/sidebar-content-close' @@ -10,9 +14,8 @@ 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 type { ElementDetails, Attribute, EnumValue } from '~/types/ff-doc.types' -import { useFrankDoc } from '~/providers/frankdoc-provider' 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' @@ -21,26 +24,131 @@ import GitPanel from '~/components/git/git-panel' import DiffTabView from '~/components/git/diff-tab-view' import clsx from 'clsx' import { refreshOpenDiffs } from '~/services/git-service' -import { findAdaptersInXml, lineToOffset, findAdapterIndexAtOffset, normalizeFrankElements } from './xml-utils' +import { findAdapterIndexAtOffset, findAdaptersInXml, lineToOffset, normalizeFrankElements } from './xml-utils' import { useSettingsStore } from '~/stores/settings-store' type LeftTab = 'files' | 'git' type SaveStatus = 'idle' | 'saving' | 'saved' +interface ValidationError { + message: string + lineNumber: number + startColumn: number + endColumn: number +} +interface TextModel { + getLineContent: (n: number) => string + getLineCount: () => number + getLineMaxColumn: (n: number) => number +} 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'"'}]?/ + +function extractLocalName(name: string): string { + return name.includes(':') ? name.split(':').pop()! : name +} + +function findElementRange(lineContent: string, localName: string): { startColumn: number; endColumn: number } | null { + const openIdx = lineContent.indexOf(`<${localName}`) + if (openIdx !== -1) return { startColumn: openIdx + 1, endColumn: openIdx + 1 + localName.length + 1 } + const closeIdx = lineContent.indexOf(`= 0 ? firstNonWs + 1 : 1, endColumn: lineContent.length + 1 } +} + +function findErrorRange(lineContent: string, message: string): { startColumn: number; endColumn: number } { + const elementMatch = message.match(ELEMENT_ERROR_RE) + if (elementMatch) { + const range = findElementRange(lineContent, extractLocalName(elementMatch[1])) + if (range) return range + } + + const attrMatch = message.match(ATTRIBUTE_ERROR_RE) + if (attrMatch) { + const range = findAttributeRange(lineContent, extractLocalName(attrMatch[1])) + if (range) return range + } + + return fallbackRange(lineContent) +} + +function notWellFormedError(model: TextModel): ValidationError { + return { message: 'XML is not well-formed', lineNumber: 1, startColumn: 1, endColumn: model.getLineMaxColumn(1) } +} + +function mapToValidationErrors(rawErrors: readonly XMLValidationError[], model: TextModel): ValidationError[] { + const totalLines = model.getLineCount() + const seen = new Set() + + return rawErrors + .map((e) => { + const lineNumber = Math.max(1, Math.min(e.loc?.lineNumber ?? 1, totalLines)) + const { startColumn, endColumn } = findErrorRange(model.getLineContent(lineNumber), e.message) + return { message: e.message, lineNumber, startColumn, endColumn } + }) + .filter((e) => { + if (seen.has(e.lineNumber)) return false + seen.add(e.lineNumber) + return true + }) +} + +function toDecoration(e: ValidationError) { + return { + range: { + startLineNumber: e.lineNumber, + startColumn: e.startColumn, + endLineNumber: e.lineNumber, + endColumn: e.endColumn, + }, + options: { + inlineClassName: 'xml-lint xml-lint--fatal-error', + hoverMessage: { value: `**XSD:** ${e.message}` }, + overviewRuler: { color: '#ff2424', position: 4 }, + }, + } +} + +function toMarker(e: ValidationError, severity: number) { + return { + startLineNumber: e.lineNumber, + startColumn: e.startColumn, + endLineNumber: e.lineNumber, + endColumn: e.endColumn, + message: e.message, + severity, + } +} export default function CodeEditor() { const theme = useTheme() - const { elements } = useFrankDoc() const project = useProjectStore.getState().project const [activeTabFilePath, setActiveTabFilePath] = useState(useEditorTabStore.getState().activeTabFilePath) const [xmlContent, setXmlContent] = useState('') - const editorReference = useRef[0] | null>(null) - const decorationIdsReference = useRef([]) const [saveStatus, setSaveStatus] = useState('idle') const [leftTab, setLeftTab] = useState('files') + const [editorMounted, setEditorMounted] = useState(false) + const [xsdLoaded, setXsdLoaded] = useState(false) + const editorReference = useRef[0] | null>(null) + const monacoReference = useRef(null) + const xsdContentRef = useRef(null) + const errorDecorationsRef = useRef<{ clear: () => void } | null>(null) const debounceTimerRef = useRef | null>(null) const savedTimerRef = useRef | null>(null) + const validationTimerRef = useRef | null>(null) + const validationCounterRef = useRef(0) const activeTab = useEditorTabStore( useShallow((state) => { @@ -54,7 +162,6 @@ export default function CodeEditor() { ) const refreshCounter = useEditorTabStore((state) => state.refreshCounter) - const isDiffTab = activeTab.type === 'diff' const performSave = useCallback( @@ -73,7 +180,6 @@ export default function CodeEditor() { setSaveStatus('saved') if (savedTimerRef.current) clearTimeout(savedTimerRef.current) savedTimerRef.current = setTimeout(() => setSaveStatus('idle'), SAVED_DISPLAY_DURATION) - refreshOpenDiffs(project.name) } catch (error) { showErrorToastFrom('Error saving', error) @@ -107,16 +213,109 @@ export default function CodeEditor() { return () => { if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current) if (savedTimerRef.current) clearTimeout(savedTimerRef.current) + if (validationTimerRef.current) clearTimeout(validationTimerRef.current) + } + }, []) + + const applyValidationDecorations = useCallback((errors: ValidationError[]) => { + const monaco = monacoReference.current + const editor = editorReference.current + if (!monaco || !editor) return + + const model = editor.getModel() + if (!model) return + + if (errorDecorationsRef.current) { + errorDecorationsRef.current.clear() + errorDecorationsRef.current = null } + + if (errors.length > 0) { + errorDecorationsRef.current = editor.createDecorationsCollection(errors.map((element) => toDecoration(element))) + } + + monaco.editor.setModelMarkers( + model, + 'xsd-validation', + errors.map((e) => toMarker(e, monaco.MarkerSeverity.Error)), + ) }, []) + const runSchemaValidation = useCallback( + async (content: string) => { + const monaco = monacoReference.current + const editor = editorReference.current + const xsdContent = xsdContentRef.current + if (!monaco || !editor || !xsdContent) return + + const validationId = ++validationCounterRef.current + + try { + const result = await validateXML({ + xml: [{ fileName: 'config.xml', contents: content }], + schema: [{ fileName: 'FrankConfig.xsd', contents: xsdContent }], + }) + + if (validationId !== validationCounterRef.current) return + + const model = editor.getModel() + if (!model) return + + if (!result.valid && result.errors.length === 0) { + applyValidationDecorations([notWellFormedError(model)]) + return + } + + applyValidationDecorations(mapToValidationErrors(result.errors, model)) + } catch { + if (validationId !== validationCounterRef.current) return + const model = editor.getModel() + if (!model) return + applyValidationDecorations([notWellFormedError(model)]) + } + }, + [applyValidationDecorations], + ) + + const scheduleSchemaValidation = useCallback( + (content: string) => { + if (validationTimerRef.current) clearTimeout(validationTimerRef.current) + validationTimerRef.current = setTimeout(() => { + validationTimerRef.current = null + runSchemaValidation(content) + }, 800) + }, + [runSchemaValidation], + ) + + useEffect(() => { + if (!editorMounted || !editorReference.current || !monacoReference.current) return + + const xsdManager = new XsdManager(editorReference.current) + const xsdFeatures = new XsdFeatures(xsdManager, monacoReference.current, editorReference.current) + + xsdFeatures.addCompletion() + xsdFeatures.addGenerateAction() + xsdFeatures.addReformatAction() + + fetchFrankConfigXsd() + .then((xsdContent) => { + xsdContentRef.current = xsdContent + xsdManager.set({ path: 'FrankConfig.xsd', value: xsdContent, namespace: 'xs', alwaysInclude: true }) + setXsdLoaded(true) + }) + .catch(console.error) + }, [editorMounted]) + const handleEditorMount: OnMount = (editor, monacoInstance) => { editorReference.current = editor + monacoReference.current = monacoInstance + setEditorMounted(true) editor.addAction({ id: 'save-file', label: 'Save File', - contextMenuGroupId: 'navigation', // shows in right-click menu + contextMenuGroupId: 'navigation', contextMenuOrder: 1, keybindings: [monacoInstance.KeyMod.CtrlCmd | monacoInstance.KeyCode.KeyS], run: () => { @@ -128,25 +327,18 @@ export default function CodeEditor() { }, }) - // Ctrl + Shift + F to normalize all frank elements editor.addAction({ id: 'normalize-frank-elements', label: 'Normalize Frank Elements', - contextMenuGroupId: 'navigation', // shows in right-click menu + contextMenuGroupId: 'navigation', contextMenuOrder: 2, keybindings: [monacoInstance.KeyMod.CtrlCmd | monacoInstance.KeyMod.Shift | monacoInstance.KeyCode.KeyF], run: async () => { if (activeTabFilePath.endsWith('.xml')) { const current = editor.getValue() const updated = await normalizeFrankElements(current) - editor.pushUndoStop() - editor.executeEdits('normalize-frank', [ - { - range: editor.getModel()!.getFullModelRange(), - text: updated, - }, - ]) + editor.executeEdits('normalize-frank', [{ range: editor.getModel()!.getFullModelRange(), text: updated }]) editor.pushUndoStop() } }, @@ -154,18 +346,13 @@ export default function CodeEditor() { } useEffect(() => { - const unsubActiveTab = useEditorTabStore.subscribe( + return useEditorTabStore.subscribe( (state) => state.activeTabFilePath, (newActiveTab, oldActiveTab) => { - if (oldActiveTab && oldActiveTab !== newActiveTab) { - flushPendingSave() - } + if (oldActiveTab && oldActiveTab !== newActiveTab) flushPendingSave() setActiveTabFilePath(newActiveTab) }, ) - return () => { - unsubActiveTab() - } }, [flushPendingSave]) useEffect(() => { @@ -178,26 +365,38 @@ export default function CodeEditor() { const configPath = useEditorTabStore.getState().getTab(activeTabFilePath)?.configurationPath if (!configPath || !project) return const xmlString = await fetchConfiguration(project.name, configPath, abortController.signal) - if (!abortController.signal.aborted) { - setXmlContent(xmlString) - } + if (!abortController.signal.aborted) setXmlContent(xmlString) } catch (error) { - if (!abortController.signal.aborted) { - console.error('Failed to load XML:', error) - } + if (!abortController.signal.aborted) console.error('Failed to load XML:', error) } } fetchXml() - return () => abortController.abort() }, [project, activeTabFilePath, isDiffTab, refreshCounter]) + useEffect(() => { + if (errorDecorationsRef.current) { + errorDecorationsRef.current.clear() + errorDecorationsRef.current = null + } + const monaco = monacoReference.current + const editor = editorReference.current + if (monaco && editor) { + const model = editor.getModel() + if (model) monaco.editor.setModelMarkers(model, 'xsd-validation', []) + } + }, [activeTabFilePath]) + + useEffect(() => { + if (!xmlContent || !xsdLoaded || isDiffTab) return + runSchemaValidation(xmlContent) + }, [xmlContent, xsdLoaded, isDiffTab, runSchemaValidation]) + useEffect(() => { if (!xmlContent || !activeTabFilePath || !editorReference.current || isDiffTab) return const editor = editorReference.current - const model = editor.getModel() if (!model) return @@ -206,138 +405,21 @@ export default function CodeEditor() { if (matchIndex === -1) return const lineNumber = matchIndex + 1 - editor.revealLineNearTop(lineNumber) editor.setPosition({ lineNumber, column: 1 }) editor.focus() - const newDecorations = editor.createDecorationsCollection([ + const decorations = editor.createDecorationsCollection([ { range: { startLineNumber: lineNumber, startColumn: 1, endLineNumber: lineNumber, endColumn: 1 }, - options: { - isWholeLine: true, - className: 'highlight-line', - }, + options: { isWholeLine: true, className: 'highlight-line' }, }, ]) - decorationIdsReference.current = newDecorations.getRanges().map(() => '') - - const timeout = setTimeout(() => { - newDecorations.clear() - }, 2000) + const timeout = setTimeout(() => decorations.clear(), 2000) return () => clearTimeout(timeout) }, [xmlContent, activeTabFilePath, isDiffTab]) - useEffect(() => { - if (!editorReference.current) return - const monacoInstance = (globalThis as { monaco?: Monaco }).monaco - if (!monacoInstance) return - - type CompletionProvider = Parameters[1] - type ProvideCompletionItems = CompletionProvider['provideCompletionItems'] - type ITextModel = Parameters[0] - type Position = Parameters[1] - - const isCursorInsideAttributeValue = (model: ITextModel, position: Position) => { - const text = getTextBeforeCursor(model, position) - return /="[^"]*$/.test(text) - } - - const getTextBeforeCursor = (model: ITextModel, position: Position) => { - const line = model.getLineContent(position.lineNumber) - return line.slice(0, position.column - 1) - } - - const elementProvider = monacoInstance.languages.registerCompletionItemProvider('xml', { - triggerCharacters: ['<'], - provideCompletionItems: (model: ITextModel, position: Position) => { - if (isCursorInsideAttributeValue(model, position)) { - return { suggestions: [] } - } - - if (!elements) return { suggestions: [] } - - return { - suggestions: Object.values(elements).map((el) => { - const element = el as ElementDetails - const mandatoryAttributes = Object.entries((element.attributes || {}) as Record) - .filter(([, attribute]) => attribute.mandatory) - .map(([name], index) => { - if (index === 0) return `${name}="\${1}"` - return `${name}="\${${index + 2}}"` - }) - .join(' ') - - const mandatoryAttributesWithSpace = mandatoryAttributes ? ` ${mandatoryAttributes}` : '' - const openingTag = `${element.name}${mandatoryAttributesWithSpace}>` - const closingTag = `` - - const insertText = `${openingTag}$0${closingTag}` - - return { - label: element.name, - kind: monacoInstance.languages.CompletionItemKind.Class, - insertText, - insertTextRules: monacoInstance.languages.CompletionItemInsertTextRule.InsertAsSnippet, - documentation: element.description || '', - } - }), - } - }, - }) - - const attributeProvider = monacoInstance.languages.registerCompletionItemProvider('xml', { - triggerCharacters: [' '], - provideCompletionItems: (model: ITextModel, position: Position) => { - if (isCursorInsideAttributeValue(model, position)) { - return { suggestions: [] } - } - - const textBeforeCursor = getTextBeforeCursor(model, position) - const tagMatch = textBeforeCursor.match(/<(\w+)/) - if (!tagMatch) return { suggestions: [] } - - const tagName = tagMatch[1] - if (!elements) return { suggestions: [] } - const el = elements[tagName] - if (!el || !el.attributes) return { suggestions: [] } - - const element = el as ElementDetails - - const attributeSuggestions = Object.entries((element.attributes || {}) as Record).flatMap( - ([attributeName, attribute]) => { - if (attribute.enum && element.enums && element.enums[attribute.enum]) { - const enumRecord = element.enums[attribute.enum] as Record - const enumValues = Object.entries(enumRecord) - return enumValues.map(([value]) => ({ - label: `${attributeName}="${value}"`, - kind: monacoInstance.languages.CompletionItemKind.Enum, - insertText: `${attributeName}="${value}"`, - documentation: (attribute.description as string) || '', - })) - } - - return { - label: attributeName, - kind: monacoInstance.languages.CompletionItemKind.Property, - insertText: `${attributeName}="\${1}"`, - insertTextRules: monacoInstance.languages.CompletionItemInsertTextRule.InsertAsSnippet, - documentation: attribute.description || '', - } - }, - ) - - return { suggestions: attributeSuggestions } - }, - }) - - return () => { - elementProvider.dispose() - attributeProvider.dispose() - } - }, [elements]) - const handleOpenInStudio = useCallback(() => { const editorTab = useEditorTabStore.getState().getTab(activeTabFilePath) if (!editorTab) return @@ -430,7 +512,10 @@ export default function CodeEditor() { theme={`vs-${theme}`} value={xmlContent} onMount={handleEditorMount} - onChange={scheduleSave} + onChange={(value) => { + scheduleSave() + if (value) scheduleSchemaValidation(value) + }} options={{ automaticLayout: true, quickSuggestions: false }} /> diff --git a/src/main/frontend/app/services/xml-service.ts b/src/main/frontend/app/services/xml-service.ts index c485cdd1..b9dd95c2 100644 --- a/src/main/frontend/app/services/xml-service.ts +++ b/src/main/frontend/app/services/xml-service.ts @@ -1,13 +1,6 @@ import type { XmlResponse } from '~/types/project.types' import { apiFetch } from '~/utils/api' -export async function validateXml(xmlContent: string): Promise { - return apiFetch('/xml/validate', { - method: 'POST', - body: JSON.stringify({ xmlContent }), - }) -} - export async function normalizeXml(xmlContent: string): Promise { return apiFetch('/xml/normalize', { method: 'POST', diff --git a/src/main/frontend/app/services/xsd-service.ts b/src/main/frontend/app/services/xsd-service.ts new file mode 100644 index 00000000..7fc9f207 --- /dev/null +++ b/src/main/frontend/app/services/xsd-service.ts @@ -0,0 +1,7 @@ +import { apiUrl } from '~/utils/api' + +export async function fetchFrankConfigXsd(signal?: AbortSignal): Promise { + const response = await fetch(apiUrl('/xsd/frankconfig'), { signal }) + if (!response.ok) throw new Error(`Failed to fetch FrankConfig XSD: ${response.statusText}`) + return response.text() +} diff --git a/src/main/frontend/custom.d.ts b/src/main/frontend/custom.d.ts index 82073bb9..5475b6ad 100644 --- a/src/main/frontend/custom.d.ts +++ b/src/main/frontend/custom.d.ts @@ -4,3 +4,6 @@ declare module '*.mdx' { const component: ComponentType export default component } + +declare module 'monaco-xsd-code-completion/esm/XsdManager' +declare module 'monaco-xsd-code-completion/esm/XsdFeatures' diff --git a/src/main/frontend/package.json b/src/main/frontend/package.json index f9cc8954..676efa0a 100644 --- a/src/main/frontend/package.json +++ b/src/main/frontend/package.json @@ -27,6 +27,7 @@ "dagre": "^0.8.5", "dotenv": "^17.2.3", "isbot": "^5.1.17", + "monaco-xsd-code-completion": "^0.13.0", "react": "^19.0.0", "react-complex-tree": "^2.6.0", "react-dom": "^19.0.0", @@ -34,6 +35,7 @@ "remark-gfm": "^4.0.1", "sax-ts": "^1.2.13", "tailwind-merge": "^3.3.0", + "xmllint-wasm": "^5.1.0", "zustand": "^5.0.3" }, "devDependencies": { diff --git a/src/main/frontend/vite.config.ts b/src/main/frontend/vite.config.ts index 1fdb5ae6..92cf31d5 100644 --- a/src/main/frontend/vite.config.ts +++ b/src/main/frontend/vite.config.ts @@ -25,6 +25,9 @@ export default defineConfig({ tsconfigPaths(), svgr(), ], + optimizeDeps: { + exclude: ['xmllint-wasm'], + }, resolve: { alias: { '@frankframework/ff-doc/react': path.join(ffDocRoot, 'react/frankframework-ff-doc.mjs'), diff --git a/src/main/java/org/frankframework/flow/frankconfig/FrankConfigXsdController.java b/src/main/java/org/frankframework/flow/frankconfig/FrankConfigXsdController.java new file mode 100644 index 00000000..601bc4ee --- /dev/null +++ b/src/main/java/org/frankframework/flow/frankconfig/FrankConfigXsdController.java @@ -0,0 +1,27 @@ +package org.frankframework.flow.frankconfig; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Slf4j +@RestController +@RequestMapping("/xsd/frankconfig") +public class FrankConfigXsdController { + + private final FrankConfigXsdService frankConfigXsdService; + + public FrankConfigXsdController(FrankConfigXsdService frankConfigXsdService) { + this.frankConfigXsdService = frankConfigXsdService; + } + + @GetMapping(produces = MediaType.TEXT_XML_VALUE) + public ResponseEntity getFrankConfigXsd() throws FrankConfigXsdNotFoundException { + String xsd = frankConfigXsdService.getFrankConfigXsd(); + log.info("Fetched FrankConfig XSD"); + return ResponseEntity.ok(xsd); + } +} diff --git a/src/main/java/org/frankframework/flow/frankconfig/FrankConfigXsdNotFoundException.java b/src/main/java/org/frankframework/flow/frankconfig/FrankConfigXsdNotFoundException.java new file mode 100644 index 00000000..d9103496 --- /dev/null +++ b/src/main/java/org/frankframework/flow/frankconfig/FrankConfigXsdNotFoundException.java @@ -0,0 +1,10 @@ +package org.frankframework.flow.frankconfig; + +import org.frankframework.flow.exception.ApiException; +import org.springframework.http.HttpStatus; + +public class FrankConfigXsdNotFoundException extends ApiException { + public FrankConfigXsdNotFoundException(String message, Throwable cause) { + super(message, HttpStatus.NOT_FOUND, cause); + } +} diff --git a/src/main/java/org/frankframework/flow/frankconfig/FrankConfigXsdService.java b/src/main/java/org/frankframework/flow/frankconfig/FrankConfigXsdService.java new file mode 100644 index 00000000..4e531216 --- /dev/null +++ b/src/main/java/org/frankframework/flow/frankconfig/FrankConfigXsdService.java @@ -0,0 +1,27 @@ +package org.frankframework.flow.frankconfig; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +@Service +@Slf4j +public class FrankConfigXsdService { + private static final String FRANKCONFIG_XSD_URL = "https://schemas.frankframework.org/FrankConfig.xsd"; + + private final RestTemplate restTemplate; + + public FrankConfigXsdService(RestTemplate restTemplate) { + this.restTemplate = restTemplate; + } + + public String getFrankConfigXsd() throws FrankConfigXsdNotFoundException { + try { + log.info("Fetching FrankConfig XSD from {}", FRANKCONFIG_XSD_URL); + return restTemplate.getForObject(FRANKCONFIG_XSD_URL, String.class); + } catch (Exception exception) { + log.error("Failed to fetch FrankConfig XSD from {}", FRANKCONFIG_XSD_URL, exception); + throw new FrankConfigXsdNotFoundException("Failed to fetch FrankConfig XSD", exception); + } + } +} diff --git a/src/main/java/org/frankframework/flow/project/ProjectController.java b/src/main/java/org/frankframework/flow/project/ProjectController.java index e186a8b1..337a8e50 100644 --- a/src/main/java/org/frankframework/flow/project/ProjectController.java +++ b/src/main/java/org/frankframework/flow/project/ProjectController.java @@ -23,7 +23,6 @@ import org.frankframework.flow.filetree.FileTreeService; import org.frankframework.flow.projectsettings.InvalidFilterTypeException; import org.frankframework.flow.recentproject.RecentProjectsService; -import org.frankframework.flow.utility.XmlValidator; import org.frankframework.flow.xml.XmlDTO; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; @@ -173,11 +172,7 @@ public ResponseEntity getConfigurationByPath( @PutMapping("/{projectName}/configuration") public ResponseEntity updateConfiguration( @PathVariable String projectName, @RequestBody ConfigurationDTO configurationDTO) - throws ConfigurationNotFoundException, InvalidXmlContentException, IOException, ProjectNotFoundException { - - if (configurationDTO.filepath().toLowerCase().endsWith(".xml")) { - XmlValidator.validateXml(configurationDTO.content()); - } + throws ConfigurationNotFoundException, IOException, ProjectNotFoundException { try { fileTreeService.updateFileContent(projectName, configurationDTO.filepath(), configurationDTO.content()); return ResponseEntity.ok().build(); diff --git a/src/main/java/org/frankframework/flow/utility/XmlValidator.java b/src/main/java/org/frankframework/flow/utility/XmlValidator.java deleted file mode 100644 index cf9ada45..00000000 --- a/src/main/java/org/frankframework/flow/utility/XmlValidator.java +++ /dev/null @@ -1,37 +0,0 @@ -package org.frankframework.flow.utility; - -import java.io.IOException; -import java.io.StringReader; -import javax.xml.parsers.DocumentBuilder; -import javax.xml.parsers.DocumentBuilderFactory; -import javax.xml.parsers.ParserConfigurationException; -import lombok.experimental.UtilityClass; -import org.frankframework.flow.project.InvalidXmlContentException; -import org.xml.sax.InputSource; -import org.xml.sax.SAXException; - -@UtilityClass -public class XmlValidator { - - public static void validateXml(String xmlContent) throws InvalidXmlContentException { - if (xmlContent == null || xmlContent.isBlank()) { - return; - } - - try { - DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); - factory.setNamespaceAware(true); - factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); - factory.setFeature("http://xml.org/sax/features/external-general-entities", false); - factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false); - factory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false); - factory.setXIncludeAware(false); - factory.setExpandEntityReferences(false); - - DocumentBuilder builder = factory.newDocumentBuilder(); - builder.parse(new InputSource(new StringReader(xmlContent))); - } catch (ParserConfigurationException | SAXException | IOException e) { - throw new InvalidXmlContentException("Failed to validate xml content: ", e); - } - } -} diff --git a/src/main/java/org/frankframework/flow/xml/XmlController.java b/src/main/java/org/frankframework/flow/xml/XmlController.java index 898c3aa4..cfc0ddd1 100644 --- a/src/main/java/org/frankframework/flow/xml/XmlController.java +++ b/src/main/java/org/frankframework/flow/xml/XmlController.java @@ -1,6 +1,5 @@ package org.frankframework.flow.xml; -import org.frankframework.flow.project.InvalidXmlContentException; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -17,16 +16,8 @@ public XmlController(XmlService xmlService) { this.xmlService = xmlService; } - @PostMapping("/validate") - public ResponseEntity validateXml(@RequestBody XmlDTO xmlValidateDTO) throws InvalidXmlContentException { - - xmlService.validateXml(xmlValidateDTO.xmlContent()); - return ResponseEntity.ok().build(); - } - @PostMapping("/normalize") - public ResponseEntity normalizeXml(@RequestBody XmlDTO xmlValidateDTO) - throws InvalidXmlContentException, Exception { + public ResponseEntity normalizeXml(@RequestBody XmlDTO xmlValidateDTO) throws Exception { String normalizedXml = xmlService.normalizeElementsInXml(xmlValidateDTO.xmlContent()); return ResponseEntity.ok(new XmlDTO(normalizedXml)); diff --git a/src/main/java/org/frankframework/flow/xml/XmlService.java b/src/main/java/org/frankframework/flow/xml/XmlService.java index 14513e51..153333f0 100644 --- a/src/main/java/org/frankframework/flow/xml/XmlService.java +++ b/src/main/java/org/frankframework/flow/xml/XmlService.java @@ -2,21 +2,16 @@ import org.frankframework.flow.project.InvalidXmlContentException; import org.frankframework.flow.utility.XmlAdapterUtils; -import org.frankframework.flow.utility.XmlValidator; import org.springframework.stereotype.Service; +import org.xml.sax.SAXException; @Service public class XmlService { - - public XmlService() {} - - public void validateXml(String xmlContent) throws InvalidXmlContentException { - XmlValidator.validateXml(xmlContent); - } - - public String normalizeElementsInXml(String xmlContent) throws InvalidXmlContentException, Exception { - validateXml(xmlContent); - String normalizedXml = XmlAdapterUtils.normalizeFrankElements(xmlContent); - return normalizedXml; + public String normalizeElementsInXml(String xmlContent) throws Exception { + try { + return XmlAdapterUtils.normalizeFrankElements(xmlContent); + } catch (SAXException e) { + throw new InvalidXmlContentException("Invalid XML", e); + } } } diff --git a/src/test/java/org/frankframework/flow/frankconfig/FrankConfigXsdControllerTest.java b/src/test/java/org/frankframework/flow/frankconfig/FrankConfigXsdControllerTest.java new file mode 100644 index 00000000..01318662 --- /dev/null +++ b/src/test/java/org/frankframework/flow/frankconfig/FrankConfigXsdControllerTest.java @@ -0,0 +1,56 @@ +package org.frankframework.flow.frankconfig; + +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import org.frankframework.flow.security.UserContextFilter; +import org.frankframework.flow.security.UserWorkspaceContext; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(FrankConfigXsdController.class) +@AutoConfigureMockMvc(addFilters = false) +class FrankConfigXsdControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private FrankConfigXsdService frankConfigXsdService; + + @MockitoBean + private UserContextFilter userContextFilter; + + @MockitoBean + private UserWorkspaceContext userWorkspaceContext; + + @Test + void getFrankConfigXsdReturnsXmlContent() throws Exception { + String xsdContent = ""; + when(frankConfigXsdService.getFrankConfigXsd()).thenReturn(xsdContent); + + mockMvc.perform(get("/api/xsd/frankconfig").accept(MediaType.TEXT_XML)) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(MediaType.TEXT_XML)) + .andExpect(content().string(xsdContent)); + + verify(frankConfigXsdService).getFrankConfigXsd(); + } + + @Test + void getFrankConfigXsdServiceFailsReturns404() throws Exception { + when(frankConfigXsdService.getFrankConfigXsd()) + .thenThrow( + new FrankConfigXsdNotFoundException("Failed to fetch FrankConfig XSD", new RuntimeException())); + + mockMvc.perform(get("/api/xsd/frankconfig")).andExpect(status().isNotFound()); + + verify(frankConfigXsdService).getFrankConfigXsd(); + } +} diff --git a/src/test/java/org/frankframework/flow/frankconfig/FrankConfigXsdServiceTest.java b/src/test/java/org/frankframework/flow/frankconfig/FrankConfigXsdServiceTest.java new file mode 100644 index 00000000..8d3b47f1 --- /dev/null +++ b/src/test/java/org/frankframework/flow/frankconfig/FrankConfigXsdServiceTest.java @@ -0,0 +1,49 @@ +package org.frankframework.flow.frankconfig; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestTemplate; + +@ExtendWith(MockitoExtension.class) +class FrankConfigXsdServiceTest { + + @Mock + private RestTemplate restTemplate; + + private FrankConfigXsdService frankConfigXsdService; + + private static final String FRANKCONFIG_XSD_URL = "https://schemas.frankframework.org/FrankConfig.xsd"; + + @BeforeEach + void setUp() { + frankConfigXsdService = new FrankConfigXsdService(restTemplate); + } + + @Test + void getFrankConfigXsdReturnsXsdContent() throws FrankConfigXsdNotFoundException { + String expectedXsd = ""; + when(restTemplate.getForObject(FRANKCONFIG_XSD_URL, String.class)).thenReturn(expectedXsd); + + String result = frankConfigXsdService.getFrankConfigXsd(); + + assertEquals(expectedXsd, result); + verify(restTemplate).getForObject(FRANKCONFIG_XSD_URL, String.class); + } + + @Test + void getFrankConfigXsdThrowsWhenRestTemplateFails() { + when(restTemplate.getForObject(FRANKCONFIG_XSD_URL, String.class)) + .thenThrow(new RestClientException("Connection refused")); + + assertThrows(FrankConfigXsdNotFoundException.class, () -> frankConfigXsdService.getFrankConfigXsd()); + + verify(restTemplate).getForObject(FRANKCONFIG_XSD_URL, String.class); + } +} diff --git a/src/test/java/org/frankframework/flow/frankdoc/FrankDocControllerTest.java b/src/test/java/org/frankframework/flow/frankdoc/FrankDocControllerTest.java new file mode 100644 index 00000000..439f7ce5 --- /dev/null +++ b/src/test/java/org/frankframework/flow/frankdoc/FrankDocControllerTest.java @@ -0,0 +1,56 @@ +package org.frankframework.flow.frankdoc; + +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import org.frankframework.flow.security.UserContextFilter; +import org.frankframework.flow.security.UserWorkspaceContext; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(FrankDocController.class) +@AutoConfigureMockMvc(addFilters = false) +class FrankDocControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private FrankDocService frankDocService; + + @MockitoBean + private UserContextFilter userContextFilter; + + @MockitoBean + private UserWorkspaceContext userWorkspaceContext; + + @Test + void getFrankDocJsonReturnsJsonContent() throws Exception { + String frankDocJson = "{\"version\":\"1.0\",\"types\":{}}"; + when(frankDocService.getFrankDocJson()).thenReturn(frankDocJson); + + mockMvc.perform(get("/api/json/frankdoc").accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(content().string(frankDocJson)); + + verify(frankDocService).getFrankDocJson(); + } + + @Test + void getFrankDocJsonServiceFailsReturns404() throws Exception { + when(frankDocService.getFrankDocJson()) + .thenThrow(new FrankDocJsonNotFoundException("Failed to fetch FrankDoc JSON", new RuntimeException())); + + mockMvc.perform(get("/api/json/frankdoc").accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isNotFound()); + + verify(frankDocService).getFrankDocJson(); + } +} diff --git a/src/test/java/org/frankframework/flow/frankdoc/FrankDocServiceTest.java b/src/test/java/org/frankframework/flow/frankdoc/FrankDocServiceTest.java new file mode 100644 index 00000000..512e3d81 --- /dev/null +++ b/src/test/java/org/frankframework/flow/frankdoc/FrankDocServiceTest.java @@ -0,0 +1,49 @@ +package org.frankframework.flow.frankdoc; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestTemplate; + +@ExtendWith(MockitoExtension.class) +class FrankDocServiceTest { + + @Mock + private RestTemplate restTemplate; + + private FrankDocService frankDocService; + + private static final String FRANKDOC_URL = "https://frankdoc.frankframework.org/js/frankdoc.json"; + + @BeforeEach + void setUp() { + frankDocService = new FrankDocService(restTemplate); + } + + @Test + void getFrankDocJsonReturnsJsonContent() throws FrankDocJsonNotFoundException { + String expectedJson = "{\"version\":\"1.0\",\"types\":{}}"; + when(restTemplate.getForObject(FRANKDOC_URL, String.class)).thenReturn(expectedJson); + + String result = frankDocService.getFrankDocJson(); + + assertEquals(expectedJson, result); + verify(restTemplate).getForObject(FRANKDOC_URL, String.class); + } + + @Test + void getFrankDocJsonThrowsWhenRestTemplateFails() { + when(restTemplate.getForObject(FRANKDOC_URL, String.class)) + .thenThrow(new RestClientException("Connection refused")); + + assertThrows(FrankDocJsonNotFoundException.class, () -> frankDocService.getFrankDocJson()); + + verify(restTemplate).getForObject(FRANKDOC_URL, String.class); + } +} diff --git a/src/test/java/org/frankframework/flow/project/ProjectControllerTest.java b/src/test/java/org/frankframework/flow/project/ProjectControllerTest.java index 372dd5ee..c583d89a 100644 --- a/src/test/java/org/frankframework/flow/project/ProjectControllerTest.java +++ b/src/test/java/org/frankframework/flow/project/ProjectControllerTest.java @@ -23,11 +23,9 @@ import org.frankframework.flow.projectsettings.InvalidFilterTypeException; import org.frankframework.flow.projectsettings.ProjectSettings; import org.frankframework.flow.recentproject.RecentProjectsService; -import org.frankframework.flow.utility.XmlValidator; import org.frankframework.flow.xml.XmlDTO; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.mockito.MockedStatic; import org.mockito.Mockito; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; @@ -228,34 +226,6 @@ void updateConfigurationConfigurationNotFoundReturns404() throws Exception { verify(fileTreeService).updateFileContent(TEST_PROJECT_NAME, filepath, xmlContent); } - @Test - void updateConfigurationValidationErrorReturns400() throws Exception { - String invalidXml = ""; - - try (MockedStatic validatorMock = Mockito.mockStatic(XmlValidator.class)) { - - validatorMock - .when(() -> XmlValidator.validateXml(invalidXml)) - .thenThrow(new InvalidXmlContentException("Malformed XML", null)); - - mockMvc.perform( - put("/api/projects/" + TEST_PROJECT_NAME + "/configuration") - .contentType(MediaType.APPLICATION_JSON) - .content( - """ - { - "filepath": "config1.xml", - "content": "" - } - """)) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.httpStatus").value(400)) - .andExpect(jsonPath("$.messages[0]").value("Malformed XML")); - - verify(fileTreeService, never()).updateFileContent(eq(TEST_PROJECT_NAME), anyString(), anyString()); - } - } - @Test void updateAdapterFromFileSuccessReturns200() throws Exception { String projectName = "MyProject"; diff --git a/src/test/java/org/frankframework/flow/xml/XmlServiceTest.java b/src/test/java/org/frankframework/flow/xml/XmlServiceTest.java index 8d2332b6..80bbd808 100644 --- a/src/test/java/org/frankframework/flow/xml/XmlServiceTest.java +++ b/src/test/java/org/frankframework/flow/xml/XmlServiceTest.java @@ -5,7 +5,6 @@ import org.frankframework.flow.project.InvalidXmlContentException; import org.frankframework.flow.utility.XmlAdapterUtils; -import org.frankframework.flow.utility.XmlValidator; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -22,49 +21,18 @@ void setUp() { xmlService = new XmlService(); } - @Test - void validateXmlShouldSucceedWhenXmlIsValid() { - String xml = ""; - - // Mock static validator - try (MockedStatic validatorMock = mockStatic(XmlValidator.class)) { - assertDoesNotThrow(() -> xmlService.validateXml(xml)); - - validatorMock.verify(() -> XmlValidator.validateXml(xml)); - } - } - - @Test - void validateXmlShouldThrowWhenXmlIsInvalid() { - String xml = ""; - - try (MockedStatic validatorMock = mockStatic(XmlValidator.class)) { - validatorMock - .when(() -> XmlValidator.validateXml(xml)) - .thenThrow(new InvalidXmlContentException("Invalid XML", null)); - - InvalidXmlContentException ex = - assertThrows(InvalidXmlContentException.class, () -> xmlService.validateXml(xml)); - - assertEquals("Invalid XML", ex.getMessage()); - } - } - @Test void normalizeElementsInXmlShouldReturnNormalizedXmlWhenValid() throws Exception { String xml = ""; String normalizedXml = ""; - try (MockedStatic validatorMock = mockStatic(XmlValidator.class); - MockedStatic adapterMock = mockStatic(XmlAdapterUtils.class)) { - + try (MockedStatic adapterMock = mockStatic(XmlAdapterUtils.class)) { adapterMock.when(() -> XmlAdapterUtils.normalizeFrankElements(xml)).thenReturn(normalizedXml); String result = xmlService.normalizeElementsInXml(xml); assertEquals(normalizedXml, result); - validatorMock.verify(() -> XmlValidator.validateXml(xml)); adapterMock.verify(() -> XmlAdapterUtils.normalizeFrankElements(xml)); } } @@ -73,25 +41,17 @@ void normalizeElementsInXmlShouldReturnNormalizedXmlWhenValid() throws Exception void normalizeElementsInXmlShouldThrowWhenXmlIsInvalid() { String xml = ""; - try (MockedStatic validatorMock = mockStatic(XmlValidator.class)) { - validatorMock - .when(() -> XmlValidator.validateXml(xml)) - .thenThrow(new InvalidXmlContentException("Invalid XML", null)); - - InvalidXmlContentException ex = - assertThrows(InvalidXmlContentException.class, () -> xmlService.normalizeElementsInXml(xml)); + InvalidXmlContentException ex = + assertThrows(InvalidXmlContentException.class, () -> xmlService.normalizeElementsInXml(xml)); - assertEquals("Invalid XML", ex.getMessage()); - } + assertEquals("Invalid XML", ex.getMessage()); } @Test - void normalizeElementsInXmlShouldThrowWhenAdapterThrowsException() throws Exception { + void normalizeElementsInXmlShouldThrowWhenAdapterThrowsException() { String xml = ""; - try (MockedStatic validatorMock = mockStatic(XmlValidator.class); - MockedStatic adapterMock = mockStatic(XmlAdapterUtils.class)) { - + try (MockedStatic adapterMock = mockStatic(XmlAdapterUtils.class)) { adapterMock .when(() -> XmlAdapterUtils.normalizeFrankElements(xml)) .thenThrow(new RuntimeException("Adapter failed")); @@ -100,7 +60,6 @@ void normalizeElementsInXmlShouldThrowWhenAdapterThrowsException() throws Except assertEquals("Adapter failed", ex.getMessage()); - validatorMock.verify(() -> XmlValidator.validateXml(xml)); adapterMock.verify(() -> XmlAdapterUtils.normalizeFrankElements(xml)); } }