diff --git a/.claude/hooks/guard.mjs b/.claude/hooks/guard.mjs new file mode 100644 index 0000000..b0f9a93 --- /dev/null +++ b/.claude/hooks/guard.mjs @@ -0,0 +1,96 @@ +// PreToolUse guard for Claude Code. Reads the hook payload from stdin and exits with code 2 +// (which blocks the tool call and shows stderr to the agent) when a protected action is attempted. +// Rules mirror AGENTS.md: no edits to generated files or secrets, no commits or pushes on main, +// no force pushes. Requires only Node.js, which the project already needs for the web UI. +import { execFileSync } from "node:child_process"; +import { resolve } from "node:path"; + +const PROTECTED_FILES = [ + { pattern: /(^|\/)src\/Server\/wwwroot\/assets\//i, reason: "wwwroot/assets is generated build output. Change the sources in src/WebUI and rebuild." }, + { pattern: /\.g\.cs$/i, reason: "*.g.cs files are generated. Change the generator input instead." }, + { pattern: /(^|\/)Mustang-CLI-[^/]*\.jar$/i, reason: "The Mustang CLI JAR is replaced only in a dedicated dependency upgrade (use the upgrade-dependency skill)." }, + { pattern: /(^|\/)\.env(\.[^/]*)?$/i, reason: "Environment files may contain secrets and must not be edited by the agent." }, +]; + +const MAIN_BRANCH = /(? rule.pattern.test(path)); + if (match) { + block(`${path}: ${match.reason}`); + } +} + +function unquote(value) { + return /^(".*"|'.*')$/.test(value) ? value.slice(1, -1) : value; +} + +function effectiveDirectory(globalOptions, cwd) { + let directory = cwd || process.cwd(); + for (const match of globalOptions.matchAll(GIT_CHANGE_DIRECTORY)) { + directory = resolve(directory, unquote(match[1])); + } + return directory; +} + +function checkShellCommand(command, cwd) { + for (const [, globalOptions, subcommand, argumentsText] of command.matchAll(GIT_COMMIT_OR_PUSH)) { + if (subcommand === "push") { + if (/(\s--force\b|\s--force-with-lease\b|\s-f\b)/.test(argumentsText)) { + block("force pushes are not allowed."); + } + if (MAIN_BRANCH.test(argumentsText)) { + block("pushing to main is not allowed. Push a feature branch and open a pull request."); + } + } + + if (currentBranch(effectiveDirectory(globalOptions, cwd)) === "main") { + block(`the current branch is main. Create a branch first (see Branching Strategy in AGENTS.md).`); + } + } +} + +let raw = ""; +for await (const chunk of process.stdin) { + raw += chunk; +} + +let payload; +try { + payload = JSON.parse(raw); +} catch { + process.exit(0); +} + +const toolName = payload.tool_name ?? ""; +const toolInput = payload.tool_input ?? {}; + +if (["Edit", "Write", "MultiEdit", "NotebookEdit"].includes(toolName)) { + checkFileEdit(toolInput); +} else if (toolName === "Bash") { + checkShellCommand(String(toolInput.command ?? ""), payload.cwd); +} + +process.exit(0); diff --git a/.claude/hooks/guard.test.mjs b/.claude/hooks/guard.test.mjs new file mode 100644 index 0000000..91b7206 --- /dev/null +++ b/.claude/hooks/guard.test.mjs @@ -0,0 +1,78 @@ +// Regression tests for guard.mjs. Run with: node --test .claude/hooks/guard.test.mjs +import assert from "node:assert/strict"; +import { execFileSync, spawnSync } from "node:child_process"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { after, before, describe, it } from "node:test"; +import { fileURLToPath } from "node:url"; + +const guardPath = fileURLToPath(new URL("./guard.mjs", import.meta.url)); + +function runGuard(toolName, toolInput, cwd) { + const result = spawnSync("node", [guardPath], { + input: JSON.stringify({ tool_name: toolName, tool_input: toolInput, cwd }), + encoding: "utf8", + }); + return result.status; +} + +function runBash(command, cwd) { + return runGuard("Bash", { command }, cwd); +} + +describe("guard.mjs", () => { + let mainRepo; + let featureRepo; + + before(() => { + mainRepo = mkdtempSync(join(tmpdir(), "guard-main-")); + featureRepo = mkdtempSync(join(tmpdir(), "guard-feature-")); + for (const [repo, branch] of [[mainRepo, "main"], [featureRepo, "feature/1_test"]]) { + execFileSync("git", ["init", `--initial-branch=${branch}`], { cwd: repo }); + // A branch name is only reported once the repository has a first commit. + execFileSync("git", ["-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "--allow-empty", "-m", "init"], { cwd: repo }); + } + }); + + after(() => { + rmSync(mainRepo, { recursive: true, force: true }); + rmSync(featureRepo, { recursive: true, force: true }); + }); + + const blockedFromFeatureBranch = [ + "git push --force origin feature/1_test", + "git -C /some/repo push --force origin feature/1_test", + "git -c core.editor=true push -f origin feature/1_test", + "git --no-pager push origin main", + "git -C /some/repo push origin main", + "git push origin HEAD:refs/heads/main", + "git --git-dir=.git push --force-with-lease origin feature/1_test", + ]; + + for (const command of blockedFromFeatureBranch) { + it(`blocks "${command}"`, () => { + assert.equal(runBash(command, featureRepo), 2); + }); + } + + it("allows a normal push of a feature branch", () => { + assert.equal(runBash("git push origin feature/1_test", featureRepo), 0); + }); + + it("blocks a commit on main", () => { + assert.equal(runBash("git commit -m test", mainRepo), 2); + }); + + it("blocks a commit on main when git -C points to the main repository", () => { + assert.equal(runBash(`git -C "${mainRepo}" commit -m test`, featureRepo), 2); + }); + + it("allows a commit on a feature branch when git -C points to it", () => { + assert.equal(runBash(`git -C ${featureRepo} commit -m test`, mainRepo), 0); + }); + + it("blocks edits to generated files", () => { + assert.equal(runGuard("Edit", { file_path: "src/Server/wwwroot/assets/index.js" }, featureRepo), 2); + }); +}); diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..80256d5 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,62 @@ +{ + "$schema": "https://json.schemastore.org/claude-code-settings.json", + "permissions": { + "allow": [ + "Bash(dotnet restore *)", + "Bash(dotnet build *)", + "Bash(dotnet test *)", + "Bash(dotnet format *)", + "Bash(dotnet list *)", + "Bash(npm ci)", + "Bash(npm run build)", + "Bash(npm run lint)", + "Bash(npm audit *)", + "Bash(npm outdated)", + "Bash(git status *)", + "Bash(git diff *)", + "Bash(git log *)", + "Bash(git show *)", + "Bash(git branch *)", + "Bash(git fetch *)" + ], + "ask": [ + "Bash(git add *)", + "Bash(git commit *)", + "Bash(git push *)", + "Bash(git checkout *)", + "Bash(git switch *)", + "Bash(git rebase *)", + "Bash(git merge *)", + "Bash(gh pr create *)", + "Bash(gh pr merge *)", + "Bash(npm install *)", + "Bash(npm audit fix *)" + ], + "deny": [ + "Bash(git push --force *)", + "Bash(git push -f *)", + "Bash(git reset --hard *)", + "Bash(git clean *)", + "Bash(rm -rf *)", + "Bash(Remove-Item * -Recurse *)", + "Read(./.env)", + "Read(./.env.*)", + "Read(./**/secrets.*)", + "Edit(./src/Server/wwwroot/assets/**)", + "Edit(./**/*.g.cs)" + ] + }, + "hooks": { + "PreToolUse": [ + { + "matcher": "Edit|Write|MultiEdit|NotebookEdit|Bash", + "hooks": [ + { + "type": "command", + "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/guard.mjs\"" + } + ] + } + ] + } +} diff --git a/.claude/skills/add-tests/SKILL.md b/.claude/skills/add-tests/SKILL.md new file mode 100644 index 0000000..5530c0c --- /dev/null +++ b/.claude/skills/add-tests/SKILL.md @@ -0,0 +1,14 @@ +--- +name: add-tests +description: Write xUnit v3 tests for the selected code in src/Server.Tests. Use when asked to add or extend unit tests. +--- +Write tests for the selected code in `src/Server.Tests`, following the "Tests" section of `AGENTS.md`. + +- Mirror the source structure and naming (`Tests`). +- xUnit v3 with `[Fact]` / `[Theory]`, built-in `Assert`, Arrange-Act-Assert. +- Cover normal, edge (empty/invalid input), cancellation and failure cases. +- Test via public APIs only; mock only external dependencies (Java process, file system). +- No loops or conditionals; deterministic and parallel-safe. +- Document every public test class and test method with XML docs (``, plus `` for theory and fixture parameters). + +Run `dotnet test src/Docentric.EInvoice.Validator.slnx` and fix failures. diff --git a/.claude/skills/commit-message/SKILL.md b/.claude/skills/commit-message/SKILL.md new file mode 100644 index 0000000..f11fd81 --- /dev/null +++ b/.claude/skills/commit-message/SKILL.md @@ -0,0 +1,12 @@ +--- +name: commit-message +description: Write a commit message in the repository format for the current staged changes (or working tree). Use when the user asks for a commit message. Only returns text and does not commit. +--- +Write a commit message for the current staged changes (or the working tree if nothing is staged). + +- Format: `# `; infer the issue number from the branch name, otherwise use `#noissue`. +- Capitalized imperative summary, no trailing period, about 72 characters or less. +- Add a body only if the motivation, trade-offs or breaking changes are not obvious. +- If the changes mix unrelated concerns, say so and suggest how to split them. + +Return only the message. diff --git a/.claude/skills/create-branch-implement-and-pr/SKILL.md b/.claude/skills/create-branch-implement-and-pr/SKILL.md new file mode 100644 index 0000000..c44e58c --- /dev/null +++ b/.claude/skills/create-branch-implement-and-pr/SKILL.md @@ -0,0 +1,15 @@ +--- +name: create-branch-implement-and-pr +description: Run the full issue workflow end to end. Creates the branch, implements, reviews, asks the user to confirm, then opens the pull request. Use only when the user wants the whole flow. For a single step use create-branch, implement, review-implementation or create-pull-request. +argument-hint: "[issue number]" +disable-model-invocation: true +--- +Work on the GitHub issue given as the argument (ask for the number if missing). Run these skills in order and follow their instructions exactly. Do not repeat their steps here. + +1. `create-branch`: stop and report if a branch or PR already exists for the issue. +2. `implement`: implement, build and test. Skip its closing question, because the confirmation happens in step 4. +3. `review-implementation`: fix actionable findings, then rebuild and retest. +4. Summarize the implementation and the review result, then ask the user whether the implementation is done as expected. Do not continue until the user confirms. If changes are requested, apply them and repeat steps 3 and 4. +5. `create-pull-request`: the user has confirmed in step 4, so do not ask again. + +Never push to `main`. diff --git a/.claude/skills/create-branch/SKILL.md b/.claude/skills/create-branch/SKILL.md new file mode 100644 index 0000000..3b5185e --- /dev/null +++ b/.claude/skills/create-branch/SKILL.md @@ -0,0 +1,13 @@ +--- +name: create-branch +description: Read a GitHub issue, then create and check out a branch named by repository convention. Use when starting work on an issue. Does not implement anything. +argument-hint: "[issue number]" +disable-model-invocation: true +--- +Prepare a branch for the GitHub issue given as the argument (ask for the number if missing). + +1. Read the issue (GitHub MCP tools) and check for existing branches/PRs for it; stop and report if one already exists. +2. Create the branch from the latest `main` using `/_` (types: `feature`, `bugfix`, `hotfix`, `chore`, `docs`). +3. Check out the branch locally. + +Do not implement anything. Never push to `main`. diff --git a/.claude/skills/create-pull-request/SKILL.md b/.claude/skills/create-pull-request/SKILL.md new file mode 100644 index 0000000..01398a1 --- /dev/null +++ b/.claude/skills/create-pull-request/SKILL.md @@ -0,0 +1,13 @@ +--- +name: create-pull-request +description: Commit, push and open a pull request for the current branch after the user has confirmed the implementation. Use when the work is done and reviewed. +argument-hint: "[issue number]" +disable-model-invocation: true +--- +Open a pull request for the current branch (issue number given as the argument; infer from the branch name if missing). + +1. Confirm with the user that the implementation is done as expected and has been reviewed; stop if not. Skip the question if the user already confirmed earlier in this session. +2. Run the build and tests; fix failures. +3. Commit as `# ` and push the branch (never `main`). +4. Open a PR to `main` titled like the commit. Use `.github/pull_request_template.md` as the body and fill in every section. +5. Check the PR check runs and fix failures. diff --git a/.claude/skills/document-public-members/SKILL.md b/.claude/skills/document-public-members/SKILL.md new file mode 100644 index 0000000..8c0af9a --- /dev/null +++ b/.claude/skills/document-public-members/SKILL.md @@ -0,0 +1,10 @@ +--- +name: document-public-members +description: Add missing XML documentation (C#) or TSDoc (TypeScript) to public members in the selected files. Use when asked to document code. Never changes behavior. +--- +Find public types and members in the selected file(s) that lack documentation and add it. + +- C#: ``, plus ``, ``, ``, `` where applicable; `` for overrides and interface implementations; document record parameters, enum values and constants. +- TypeScript: TSDoc on exported functions, hooks and components. +- Explain *why* and behavior, not what the name already says. +- Do not change code behavior. diff --git a/.claude/skills/implement/SKILL.md b/.claude/skills/implement/SKILL.md new file mode 100644 index 0000000..4008bcb --- /dev/null +++ b/.claude/skills/implement/SKILL.md @@ -0,0 +1,14 @@ +--- +name: implement +description: Implement a GitHub issue on the current branch, including tests and docs, without committing. Use when the branch already exists. Use create-branch-implement-and-pr for the full flow. +argument-hint: "[issue number]" +disable-model-invocation: true +--- +Implement the GitHub issue given as the argument (ask for the number if missing) on the current branch. + +1. Read the issue (GitHub MCP tools). +2. Implement the change following `AGENTS.md` (code style, XML docs, tests, docs updates). +3. Build and run tests; fix failures. +4. Summarize the changes and ask the user whether the implementation is done as expected. + +Do not commit, push or open a PR. Never work on `main`. diff --git a/.claude/skills/review-implementation/SKILL.md b/.claude/skills/review-implementation/SKILL.md new file mode 100644 index 0000000..0ce4ddd --- /dev/null +++ b/.claude/skills/review-implementation/SKILL.md @@ -0,0 +1,12 @@ +--- +name: review-implementation +description: Review local uncommitted and unpushed changes against the Code Review checklist before a pull request exists. For an existing pull request use review-pull-request. +--- +Review the uncommitted and unpushed changes on the current branch (diff against `main`). + +1. Apply the Code Review checklist in `.github/copilot-instructions.md`. +2. Run the build and tests; report failures. +3. Report only actionable findings ordered by severity (security/correctness > contracts > tests > style), with a concrete fix for each. +4. Fix findings the user approves. + +Do not commit, push or open a PR. diff --git a/.claude/skills/review-pull-request/SKILL.md b/.claude/skills/review-pull-request/SKILL.md new file mode 100644 index 0000000..10f1314 --- /dev/null +++ b/.claude/skills/review-pull-request/SKILL.md @@ -0,0 +1,16 @@ +--- +name: review-pull-request +description: Review an existing GitHub pull request by number using the Code Review checklist and report findings without submitting a review. For local changes use review-implementation. +argument-hint: "[PR number]" +--- +Review the pull request given as the argument (ask for the number if missing) using the GitHub MCP tools (details, diff, files, check runs, existing review comments). + +Apply the "Code Review" checklist in `.github/copilot-instructions.md`. + +Output: +1. Short summary of the change. +2. Findings grouped as Blocking / Suggestions / Nits, each with file, line and a concrete fix. +3. CI status and missing tests/docs. +4. Recommendation: approve, comment or request changes. + +Do not submit the review unless asked. diff --git a/.claude/skills/security-check/SKILL.md b/.claude/skills/security-check/SKILL.md new file mode 100644 index 0000000..49fb963 --- /dev/null +++ b/.claude/skills/security-check/SKILL.md @@ -0,0 +1,12 @@ +--- +name: security-check +description: Audit NuGet, .NET and npm dependencies for vulnerabilities and deprecated packages, read GitHub security alerts and fix findings at the root cause. Use for security reviews and before releases. +--- +Follow "Dependency and Security Checking" in `AGENTS.md`. + +1. Run `dotnet list src/Docentric.EInvoice.Validator.slnx package --vulnerable --include-transitive`, `--deprecated` and `--outdated`. +2. In `src/WebUI` run `npm audit` and `npm audit --omit=dev`. +3. Read open Dependabot, code scanning and secret scanning alerts (GitHub MCP tools). +4. Fix at the root cause: lowest patched version, direct reference for vulnerable transitive packages, `npm audit fix` before `--force`. Do not suppress warnings. +5. Rebuild, run tests and `npm run build`. +6. Summarize findings, fixes and anything left unresolved (with advisory IDs). diff --git a/.claude/skills/upgrade-dependency/SKILL.md b/.claude/skills/upgrade-dependency/SKILL.md new file mode 100644 index 0000000..d9026dd --- /dev/null +++ b/.claude/skills/upgrade-dependency/SKILL.md @@ -0,0 +1,13 @@ +--- +name: upgrade-dependency +description: Upgrade one NuGet or npm package, or the Mustang CLI JAR, as a dedicated change with release note review. Use when asked to bump a dependency version. +argument-hint: "[package or Mustang CLI] [target version]" +disable-model-invocation: true +--- +Upgrade the package given as the first argument to the version given as the second (ask if missing). + +- Read the release notes/changelog for breaking changes and security fixes; never invent CLI options or APIs. +- Change only what the upgrade requires; for Mustang CLI replace the JAR and update every reference (project file, Dockerfile, README, code). +- For npm, commit `package-lock.json`; for NuGet, keep versions consistent across projects. +- Build with warnings as errors, run all tests, and verify the UI builds when relevant. +- Commit as `# Upgrade to ` and note advisories or breaking changes in the PR. diff --git a/.editorconfig b/.editorconfig index 81eb883..a9d160c 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,200 +1,200 @@ -# top-most EditorConfig file -root = true - -############################################################ -# GLOBAL SETTINGS -############################################################ -[*] -charset = utf-8 -indent_style = space -indent_size = 4 -end_of_line = crlf -insert_final_newline = true -trim_trailing_whitespace = true -max_line_length = 140 - -############################################################ -# C# CODE STYLE (BACKEND) -############################################################ -[*.cs] -dotnet_language_version = latest - -# Indentation -indent_size = 4 - -# Using directives -dotnet_sort_system_directives_first = true -dotnet_separate_import_directive_groups = true -csharp_using_directive_placement = outside_namespace:suggestion - -# var preferences -csharp_style_var_for_built_in_types = false:suggestion -csharp_style_var_when_type_is_apparent = true:suggestion -csharp_style_var_elsewhere = false:suggestion - -# Namespaces -csharp_style_namespace_declarations = file_scoped:suggestion - -# Expression-bodied members -csharp_style_expression_bodied_methods = true:suggestion -csharp_style_expression_bodied_properties = true:suggestion -csharp_style_expression_bodied_accessors = true:suggestion - -# Pattern matching -csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion -csharp_style_pattern_matching_over_as_with_null_check = true:suggestion -csharp_style_pattern_matching_over_switch_expressions = true:suggestion - -# Nullability -dotnet_style_null_propagation = true:suggestion -dotnet_style_coalesce_expression = true:suggestion - -# Initializers -dotnet_style_object_initializer = true:suggestion -dotnet_style_collection_initializer = true:suggestion - -# Simplifications -dotnet_style_prefer_simplified_boolean_expressions = true:suggestion -dotnet_style_prefer_simplified_interpolation = true:suggestion - -# Index/range -csharp_style_prefer_index_operator = true:suggestion -csharp_style_prefer_range_operator = true:suggestion - -############################################################ -# C# NAMING RULES -############################################################ -[*.cs] - -# Private fields: _camelCase -dotnet_naming_rule.private_fields_should_be_camelcase.rule = private_fields_should_be_camelcase -dotnet_naming_rule.private_fields_should_be_camelcase.severity = suggestion -dotnet_naming_rule.private_fields_should_be_camelcase.symbols = private_fields -dotnet_naming_rule.private_fields_should_be_camelcase.style = camelcase_underscore - -dotnet_naming_symbols.private_fields.applicable_kinds = field -dotnet_naming_symbols.private_fields.applicable_accessibilities = private, internal, protected -dotnet_naming_style.camelcase_underscore.capitalization = camel_case -dotnet_naming_style.camelcase_underscore.required_prefix = _ - -# Constants: PascalCase -dotnet_naming_rule.constants.rule = constants_rule -dotnet_naming_rule.constants.severity = suggestion -dotnet_naming_rule.constants.symbols = constants -dotnet_naming_rule.constants.style = pascalcase - -dotnet_naming_symbols.constants.applicable_kinds = field -dotnet_naming_symbols.constants.required_modifiers = const -dotnet_naming_style.pascalcase.capitalization = pascal_case - -# Interfaces: I-prefixed -dotnet_naming_rule.interfaces.rule = interfaces_rule -dotnet_naming_rule.interfaces.severity = suggestion -dotnet_naming_rule.interfaces.symbols = interfaces -dotnet_naming_rule.interfaces.style = iprefix - -dotnet_naming_symbols.interfaces.applicable_kinds = interface -dotnet_naming_style.iprefix.capitalization = pascal_case -dotnet_naming_style.iprefix.required_prefix = I - -############################################################ -# TYPESCRIPT / JAVASCRIPT (FRONTEND / SPA) -############################################################ - -[*.ts] -indent_style = space -indent_size = 2 -quote_type = double -max_line_length = 120 - -[*.tsx] -indent_style = space -indent_size = 2 -quote_type = double -max_line_length = 120 - -[*.js] -indent_style = space -indent_size = 2 -quote_type = double -max_line_length = 120 - -[*.jsx] -indent_style = space -indent_size = 2 -quote_type = double -max_line_length = 120 - -# Semicolons / trailing commas (for editors that honor JetBrains-style keys) -ij_javascript_semicolon_after_statement = always -ij_javascript_trailing_comma = true -ij_javascript_jsx_attribute_value_alignment = align_multiline - -############################################################ -# XML (Configs, XDocument templates, etc.) -############################################################ -[*.xml] -indent_style = space -indent_size = 2 -ij_xml_space_around_equal_sign = false -ij_xml_keep_line_breaks = true -ij_xml_align_attributes = false - -############################################################ -# MSBUILD & PROJECT FILES (.csproj, .esproj) -############################################################ -[*.csproj] -indent_style = space -indent_size = 2 - -[*.esproj] -indent_style = space -indent_size = 2 - -############################################################ -# SOLUTION FILES (.sln) -############################################################ -[*.sln] -indent_style = space -indent_size = 4 -trim_trailing_whitespace = true -insert_final_newline = true - -############################################################ -# JSON -############################################################ -[*.json] -indent_style = space -indent_size = 2 - -############################################################ -# YAML -############################################################ -[*.yml] -indent_style = space -indent_size = 2 - -############################################################ -# Markdown -############################################################ -[*.md] -trim_trailing_whitespace = false -max_line_length = off - -############################################################ -# HTML / CSS -############################################################ -[*.html] -indent_style = space -indent_size = 2 - -[*.css] -indent_style = space -indent_size = 2 - -############################################################ -# Dockerfiles -############################################################ -[Dockerfile] -indent_style = space -indent_size = 2 +# top-most EditorConfig file +root = true + +############################################################ +# GLOBAL SETTINGS +############################################################ +[*] +charset = utf-8 +indent_style = space +indent_size = 4 +end_of_line = crlf +insert_final_newline = true +trim_trailing_whitespace = true +max_line_length = 140 + +############################################################ +# C# CODE STYLE (BACKEND) +############################################################ +[*.cs] +dotnet_language_version = latest + +# Indentation +indent_size = 4 + +# Using directives +dotnet_sort_system_directives_first = true +dotnet_separate_import_directive_groups = true +csharp_using_directive_placement = outside_namespace:suggestion + +# var preferences +csharp_style_var_for_built_in_types = false:suggestion +csharp_style_var_when_type_is_apparent = true:suggestion +csharp_style_var_elsewhere = false:suggestion + +# Namespaces +csharp_style_namespace_declarations = file_scoped:suggestion + +# Expression-bodied members +csharp_style_expression_bodied_methods = true:suggestion +csharp_style_expression_bodied_properties = true:suggestion +csharp_style_expression_bodied_accessors = true:suggestion + +# Pattern matching +csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion +csharp_style_pattern_matching_over_as_with_null_check = true:suggestion +csharp_style_pattern_matching_over_switch_expressions = true:suggestion + +# Nullability +dotnet_style_null_propagation = true:suggestion +dotnet_style_coalesce_expression = true:suggestion + +# Initializers +dotnet_style_object_initializer = true:suggestion +dotnet_style_collection_initializer = true:suggestion + +# Simplifications +dotnet_style_prefer_simplified_boolean_expressions = true:suggestion +dotnet_style_prefer_simplified_interpolation = true:suggestion + +# Index/range +csharp_style_prefer_index_operator = true:suggestion +csharp_style_prefer_range_operator = true:suggestion + +############################################################ +# C# NAMING RULES +############################################################ +[*.cs] + +# Private fields: _camelCase +dotnet_naming_rule.private_fields_should_be_camelcase.rule = private_fields_should_be_camelcase +dotnet_naming_rule.private_fields_should_be_camelcase.severity = suggestion +dotnet_naming_rule.private_fields_should_be_camelcase.symbols = private_fields +dotnet_naming_rule.private_fields_should_be_camelcase.style = camelcase_underscore + +dotnet_naming_symbols.private_fields.applicable_kinds = field +dotnet_naming_symbols.private_fields.applicable_accessibilities = private, internal, protected +dotnet_naming_style.camelcase_underscore.capitalization = camel_case +dotnet_naming_style.camelcase_underscore.required_prefix = _ + +# Constants: PascalCase +dotnet_naming_rule.constants.rule = constants_rule +dotnet_naming_rule.constants.severity = suggestion +dotnet_naming_rule.constants.symbols = constants +dotnet_naming_rule.constants.style = pascalcase + +dotnet_naming_symbols.constants.applicable_kinds = field +dotnet_naming_symbols.constants.required_modifiers = const +dotnet_naming_style.pascalcase.capitalization = pascal_case + +# Interfaces: I-prefixed +dotnet_naming_rule.interfaces.rule = interfaces_rule +dotnet_naming_rule.interfaces.severity = suggestion +dotnet_naming_rule.interfaces.symbols = interfaces +dotnet_naming_rule.interfaces.style = iprefix + +dotnet_naming_symbols.interfaces.applicable_kinds = interface +dotnet_naming_style.iprefix.capitalization = pascal_case +dotnet_naming_style.iprefix.required_prefix = I + +############################################################ +# TYPESCRIPT / JAVASCRIPT (FRONTEND / SPA) +############################################################ + +[*.ts] +indent_style = space +indent_size = 2 +quote_type = double +max_line_length = 120 + +[*.tsx] +indent_style = space +indent_size = 2 +quote_type = double +max_line_length = 120 + +[*.js] +indent_style = space +indent_size = 2 +quote_type = double +max_line_length = 120 + +[*.jsx] +indent_style = space +indent_size = 2 +quote_type = double +max_line_length = 120 + +# Semicolons / trailing commas (for editors that honor JetBrains-style keys) +ij_javascript_semicolon_after_statement = always +ij_javascript_trailing_comma = true +ij_javascript_jsx_attribute_value_alignment = align_multiline + +############################################################ +# XML (Configs, XDocument templates, etc.) +############################################################ +[*.xml] +indent_style = space +indent_size = 2 +ij_xml_space_around_equal_sign = false +ij_xml_keep_line_breaks = true +ij_xml_align_attributes = false + +############################################################ +# MSBUILD & PROJECT FILES (.csproj, .esproj) +############################################################ +[*.csproj] +indent_style = space +indent_size = 2 + +[*.esproj] +indent_style = space +indent_size = 2 + +############################################################ +# SOLUTION FILES (.sln) +############################################################ +[*.sln] +indent_style = space +indent_size = 4 +trim_trailing_whitespace = true +insert_final_newline = true + +############################################################ +# JSON +############################################################ +[*.json] +indent_style = space +indent_size = 2 + +############################################################ +# YAML +############################################################ +[*.yml] +indent_style = space +indent_size = 2 + +############################################################ +# Markdown +############################################################ +[*.md] +trim_trailing_whitespace = false +max_line_length = off + +############################################################ +# HTML / CSS +############################################################ +[*.html] +indent_style = space +indent_size = 2 + +[*.css] +indent_style = space +indent_size = 2 + +############################################################ +# Dockerfiles +############################################################ +[Dockerfile] +indent_style = space +indent_size = 2 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..55ef660 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,6 @@ +* text=auto eol=crlf +*.sh text eol=lf +Dockerfile text eol=lf +.env text eol=lf +.dockerignore text eol=lf +*.jar binary diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..7540ebc --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,25 @@ +# Copilot Instructions + +All general project rules (layout, code style, branching, commits, pull requests, tests, frontend, build, pipelines, GitHub access, dependency security) are in [`AGENTS.md`](../AGENTS.md) at the repository root. Follow them. Do not duplicate them here. + +Reusable workflows are skills in `.claude/skills/` (invoke with `/`). + +## Code Review + +Applies to human and Copilot reviews. Review the diff against `AGENTS.md` and this checklist; comment only on actionable findings, ordered by severity (security/correctness > contracts > tests > style). + +Checklist: +- **Scope:** one issue per PR, no unrelated refactoring, branch/commit/PR title follow the conventions in `AGENTS.md`. +- **Correctness:** null/empty input, cancellation (`CancellationToken` passed through), error paths return a defined `ErrorCode`, no sync-over-async. +- **Contracts:** no breaking change to REST contracts or `ErrorCode` values unless requested; Swagger metadata, `ErrorCode.Examples.md` and README updated when behavior changes. +- **Security:** uploads validated (type, size, path traversal), temp files always deleted, no shell string concatenation for Java/Mustang, no secrets or file contents in logs, no stack traces or internal paths in responses. +- **Style:** `.editorconfig`, naming, least-exposure access modifiers, XML docs on every public member, TSDoc on exported frontend symbols. +- **Tests:** new/changed behavior has deterministic tests including edge cases and regressions; no loops/conditionals in tests; public test classes and methods have XML docs. +- **Dependencies:** new packages justified; `package-lock.json` committed with `package.json`; no suppressed audit warnings. +- **Pipeline:** workflow changes keep SHA-pinned actions, least-privilege permissions and `env:`-passed untrusted values. +- **Generated files:** no manual edits to `wwwroot/assets/*`, `*.g.cs` or the Mustang JAR (outside a dedicated upgrade). + +Review etiquette: +- Prefix non-blocking remarks with `nit:`; explain the *why* and suggest a concrete fix. +- Do not approve while CI is failing or blocking comments are unresolved. +- Authors address feedback with follow-up commits and resolve threads once fixed. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..740c1bf --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,19 @@ +## Summary + + + +## Related issue + +Closes # + +## Changes + +- + +## Testing + + + +## Breaking changes / notes + + diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ac36d49..95bb657 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -99,6 +99,16 @@ jobs: working-directory: src/WebUI run: npm ci + # Style checks. Non-blocking until the existing violations are fixed; then remove continue-on-error + - name: Check C# formatting + continue-on-error: true + run: dotnet format ${{ env.SOLUTION_PATH }} --verify-no-changes --no-restore --verbosity minimal + + - name: Lint web UI + continue-on-error: true + working-directory: src/WebUI + run: npm run lint + # Build and test - name: Build solution run: dotnet build ${{ env.SOLUTION_PATH }} --configuration Release --no-restore /p:Version=${{ steps.is_release.outputs.version }} /p:TreatWarningsAsErrors=true @@ -113,7 +123,7 @@ jobs: with: reports: test-results/**/coverage.cobertura.xml targetdir: coverage-report - reporttypes: Html;MarkdownSummaryGithub + reporttypes: Html;MarkdownSummaryGithub;Badges - name: Add coverage to job summary if: always() @@ -122,6 +132,37 @@ jobs: cat coverage-report/SummaryGithub.md >> "$GITHUB_STEP_SUMMARY" fi + # Publish coverage badge to the 'badges' branch (main only; uses contents: write from workflow permissions) + - name: Publish coverage badge + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + run: | + set -euo pipefail + SVG="$PWD/coverage-report/badge_linecoverage.svg" + if [ ! -f "$SVG" ]; then + echo "::warning::Coverage badge was not generated; skipping publish" + exit 0 + fi + WORKTREE="$(mktemp -d)" + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + if git ls-remote --exit-code --heads origin badges > /dev/null; then + git fetch origin badges + git worktree add -B badges "$WORKTREE" origin/badges + else + git worktree add --detach "$WORKTREE" + git -C "$WORKTREE" checkout --orphan badges + git -C "$WORKTREE" rm -rf --quiet . + fi + cp "$SVG" "$WORKTREE/ci-coverage.svg" + cd "$WORKTREE" + git add ci-coverage.svg + if git diff --cached --quiet; then + echo "Coverage badge unchanged" + else + git commit -m "Update coverage badge" + git push origin badges + fi + - name: Upload test results and coverage if: always() uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 diff --git a/.gitignore b/.gitignore index d5eec18..aef594c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,458 +1,461 @@ -*############################################################################################################################################ -## No files ignored on the root -############################################################################################################################################ -! -!* - -############################################################################################################################################ -## Repository build folder -############################################################################################################################################ -/build -/build/* -/build/** - -############################################################################################################################################ -## Users temp folder (e.g., egistry files, temp files, debug folders, upload folders, etc.) -############################################################################################################################################ -temp -temp/* -temp/** -**/temp -**/temp/* -**/temp/** -_temp -_temp/* -_temp/** -**/_temp -**/_temp/* -**/_temp/** -_ouput -_ouput/* -_ouput/** -**/_ouput -**/_ouput/* -**/_ouput/** - -############################################################################################################################################ -## Visual Studio -############################################################################################################################################ -.vs -.vs/* -.vs/** -**/.vs -**/.vs/* -**/.vs/** - -############################################################################################################################################ -## D365FO specifics -############################################################################################################################################ -Metadata/**/bin/ -Metadata/**/resources/ -Metadata/**/XppMetadata/ -Metadata/**/BPCheck.xml -Metadata/**/BuildModelResult.xml -Metadata/**/BuildProjectResult.err.xml -Metadata/**/BuildProjectResult.xml -Metadata/**/CompileLabels.xml -Metadata/**/*.xref -Metadata/**/*.$usr -*.version - -############################################################################################################################################ -## Docentric specifics -############################################################################################################################################ -# .Net Build results -[Rr]eleaseDebug/ - -############################################################################################################################################ -## Ignore Visual Studio temporary files, build results, and -## files generated by popular Visual Studio add-ons. -## -## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore -############################################################################################################################################ - -# User-specific files -*.rsuser -*.suo -*.user -*.userosscache -*.sln.docstates - -# User-specific files (MonoDevelop/Xamarin Studio) -*.userprefs - -# Mono auto generated files -mono_crash.* - -# Build results -[Dd]ebug/ -[Dd]ebugPublic/ -[Rr]elease/ -[Rr]eleases/ -x64/ -x86/ -[Ww][Ii][Nn]32/ -[Aa][Rr][Mm]/ -[Aa][Rr][Mm]64/ -bld/ -[Bb]in/ -[Oo]bj/ -[Ll]og/ -[Ll]ogs/ - -# Visual Studio 2015/2017 cache/options directory -.vs/ -# Uncomment if you have tasks that create the project's static files in wwwroot -#wwwroot/ - -# Visual Studio 2017 auto generated files -Generated\ Files/ - -# MSTest test Results -[Tt]est[Rr]esult*/ -[Bb]uild[Ll]og.* - -# NUnit -*.VisualState.xml -TestResult.xml -nunit-*.xml - -# Build Results of an ATL Project -[Dd]ebugPS/ -[Rr]eleasePS/ -dlldata.c - -# Benchmark Results -BenchmarkDotNet.Artifacts/ - -# .NET Core -project.lock.json -project.fragment.lock.json -artifacts/ - -# ASP.NET Scaffolding -ScaffoldingReadMe.txt - -# StyleCop -StyleCopReport.xml - -# Files built by Visual Studio -*_i.c -*_p.c -*_h.h -*.ilk -*.meta -*.obj -*.iobj -*.pch -*.pdb -*.ipdb -*.pgc -*.pgd -*.rsp -*.sbr -*.tlb -*.tli -*.tlh -*.tmp -*.tmp_proj -*_wpftmp.csproj -*.log -*.vspscc -*.vssscc -.builds -*.pidb -*.svclog -*.scc -npm-debug.log* -yarn-debug.log* -yarn-error.log* -pnpm-debug.log* -lerna-debug.log* - -# Chutzpah Test files -_Chutzpah* - -# Visual C++ cache files -ipch/ -*.aps -*.ncb -*.opendb -*.opensdf -*.sdf -*.cachefile -*.VC.db -*.VC.VC.opendb - -# Visual Studio profiler -*.psess -*.vsp -*.vspx -*.sap - -# Visual Studio Trace Files -*.e2e - -# TFS 2012 Local Workspace -$tf/ - -# Guidance Automation Toolkit -*.gpState - -# ReSharper is a .NET coding add-in -_ReSharper*/ -*.[Rr]e[Ss]harper -*.DotSettings.user - -# TeamCity is a build add-in -_TeamCity* - -# DotCover is a Code Coverage Tool -*.dotCover - -# AxoCover is a Code Coverage Tool -.axoCover/* -!.axoCover/settings.json - -# Coverlet is a free, cross platform Code Coverage Tool -coverage*.json -coverage*.xml -coverage*.info - -# Visual Studio code coverage results -*.coverage -*.coveragexml - -# NCrunch -_NCrunch_* -.*crunch*.local.xml -nCrunchTemp_* - -# MightyMoose -*.mm.* -AutoTest.Net/ - -# Web workbench (sass) -.sass-cache/ - -# Installshield output folder -[Ee]xpress/ - -# DocProject is a documentation generator add-in -DocProject/buildhelp/ -DocProject/Help/*.HxT -DocProject/Help/*.HxC -DocProject/Help/*.hhc -DocProject/Help/*.hhk -DocProject/Help/*.hhp -DocProject/Help/Html2 -DocProject/Help/html - -# Click-Once directory -publish/ - -# Publish Web Output -*.[Pp]ublish.xml -*.azurePubxml -# Note: Comment the next line if you want to checkin your web deploy settings, -# but database connection strings (with potential passwords) will be unencrypted -*.pubxml -*.publishproj - -# Microsoft Azure Web App publish settings. Comment the next line if you want to -# checkin your Azure Web App publish settings, but sensitive information contained -# in these scripts will be unencrypted -PublishScripts/ - -# NuGet Packages -*.nupkg -# NuGet Symbol Packages -*.snupkg -# The packages folder can be ignored because of Package Restore -**/[Pp]ackages/* -# except build/, which is used as an MSBuild target. -!**/[Pp]ackages/build/ -# Uncomment if necessary however generally it will be regenerated when needed -#!**/[Pp]ackages/repositories.config -# NuGet v3's project.json files produces more ignorable files -*.nuget.props -*.nuget.targets - -# Microsoft Azure Build Output -csx/ -*.build.csdef - -# Microsoft Azure Emulator -ecf/ -rcf/ - -# Windows Store app package directories and files -AppPackages/ -BundleArtifacts/ -Package.StoreAssociation.xml -_pkginfo.txt -*.appx -*.appxbundle -*.appxupload - -# Visual Studio cache files -# files ending in .cache can be ignored -*.[Cc]ache -# but keep track of directories ending in .cache -!?*.[Cc]ache/ - -# Others -ClientBin/ -~$* -*~ -*.dbmdl -*.dbproj.schemaview -*.jfm -#*.pfx -*.publishsettings -orleans.codegen.cs - -# Including strong name files can present a security risk -# (https://github.com/github/gitignore/pull/2483#issue-259490424) -#*.snk - -# Since there are multiple workflows, uncomment next line to ignore bower_components -# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) -#bower_components/ - -# RIA/Silverlight projects -Generated_Code/ - -# Backup & report files from converting an old project file -# to a newer Visual Studio version. Backup files are not needed, -# because we have git ;-) -_UpgradeReport_Files/ -Backup*/ -UpgradeLog*.XML -UpgradeLog*.htm -ServiceFabricBackup/ -*.rptproj.bak - -# SQL Server files -*.mdf -*.ldf -*.ndf - -# Business Intelligence projects -*.rdl.data -*.bim.layout -*.bim_*.settings -*.rptproj.rsuser -*- [Bb]ackup.rdl -*- [Bb]ackup ([0-9]).rdl -*- [Bb]ackup ([0-9][0-9]).rdl - -# Microsoft Fakes -FakesAssemblies/ - -# GhostDoc plugin setting file -*.GhostDoc.xml - -# Node.js Tools for Visual Studio -.ntvs_analysis.dat -node_modules/ - -# Visual Studio 6 build log -*.plg - -# Visual Studio 6 workspace options file -*.opt - -# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) -*.vbw - -# Visual Studio LightSwitch build output -**/*.HTMLClient/GeneratedArtifacts -**/*.DesktopClient/GeneratedArtifacts -**/*.DesktopClient/ModelManifest.xml -**/*.Server/GeneratedArtifacts -**/*.Server/ModelManifest.xml -_Pvt_Extensions - -# Paket dependency manager -.paket/paket.exe -paket-files/ - -# FAKE - F# Make -.fake/ - -# CodeRush personal settings -.cr/personal - -# Python Tools for Visual Studio (PTVS) -__pycache__/ -*.pyc - -# Cake - Uncomment if you are using it -# tools/** -# !tools/packages.config - -# Tabs Studio -*.tss - -# Telerik's JustMock configuration file -*.jmconfig - -# BizTalk build output -*.btp.cs -*.btm.cs -*.odx.cs -*.xsd.cs - -# OpenCover UI analysis results -OpenCover/ - -# Azure Stream Analytics local run output -ASALocalRun/ - -# MSBuild Binary and Structured Log -*.binlog - -# NVidia Nsight GPU debugger configuration file -*.nvuser - -# MFractors (Xamarin productivity tool) working folder -.mfractor/ - -# Local History for Visual Studio -.localhistory/ - -# BeatPulse healthcheck temp database -healthchecksdb - -# Backup folder for Package Reference Convert tool in Visual Studio 2017 -MigrationBackup/ - -# Ionide (cross platform F# VS Code tools) working folder -.ionide/ - -# Fody - auto-generated XML schema -FodyWeavers.xsd - -# Visual Studio Code -.vscode/* -!.vscode/settings.json -!.vscode/tasks.json -!.vscode/launch.json -!.vscode/extensions.json -!.vscode/*.code-snippets - -# Local History for Visual Studio Code -.history/ - -# Built Visual Studio Code Extensions -*.vsix - -# Web build -node_modules -dist -dist-ssr -*.local - -# Rider -.idea +*############################################################################################################################################ +## No files ignored on the root +############################################################################################################################################ +! +!* + +############################################################################################################################################ +## Repository build folder +############################################################################################################################################ +/build +/build/* +/build/** + +############################################################################################################################################ +## Users temp folder (e.g., egistry files, temp files, debug folders, upload folders, etc.) +############################################################################################################################################ +temp +temp/* +temp/** +**/temp +**/temp/* +**/temp/** +_temp +_temp/* +_temp/** +**/_temp +**/_temp/* +**/_temp/** +_ouput +_ouput/* +_ouput/** +**/_ouput +**/_ouput/* +**/_ouput/** + +############################################################################################################################################ +## Visual Studio +############################################################################################################################################ +.vs +.vs/* +.vs/** +**/.vs +**/.vs/* +**/.vs/** + +############################################################################################################################################ +## D365FO specifics +############################################################################################################################################ +Metadata/**/bin/ +Metadata/**/resources/ +Metadata/**/XppMetadata/ +Metadata/**/BPCheck.xml +Metadata/**/BuildModelResult.xml +Metadata/**/BuildProjectResult.err.xml +Metadata/**/BuildProjectResult.xml +Metadata/**/CompileLabels.xml +Metadata/**/*.xref +Metadata/**/*.$usr +*.version + +############################################################################################################################################ +## Docentric specifics +############################################################################################################################################ +# .Net Build results +[Rr]eleaseDebug/ + +############################################################################################################################################ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore +############################################################################################################################################ + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +#*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd + +# Visual Studio Code +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +!.vscode/*.code-snippets + +# Local History for Visual Studio Code +.history/ + +# Built Visual Studio Code Extensions +*.vsix + +# Web build +node_modules +dist +dist-ssr +*.local + +# Rider +.idea + +# Claude Code personal settings +.claude/settings.local.json diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..c9ac341 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,11 @@ +{ + "mcpServers": { + "github": { + "type": "http", + "url": "https://api.githubcopilot.com/mcp/", + "headers": { + "Authorization": "Bearer ${GITHUB_PERSONAL_ACCESS_TOKEN}" + } + } + } +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..5b6a246 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,302 @@ +# Agent Instructions: Docentric e-Invoice Validator + +Shared instructions for all AI coding agents (GitHub Copilot, Claude Code, others). This file is the single source of truth; `CLAUDE.md` and `.github/copilot-instructions.md` only point here. + +REST API and web UI for validating ZuGFeRD / Factur-X e-invoices (PDF and XML). The backend wraps the Mustang Project CLI (Java) and is used mainly for QA of Docentric AX. + +## Repository Layout + +| Path | Description | +|------|-------------| +| `src/Server` (`RestServer.csproj`) | ASP.NET Core 10 minimal API. `Configuration/`, `Contracts/`, `Endpoints/`, `IO/`, `Services/`. Serves the built UI from `wwwroot`. | +| `src/Server.Tests` (`RestServer.Tests.csproj`) | xUnit v3 tests for the server. | +| `src/WebUI` (`WebUI.esproj`) | React + Vite + TypeScript, shadcn/ui, Tailwind CSS v4, react-router v7. | +| `src/Directory.Build.props` | Shared settings (.NET 10, nullable, implicit usings, version metadata). | +| `.editorconfig` | Authoritative formatting and naming rules. | +| `.github/workflows/build.yml` | CI build. | +| `.claude/skills/` | Shared agent skills (workflows), used by both Copilot and Claude Code. | + +Solution: `src/Docentric.EInvoice.Validator.slnx`. + +## General Principles + +- Keep changes small, focused and in line with existing patterns. No unrelated refactoring. +- Preserve existing behavior and public API contracts (REST contracts, `ErrorCode` values) unless a breaking change is explicitly requested. +- Do not add dependencies unless clearly necessary. Do not change TFM, SDK or `LangVersion`. +- Never invent APIs or Mustang CLI options; verify them in code or documentation. +- Never commit secrets, credentials or personal data. Treat uploaded files as untrusted input. +- Do not edit generated files or build output (`wwwroot/assets/*`, `*.g.cs`, `// `). +- Do not hand-edit `Mustang-CLI-*.jar`; it is upgraded only as part of a dedicated dependency upgrade. + +## Branching Strategy + +- `main` is the always-releasable default branch. Never commit directly to `main`; all changes go through a pull request. +- Create one short-lived branch per GitHub issue from the latest `main`. +- Branch name: `/_` + - Types: `feature`, `bugfix`, `hotfix`, `chore`, `docs`. + - Examples: `feature/17_upgrade-mustang-project-cli-to-2.26.0`, `bugfix/21_fix-pdf-upload-limit`. + - Work without an issue: `chore/noissue_`. +- Keep branches up to date by rebasing on or merging `main`; resolve conflicts locally. +- Delete the branch after merge. +- Releases are produced from Git tags named `v` (`vMAJOR.MINOR.PATCH`, optionally with a prerelease suffix such as `v1.2.3-preview.0`). The pipeline strips the `v` and passes the result as `/p:Version`, which overrides the placeholder `0.0.0` in `src/Directory.Build.props`. + +## Commit Messages + +Format: `# ` + +- Start with the issue number (`#17`). Use `#noissue` when none exists. Multiple issues: `#13, #19, #20 ...`. +- Summary in imperative mood, capitalized, no trailing period, about 72 characters or less (e.g. `#15 Migrate to Tailwind CSS v4 and update utilities`). +- Optional prefixes such as `feat:` / `fix:` after the issue number are acceptable but not required; stay consistent within a PR. +- Add a body (blank line after the subject) when the "why" is not obvious: motivation, trade-offs, breaking changes. +- One logical change per commit; do not mix formatting-only changes with functional changes. + +## Pull Requests + +- Target `main`. Title follows the commit format: `# `. +- Description must include (the template in `.github/pull_request_template.md` provides these sections): + 1. **Summary**: what and why. + 2. **Related issue**: `Closes #`. + 3. **Changes**: bullet list of notable changes. + 4. **Testing**: automated tests added/run and manual verification (including Docker if relevant). + 5. **Breaking changes / notes**: API, configuration or deployment impact, if any. +- Keep PRs small and focused; CI (`build.yml`) must pass. +- Add or update tests and documentation (README, `ErrorCode.Examples.md`, Swagger metadata) when behavior changes. +- Prefer squash merge with a message following the commit format. +- Address review comments with follow-up commits; do not force-push over reviewed history without notice. + +## C# Code Style and Naming + +Follow `.editorconfig` first: + +- 4 spaces indent, CRLF line endings, file-scoped namespaces, `Nullable` and `ImplicitUsings` enabled. +- Namespace matches folder structure under root namespace `Docentric.EInvoice.Validator.RestServer`. +- One top-level type per file; file name equals type name. +- Naming: + - Types, methods, properties, events, constants: `PascalCase`. + - Interfaces: `IPascalCase`. + - Parameters and locals: `camelCase`. + - Private/internal/protected fields: `_camelCase`. + - Async methods end with `Async`. + - Endpoint groups: `Endpoints`; DTOs: `Request` / `Response`. +- Least-exposure rule: `private` > `internal` > `protected` > `public`. Types default to `internal` unless needed externally. +- Prefer records for DTOs/contracts; prefer immutability. +- Use `var` when the type is obvious; use `sealed` for classes not designed for inheritance. +- Do not add interfaces or abstractions unless needed for external dependencies or testing. +- Null handling: guard early with `ArgumentNullException.ThrowIfNull`, `string.IsNullOrWhiteSpace`; avoid the `!` operator. +- Exceptions: throw precise types; do not catch base `Exception` silently; log and rethrow or return a defined `ErrorCode`. +- Async end to end: accept and pass `CancellationToken`, no sync-over-async, no fire-and-forget, `ConfigureAwait(false)` only in library-style helper code. +- Use dependency injection and `ILogger` with structured logging (message templates, no string interpolation); never log file contents or sensitive data. +- Uploaded files: use `TemporaryUploadedFile`, validate with `FileTypeValidator`, never trust client file names or paths, and always delete temporary files. +- External process execution (Java / Mustang): pass arguments as separate argument-list items (no shell string concatenation), apply timeouts, and capture output. +- Configuration through `appsettings*.json` / environment variables; no hard-coded paths. +- User-facing strings and error texts should be centralized (constants / resources), not scattered. + +## Documentation of Public Members + +- **Every public type and member** (classes, records, record parameters/properties, methods, properties, enums and enum values, constants, events) must have XML documentation: ``, plus ``, ``, `` and `` where applicable. +- Use `` for overrides and interface implementations. +- Comments explain *why*, not *what*. No commented-out code. +- Endpoints should declare Swagger metadata (`WithName`, `WithSummary`/`WithDescription`, `Produces`) consistent with existing endpoints. +- Update `README.md` when setup, features or API behavior change. + +## Tests + +- Project: `src/Server.Tests`; framework: **xUnit v3** (`[Fact]`, `[Theory]` + `[InlineData]`). Use the framework's built-in `Assert`. +- Mirror source structure: `MustangValidationReportParser` -> `MustangValidationReportParserTests`. +- Test names describe behavior: `WhenReportContainsErrorsThenResultIsInvalid` (or the existing convention in the file). +- Every public test class and public test method (`[Fact]`/`[Theory]`) must have XML documentation: a `` describing the verified behavior, plus `` for `[Theory]` parameters and primary-constructor parameters (e.g. fixtures). Private helpers need no docs. +- Arrange-Act-Assert, one behavior per test, one logical assertion per test; use `[Theory]` for multiple outcomes of one precondition. +- No conditionals or loops inside tests. Tests must be deterministic and independent (parallel-safe, no static mutable state). +- Test through public APIs; avoid `InternalsVisibleTo` and visibility changes. +- Avoid mocks for code inside the solution; mock only external dependencies (Java process, file system). Prefer small sample XML/report fixtures. +- Avoid disk I/O where possible; if needed use randomized temp paths. +- New or changed public behavior requires tests, including edge cases (empty/invalid input, cancellation, failures) and regressions for fixed bugs. +- Run: `dotnet test src/Docentric.EInvoice.Validator.slnx`. + +## Frontend (`src/WebUI`) + +- TypeScript strict mode; functional React components and hooks; no `any` without justification. +- Components `PascalCase.tsx`; hooks `useCamelCase`; utilities and other files follow the existing naming in the folder. +- Use shadcn/ui components and Tailwind utility classes (Tailwind v4); do not add other UI libraries. +- 2-space indentation (see `.editorconfig`). +- Keep API types aligned with server contracts in `src/Server/Contracts`. +- Document exported functions, hooks and components with TSDoc. +- Build output is copied to `src/Server/wwwroot`; never edit it manually. + +## Build, Run and Verify + +```powershell +dotnet restore src/Docentric.EInvoice.Validator.slnx +dotnet build src/Docentric.EInvoice.Validator.slnx --configuration Release +dotnet test src/Docentric.EInvoice.Validator.slnx +dotnet run --project src/Server/RestServer.csproj +cd src/WebUI; npm install; npm run build +``` + +- Prerequisites: .NET 10 SDK, Node.js 20+, JRE 11+, Docker (optional). +- Docker: `src/docker-build.ps1`, `src/docker-run.ps1` (app on `http://localhost:18889`). +- Before finishing a change: build succeeds without new warnings, tests pass, and docs are updated. + +## Build Pipelines (GitHub Actions) + +- Workflow: `.github/workflows/build.yml` (jobs: build/test/publish, Docker image, GitHub release). Triggers: push to `main`, `develop`, `feature/**`, `v*` tags; pull requests to `main`/`develop`; manual dispatch. +- The pipeline must stay green: restore, `npm ci`, build with `/p:TreatWarningsAsErrors=true`, tests with coverage, publish (x64 + arm64, self-contained on release), Docker image. +- Release builds are triggered only by `v` tags; the version is injected through `/p:Version`. Never hard-code versions in workflows or project files. +- Pin third-party actions to a full commit SHA with a version comment (e.g. `# v7`); do not switch to floating tags. +- Use least-privilege `permissions` (elevate per job only where needed, e.g. `contents: write` for releases). Pass untrusted values (`github.ref_name`, PR titles, etc.) through `env:`, never directly into `run:` scripts. +- Use `secrets.*` for credentials (e.g. Docker Hub); never echo secrets or commit them. +- Keep `DOTNET_VERSION`, `NODE_VERSION` and `SOLUTION_PATH` in the workflow `env` block aligned with `Directory.Build.props` and the README prerequisites. +- Use `npm ci` (not `npm install`) and `--no-restore`/`--no-build` where already established; keep `timeout-minutes` and `concurrency` settings. +- Validate workflow changes on a branch and check the Actions run before requesting review. +- The build job also runs `dotnet format --verify-no-changes` and `npm run lint`. Both are currently non-blocking (`continue-on-error`) because of existing violations. Run them locally before pushing, do not add new violations, and remove `continue-on-error` once the existing ones are fixed. + +### Branch protection (repository settings, configured by a maintainer) + +Protect `main` with a ruleset or branch protection rule: + +- Require a pull request before merging, with at least one approval, dismissal of stale approvals on new commits and resolution of all conversations. +- Require status checks to pass and require the branch to be up to date: `Build Solution` (add the Docker job if it must gate merges). +- Block force pushes and deletion. Restrict who can push, and apply the rules to administrators too. +- Restrict creation of `v*` tags to maintainers, because tags trigger releases. +- Optionally enable Copilot code review as an automatic reviewer. + +## GitHub Access + +- Use the GitHub MCP server tools (configured for Copilot in the IDE and for Claude Code in `.mcp.json`) for all GitHub operations: reading issues/PRs/files, creating branches, creating and updating pull requests, review comments, checking workflow/check runs, releases and tags. If MCP is unavailable, use the `gh` CLI. +- Before starting work, read the related issue and look for existing PRs/branches to avoid duplicates. +- Create branches and PRs following the Branching Strategy, Commit Messages and Pull Requests sections above; never push to `main`. +- After pushing, check PR check runs/status and fix failures before requesting review. +- Read Dependabot/code scanning/secret scanning alerts and Copilot review feedback when addressing security work. +- Run secret scanning on new or changed content before committing when it may contain credentials. +- Do not paste tokens into prompts, files or commits; rely on the configured authentication. + +## Dependency and Security Checking + +Run these checks when changing dependencies, before releases, and when security alerts are reported. Fix findings at the root cause (upgrade or replace the vulnerable package); do not suppress warnings or alerts without a documented reason. + +### NuGet / .NET + +```powershell +dotnet list src/Docentric.EInvoice.Validator.slnx package --vulnerable --include-transitive +dotnet list src/Docentric.EInvoice.Validator.slnx package --deprecated +dotnet list src/Docentric.EInvoice.Validator.slnx package --outdated +``` + +- Upgrade to the lowest patched version; for vulnerable transitive packages add a direct reference at the patched version rather than ignoring the warning. +- Stay on supported .NET 10 servicing releases (SDK, runtime, base Docker images); do not change the TFM. +- Keep NuGet audit warnings (`NU1901`-`NU1904`) visible; with `TreatWarningsAsErrors` in CI they must be resolved, not suppressed. +- Rebuild and run all tests after every dependency change. + +### npm / Node.js (`src/WebUI`) + +```powershell +cd src/WebUI +npm audit +npm audit --omit=dev +npm outdated +npm audit fix +``` + +- Prefer `npm audit fix` (non-breaking); use `--force` only deliberately, reviewing the changelog and fixing resulting breakage. +- Always commit `package-lock.json` with `package.json` changes; CI uses `npm ci`. +- Use an active LTS Node.js version (20+; keep CI `NODE_VERSION` and Docker/README prerequisites in sync when upgrading). +- Do not add packages without need; remove unused ones. Prefer maintained packages with no open advisories. +- Rebuild the UI (`npm run build`) and verify it loads after dependency updates; never edit `wwwroot/assets/*` manually. + +### Other + +- Mustang CLI (Java) and the JRE/OpenJDK base image are upgraded only as dedicated dependency-upgrade work; check their release notes for security fixes. +- Keep Docker base images current and prefer pinned, official tags. +- Dependency upgrade commits use the standard format, e.g. `# Upgrade to (fixes )`; mention the advisory (GHSA/CVE) in the PR description under **Breaking changes / notes**. +- Treat Dependabot, code scanning and secret scanning alerts as blocking for release. + +## Code Review + +The review checklist lives in `.github/copilot-instructions.md` (Copilot code review reads only that file). Skills `review-implementation` and `review-pull-request` apply it. + +## Skills + +Reusable workflows live in `.claude/skills//SKILL.md` (same format for Copilot and Claude Code). Invoke with `/`. + +| Skill | Purpose | +|-------|---------| +| `create-branch-implement-and-pr` | Read issue, create branch, implement, review, confirm with user, open PR | +| `create-branch` | Read issue and create the branch | +| `implement` | Implement the issue on the current branch | +| `review-implementation` | Review local changes before opening a PR | +| `create-pull-request` | Commit, push and open the PR | +| `commit-message` | Generate a commit message for the current changes | +| `review-pull-request` | Review a PR using the Code Review checklist | +| `add-tests` | Write xUnit v3 tests for selected code | +| `document-public-members` | Add missing XML docs / TSDoc | +| `security-check` | Run NuGet/npm audits and fix findings | +| `upgrade-dependency` | Upgrade a package or Mustang CLI as a dedicated change | + +Keep skills short, reference this file instead of repeating its rules, and update them when conventions change. + +## Maintaining This File + +This file is loaded into every agent session, so its size has a cost. Current size is about 290 lines (roughly 5,000 tokens). + +- Budget: split the file when it exceeds about 350 lines or 7,000 tokens, or when a new section applies to only one area of the repository. +- Do not add rules that `.editorconfig`, analyzers or CI already enforce. Prefer enforcing a rule mechanically over describing it here. +- Planned split, in this order: + 1. Move "Dependency and Security Checking" into the `security-check` skill, because only that task needs it. + 2. Move "Build Pipelines (GitHub Actions)" into `.github/instructions/workflows.instructions.md` with `applyTo: ".github/workflows/**"`. + 3. Move "C# Code Style and Naming" and "Documentation of Public Members" into `.github/instructions/csharp.instructions.md` with `applyTo: "src/Server/**/*.cs"`. + 4. Move "Tests" and "Frontend" into scoped files with `applyTo` set to `src/Server.Tests/**` and `src/WebUI/**`. +- Claude Code does not read `.github/instructions`. When a section is moved there, add an `@` import of the new file to `CLAUDE.md` or a path scoped file under `.claude/rules/`, so both tools keep the same rules. +- Keep branching, commits, pull requests, build commands, writing style and general principles in this file, because they apply to every task. + +## Writing Style + +Applies to everything written for people: chat answers, commit messages, PR descriptions, review comments, documentation and code comments. Write plainly, specifically and naturally. Prefer complete sentences over slogans, hype, pseudo profundity or dramatic copywriting. These rules override any default tendency toward punchy, inspirational, promotional or thought leadership writing. When in doubt, choose the less theatrical version. + +### Punctuation + +- Normal hyphens are allowed (compound words, branch names, CLI options, ranges written with a hyphen). +- Do not use em dashes, en dashes or similar characters (horizontal bar, figure dash, minus sign used as a dash, `--` used as a dash) as punctuation or to join clauses. Use a comma, colon, parentheses or a new sentence instead. +- Avoid unnecessary ALL CAPS and excessive rhetorical questions. + +### Hard bans + +Do not use the contrast formula "It's not X. It's Y." or close variants such as "This isn't X; it's Y.", "Not X, but Y.", "It goes beyond X. It is Y." and "X? No. Y.". Do not use repeated escalation such as "It's not an interview, it's a statement. It's not a statement, it's a revolution." Do not use this structure for emphasis, persuasion, summaries, transitions, headings or punchlines. + +### No hype or flattery + +- Do not use phrases like "Here is where it gets really interesting", "You have just asked the big question", "This changes everything", "This is the game changer", "This is the moment" or "You are thinking about this exactly the right way". +- Do not call the user unusually insightful, unique, brave, visionary or exceptional unless there is a concrete reason. +- Do not comment on the quality of the user's question. + +### Banned headings and transitions + +Avoid "Here is why", "Why X matters", "Reality Check" and "Uncomfortable Question". Use headings that name the topic, such as "Trade-offs", "Main constraint", "What changes in practice", "Risks", "Recommendation" or "Evidence". + +### No slogan fragments or corporate haiku + +- Do not write rhythmic sequences such as "One goal. One team. One winner." or "One team. One mindset." +- Do not write three beat fragments for effect, such as "Faster. Smarter. Better." or "Build. Ship. Win." +- Do not use the exchange formula ("Inputs down, scale up.", "Costs down, value up."). +- Do not use grand metaphors built from a verb and an abstract noun ("Unlock the future.", "Navigate the journey.", "Transform the paradigm."). Name the actual action. +- Do not use the dashboard formula ("Friction gone, growth found.", "Noise removed, clarity gained."). +- Express any useful idea as a normal grammatical sentence, for example "This should save time without reducing quality." + +### Preferred style + +Prose should be direct, concrete, calm, specific, proportionate to the evidence and free of manufactured drama. Prefer ordinary sentence structure, precise verbs, concrete nouns, explicit trade-offs, measured claims, useful examples and direct answers. Avoid rhetorical inflation, motivational language, fake suspense, forced punchlines, repetitive cadence and poetic business jargon. + +### Rewrite rule + +Before sending a response, scan for formulaic phrasing. If a sentence sounds like a slogan, manifesto, keynote line, LinkedIn hook, ad headline or corporate poster, rewrite it as ordinary prose. + +- Instead of "It's not a feature. It's a platform." write "This feature is broad enough to function as a platform for several workflows." +- Instead of "Friction gone, growth found." write "Removing this source of friction may make growth easier." +- Instead of "One team. One mission. One standard." write "The team should align on a shared mission and a consistent standard." +- Instead of "Here is why this matters." write "The main practical consequence is that deployment becomes cheaper and easier to maintain." + +## Security and Performance + +- Enforce file type and size limits on uploads; protect against path traversal and zip/PDF bombs. +- Do not expose stack traces or internal paths in API responses; return `ErrorCode` based responses. +- Stream large files; avoid loading whole uploads into memory when not needed. +- Do not optimize without evidence, but avoid obvious scalability problems. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..650a9bf --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,7 @@ +@AGENTS.md + +## Claude Code Notes + +- Code review checklist: `.github/copilot-instructions.md` (section "Code Review"). +- Skills are in `.claude/skills/`; MCP servers are configured in `.mcp.json` (requires the `GITHUB_PERSONAL_ACCESS_TOKEN` environment variable). +- `.claude/settings.json` defines shared permissions and a `PreToolUse` hook (`.claude/hooks/guard.mjs`, needs Node.js). The hook blocks edits to generated files, the Mustang JAR and `.env` files, and blocks force pushes and commits or pushes on `main`. Personal overrides go in `.claude/settings.local.json`, which is not committed. diff --git a/README.md b/README.md index ce9200e..db43363 100644 --- a/README.md +++ b/README.md @@ -1,241 +1,270 @@ -# Docentric e-Invoice Validator - -[![Build Status](https://github.com/Docentric/docentric-e-invoice-validator/actions/workflows/build.yml/badge.svg)](https://github.com/Docentric/docentric-e-invoice-validator/actions/workflows/build.yml) -[![Docker Hub](https://img.shields.io/docker/v/docentric/e-invoice-validator?label=Docker%20Hub)](https://hub.docker.com/r/docentric/e-invoice-validator) -[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) - -A REST API and web-based validation tool for **ZuGFeRD** and **Factur-X** **e-invoices**, built with ASP.NET Core and React. - -## Overview - -This project provides a simple, user-friendly interface for validating ZuGFeRD and Factur-X **e-invoices** (both PDF and XML formats). It wraps the [Mustang Project](https://www.mustangproject.org/) validation engine in a modern web application with both API and UI access. - -**Purpose**: This tool is primarily used for unit testing and quality assurance in the development of [Docentric AX](https://ax.docentric.com/), our document generation and distribution solution. It helps ensure that e-invoices generated by Docentric AX comply with ZuGFeRD and Factur-X standards. - -## Features - -- **Web UI**: Modern React-based interface for uploading and validating invoices -- **REST API**: Programmatic access for automated testing and integration -- **Multiple Formats**: Supports both PDF (hybrid) and XML e-invoice formats -- **Standards Compliance**: Validates ZuGFeRD and Factur-X invoices using Mustang Project -- **Docker Ready**: Pre-built Docker images available on Docker Hub -- **CI/CD**: Automated builds and testing with GitHub Actions - -## Technology Stack - -- **Backend**: ASP.NET Core 10 (minimal API) -- **Frontend**: React + Vite + TypeScript -- **UI Components**: shadcn/ui with Tailwind CSS -- **Validation Engine**: Mustang Project CLI (Java-based) -- **Runtime**: .NET 10 + OpenJDK - -## Quick Start - -### Using Docker (Recommended) - -The easiest way to run the validator is using Docker: - -```bash -docker pull docentric/e-invoice-validator:latest -docker run -p 18889:8080 docentric/e-invoice-validator:latest -``` - -Then open your browser to `http://localhost:18889` - -## Installation & Usage - -### Prerequisites - -For local development, you need: -- .NET 10 SDK -- Node.js 20.x or later -- Java Runtime Environment (JRE) 11 or later -- Docker (for containerized deployment) -- Visual Studio 2026 or JetBrains Rider (recommended IDEs) -- Git - -### Building from Source - -1. **Clone the repository**: - ```bash - git clone https://github.com/Docentric/docentric-e-invoice-validator.git - cd docentric-e-invoice-validator - ``` - -2. **Restore dependencies**: - ```bash - dotnet restore src/Docentric.EInvoice.Validator.slnx - cd src/WebUI - npm install - cd ../.. - ``` - -3. **Build the solution**: - ```bash - dotnet build src/Docentric.EInvoice.Validator.slnx --configuration Release - ``` - -4. **Run the application**: - ```bash - dotnet run --project src/Server/RestServer.csproj - ``` - -The application will be available at `https://localhost:57456` or `http://localhost:53365`. - -**Note**: When running locally (not via Docker), ensure you have Java Runtime Environment (JRE) 11 or later installed, as it's required for the Mustang Project validation engine. - -### Building Docker Image Locally - -To build the Docker image yourself: - -```powershell -# Windows -cd src -.\docker-build.ps1 -``` - -Then run it: - -```powershell -# Windows -.\docker-run.ps1 -``` - -The application will be available at `http://localhost:18889`. - -## API Usage - -For detailed API documentation and interactive testing, visit the Swagger UI at: - -``` -http://localhost:18889/api/docs/swagger/index.html -``` - -The API provides endpoints for validating ZuGFeRD and Factur-X e-invoices in both PDF and XML formats. - -## Architecture - -The solution consists of two main components: - -### Backend (`src/Server`) -ASP.NET Core REST API that: -- Accepts file uploads (PDF or XML) -- Invokes Mustang Project CLI for validation -- Parses validation results -- Returns structured JSON responses - -### Frontend (`src/WebUI`) -React SPA that: -- Provides a user-friendly upload interface -- Displays validation results -- Calls the REST API -- Built into static assets served from `Server/wwwroot` in production - -### Docker Image -The Docker image bundles: -- .NET 10 runtime -- OpenJDK (for Mustang CLI) -- Mustang-CLI.jar in `/opt/Mustang-CLI.jar` -- Published ASP.NET Core application -- Built React SPA - -## Project Structure - -```text -docentric-e-invoice-validator/ -├── .github/ -│ └── workflows/ -│ └── build.yml # GitHub Actions CI/CD pipeline -├── samples/ # Sample e-invoice files for testing -│ ├── ZuGFeRD e-Invoice.pdf -│ ├── factur-x e-invoice.pdf -│ └── *.xml -├── scripts/ # Development scripts -├── src/ -│ ├── Directory.Build.props # Shared project properties -│ ├── Docentric.EInvoice.Validator.slnx -│ ├── Dockerfile # Multi-stage Docker build -│ ├── docker-compose.yml -│ ├── Server/ # ASP.NET Core REST API -│ │ ├── RestServer.csproj -│ │ ├── Program.cs -│ │ ├── ApiHandlers.cs -│ │ └── Models/ -│ └── WebUI/ # React + Vite frontend -│ ├── package.json -│ ├── vite.config.ts -│ ├── src/ -│ └── ... -├── LICENSE -└── README.md -``` - -## Docker Hub - -Pre-built Docker images are automatically published to Docker Hub: - -**Repository**: [`docentric/e-invoice-validator`](https://hub.docker.com/r/docentric/e-invoice-validator) - -**Available Tags**: -- `latest` - Latest stable release from the main branch -- `20XX.Y.Z` - Specific version tags -- `main` - Latest build from main branch -- `develop` - Latest development build - -**Pull Image**: -```bash -docker pull docentric/e-invoice-validator:latest -docker pull docentric/e-invoice-validator:latest -``` - -## Development - -### Running in Development Mode - -1. **Start the backend**: - ```bash - cd src/Server - dotnet run - ``` - -The frontend dev server runs on `http://localhost:53365` with hot reload enabled. - -### Running Tests - -```bash -dotnet test src/Docentric.EInvoice.Validator.slnx -``` - -## Contributing - -Contributions are welcome! Please feel free to submit issues and pull requests. - -## License - -This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. - -``` -MIT License -Copyright (c) 2026 Docentric d.o.o. -``` - -## Acknowledgments - -This project builds upon the excellent work of several open-source projects: - -- **[Mustang Project](https://www.mustangproject.org/)** - The ZuGFeRD and Factur-X validation engine that powers this tool -- **[ASP.NET Core](https://github.com/dotnet/aspnetcore)** - Microsoft's cross-platform web framework -- **[React](https://react.dev/)** - Facebook's UI library -- **[Vite](https://vitejs.dev/)** - Next-generation frontend build tool -- **[shadcn/ui](https://ui.shadcn.com/)** - Beautifully designed UI components -- **[Tailwind CSS](https://tailwindcss.com/)** - Utility-first CSS framework - -Special thanks to the Mustang Project team for creating and maintaining the comprehensive ZuGFeRD/Factur-X validation library. - -## About Docentric - -[Docentric](https://ax.docentric.com/about-us/) contributes to the Dynamics 365 and Power Platform community through free apps, knowledge platforms, and by organizing events that bring professionals together on both local and international levels – proving ourselves as community-first and community-driven Microsoft ISV Partner. - -## Questions & Feedback - -Reach out to us at if you have any questions or feedback. +# Docentric e-Invoice Validator + +[![Build Status](https://github.com/Docentric/docentric-e-invoice-validator/actions/workflows/build.yml/badge.svg)](https://github.com/Docentric/docentric-e-invoice-validator/actions/workflows/build.yml) +[![Coverage (CI)](https://raw.githubusercontent.com/Docentric/docentric-e-invoice-validator/badges/ci-coverage.svg)](https://github.com/Docentric/docentric-e-invoice-validator/actions/workflows/build.yml) +[![GitHub release](https://img.shields.io/github/v/release/Docentric/docentric-e-invoice-validator)](https://github.com/Docentric/docentric-e-invoice-validator/releases/latest) +[![Docker Hub](https://img.shields.io/docker/v/docentric/e-invoice-validator?label=Docker%20Hub)](https://hub.docker.com/r/docentric/e-invoice-validator) +[![.NET](https://img.shields.io/badge/.NET-10-512BD4?logo=dotnet)](https://dotnet.microsoft.com/) +[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) + +A REST API and web-based validation tool for **ZuGFeRD** and **Factur-X** **e-invoices**, built with ASP.NET Core and React. + +## Overview + +This project provides a simple, user-friendly interface for validating ZuGFeRD and Factur-X **e-invoices** (both PDF and XML formats). It wraps the [Mustang Project](https://www.mustangproject.org/) validation engine in a modern web application with both API and UI access. + +**Purpose**: This tool is primarily used for unit testing and quality assurance in the development of [Docentric AX](https://ax.docentric.com/), our document generation and distribution solution. It helps ensure that e-invoices generated by Docentric AX comply with ZuGFeRD and Factur-X standards. + +## Features + +- **Web UI**: Modern React-based interface for uploading and validating invoices +- **REST API**: Programmatic access for automated testing and integration +- **Multiple Formats**: Supports PDF (hybrid) and XML (Factur-X, UBL) e-invoice formats +- **Extraction and Conversion**: Extracts embedded XML from PDF and converts XML to PDF +- **Standards Compliance**: Validates ZuGFeRD, Factur-X and UBL invoices using Mustang Project CLI 2.26.0 +- **Docker Ready**: Pre-built Docker images available on Docker Hub +- **CI/CD**: Automated builds and testing with GitHub Actions + +## Technology Stack + +- **Backend**: ASP.NET Core 10 (minimal API) +- **Frontend**: React + Vite + TypeScript +- **UI Components**: shadcn/ui with Tailwind CSS +- **Validation Engine**: Mustang Project CLI 2.26.0 (Java-based) +- **Runtime**: .NET 10 + OpenJDK 17 + +## Quick Start + +### Using Docker (Recommended) + +The easiest way to run the validator is using Docker: + +```bash +docker pull docentric/e-invoice-validator:latest +docker run -p 18889:8080 docentric/e-invoice-validator:latest +``` + +Then open your browser to `http://localhost:18889` + +## Installation & Usage + +### Prerequisites + +For local development, you need: +- .NET 10 SDK +- Node.js 20.x or later +- Java Runtime Environment (JRE) 11 or later +- Docker (for containerized deployment) +- Visual Studio 2026 or JetBrains Rider (recommended IDEs) +- Git + +### Building from Source + +1. **Clone the repository**: + ```bash + git clone https://github.com/Docentric/docentric-e-invoice-validator.git + cd docentric-e-invoice-validator + ``` + +2. **Restore dependencies**: + ```bash + dotnet restore src/Docentric.EInvoice.Validator.slnx + cd src/WebUI + npm install + cd ../.. + ``` + +3. **Build the solution**: + ```bash + dotnet build src/Docentric.EInvoice.Validator.slnx --configuration Release + ``` + +4. **Run the application**: + ```bash + dotnet run --project src/Server/RestServer.csproj + ``` + +The application will be available at `https://localhost:57456` or `http://localhost:57457`. + +**Note**: When running locally (not via Docker), ensure you have Java Runtime Environment (JRE) 11 or later installed, as it's required for the Mustang Project validation engine. + +### Building Docker Image Locally + +To build the Docker image yourself: + +```powershell +# Windows +cd src +.\docker-build.ps1 +``` + +Then run it: + +```powershell +# Windows +.\docker-run.ps1 +``` + +The application will be available at `http://localhost:18889`. + +## API Usage + +For detailed API documentation and interactive testing, visit the Swagger UI at: + +``` +http://localhost:18889/api/docs/swagger/index.html +``` + +The API provides the following endpoints (all `POST` endpoints accept `multipart/form-data` with a single file, up to 100 MB, and time out after 300 seconds): + +| Endpoint | Description | +|----------|-------------| +| `POST /api/pdf/validate-zugferd` | Validates a ZuGFeRD / Factur-X PDF | +| `POST /api/pdf/extract-zugferd-xml` | Extracts the embedded XML from a ZuGFeRD / Factur-X PDF | +| `POST /api/xml/validate` | Validates a Factur-X or UBL XML file | +| `POST /api/xml/convert-to-pdf` | Converts a Factur-X or UBL XML file to PDF. Optional query parameter `language` (`en`, `fr` or `de`, default `en`) | +| `GET /api/health` | Reports Java and Mustang CLI availability and versions | + +Error responses carry an `ErrorCode`; see [ErrorCode.Examples.md](src/Server/Contracts/ErrorCode.Examples.md). ReDoc is also available at `/api/docs/redoc`. + +## Architecture + +The solution consists of two main components: + +### Backend (`src/Server`) +ASP.NET Core REST API that: +- Accepts file uploads (PDF or XML) +- Invokes Mustang Project CLI for validation +- Parses validation results +- Returns structured JSON responses + +### Frontend (`src/WebUI`) +React SPA that: +- Provides a user-friendly upload interface +- Displays validation results +- Calls the REST API +- Built into static assets served from `Server/wwwroot` in production + +### Docker Image +The Docker image bundles: +- .NET 10 runtime +- OpenJDK 17 headless (for Mustang CLI) +- `Mustang-CLI-2.26.0.jar`, published next to the application in `/app` +- Published ASP.NET Core application +- Built React SPA + +## Project Structure + +```text +docentric-e-invoice-validator/ +├── .github/ +│ ├── copilot-instructions.md +│ ├── prompts/ # Reusable Copilot Chat prompts +│ └── workflows/ +│ └── build.yml # GitHub Actions CI/CD pipeline +├── samples/ # Sample e-invoice files for testing +│ ├── ZuGFeRD e-Invoice.pdf +│ ├── factur-x e-invoice.pdf +│ └── *.xml +├── scripts/ # Development scripts +├── src/ +│ ├── Directory.Build.props # Shared project properties +│ ├── Docentric.EInvoice.Validator.slnx +│ ├── Dockerfile # Multi-stage Docker build +│ ├── docker-compose.yml +│ ├── Server/ # ASP.NET Core REST API +│ │ ├── RestServer.csproj +│ │ ├── Program.cs +│ │ ├── Mustang-CLI-2.26.0.jar +│ │ ├── Configuration/ +│ │ ├── Contracts/ +│ │ ├── Endpoints/ +│ │ ├── IO/ +│ │ └── Services/ +│ ├── Server.Tests/ # xUnit v3 tests +│ └── WebUI/ # React + Vite frontend +│ ├── package.json +│ ├── vite.config.ts +│ ├── src/ +│ └── ... +├── LICENSE +└── README.md +``` + +## Docker Hub + +Pre-built Docker images are automatically published to Docker Hub: + +**Repository**: [`docentric/e-invoice-validator`](https://hub.docker.com/r/docentric/e-invoice-validator) + +**Available Tags**: +- `latest` - Latest stable release from the main branch +- `20XX.Y.Z` - Specific version tags +- `main` - Latest build from main branch +- `develop` - Latest development build + +**Pull Image**: +```bash +docker pull docentric/e-invoice-validator:latest +docker pull docentric/e-invoice-validator:latest +``` + +## Development + +### Running in Development Mode + +1. **Start the backend**: + ```bash + cd src/Server + dotnet run + ``` + + The backend listens on `https://localhost:57456` and `http://localhost:57457`. + +2. **Start the frontend** (optional, hot reload): + ```bash + cd src/WebUI + npm run dev + ``` + + The frontend dev server runs on `http://localhost:53365` with hot reload enabled. + +### Running Tests + +```bash +dotnet test src/Docentric.EInvoice.Validator.slnx +``` + +## Contributing + +Contributions are welcome! Please feel free to submit issues and pull requests. + +## License + +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. + +``` +MIT License +Copyright (c) 2026 Docentric d.o.o. +``` + +## Acknowledgments + +This project builds upon the excellent work of several open-source projects: + +- **[Mustang Project](https://www.mustangproject.org/)** - The ZuGFeRD and Factur-X validation engine that powers this tool +- **[ASP.NET Core](https://github.com/dotnet/aspnetcore)** - Microsoft's cross-platform web framework +- **[React](https://react.dev/)** - Facebook's UI library +- **[Vite](https://vitejs.dev/)** - Next-generation frontend build tool +- **[shadcn/ui](https://ui.shadcn.com/)** - Beautifully designed UI components +- **[Tailwind CSS](https://tailwindcss.com/)** - Utility-first CSS framework + +Special thanks to the Mustang Project team for creating and maintaining the comprehensive ZuGFeRD/Factur-X validation library. + +## About Docentric + +[Docentric](https://ax.docentric.com/about-us/) contributes to the Dynamics 365 and Power Platform community through free apps, knowledge platforms, and by organizing events that bring professionals together on both local and international levels – proving ourselves as community-first and community-driven Microsoft ISV Partner. + +## Questions & Feedback + +Reach out to us at if you have any questions or feedback. diff --git a/samples/factur-x e-invoice.xml b/samples/factur-x e-invoice.xml index 48bbf19..fafb4c2 100644 --- a/samples/factur-x e-invoice.xml +++ b/samples/factur-x e-invoice.xml @@ -1,155 +1,155 @@ - - - - - urn:cen.eu:en16931:2017#conformant#urn:factur-x.eu:1p0:extended - - - - CIV-000168 - 380 - - 20250918 - - - This text is from the Sales Invoice form notes - ABL - - - - - - 1 - - - D0010 - Active speaker - - - - 000183 - - - 1000 - 1 - - - false - - 10 - - - - 990 - 1.0 - - - - 5 - - - - VAT - S - 19 - - - 4950 - - - - - - 181728298 - Contoso Entertainment System Germany - - Julia Funderburk - - 425-555-5053 - - - julia@contoso.com - - - - 10115 - Bahnhofstraße 5 - Berlin - DE - - - 203/118/12345 - - - DE100200300 - - - - DE-010 - A.Datum Corporation - - 24103 - Lilienweg 9 - Kiel - DE - - - - 000183 - - - - - A.Datum Corporation - - 24103 - Lilienweg 9 - Kiel - DE - - - - - 20250918 - - - - - 001359 - EUR - - 58 - - DE89370400440532013000 - Operating account - EUR - - - DEUTDEFF - - - - 940.5 - VAT - 4950 - S - 19 - - - Net 30 days - - 20251018 - - - - 4950 - 0 - 0 - 4950 - 940.5 - 0 - 5890.5 - 5890.5 - - - + + + + + urn:cen.eu:en16931:2017#conformant#urn:factur-x.eu:1p0:extended + + + + CIV-000168 + 380 + + 20250918 + + + This text is from the Sales Invoice form notes + ABL + + + + + + 1 + + + D0010 + Active speaker + + + + 000183 + + + 1000 + 1 + + + false + + 10 + + + + 990 + 1.0 + + + + 5 + + + + VAT + S + 19 + + + 4950 + + + + + + 181728298 + Contoso Entertainment System Germany + + Julia Funderburk + + 425-555-5053 + + + julia@contoso.com + + + + 10115 + Bahnhofstraße 5 + Berlin + DE + + + 203/118/12345 + + + DE100200300 + + + + DE-010 + A.Datum Corporation + + 24103 + Lilienweg 9 + Kiel + DE + + + + 000183 + + + + + A.Datum Corporation + + 24103 + Lilienweg 9 + Kiel + DE + + + + + 20250918 + + + + + 001359 + EUR + + 58 + + DE89370400440532013000 + Operating account - EUR + + + DEUTDEFF + + + + 940.5 + VAT + 4950 + S + 19 + + + Net 30 days + + 20251018 + + + + 4950 + 0 + 0 + 4950 + 940.5 + 0 + 5890.5 + 5890.5 + + + \ No newline at end of file diff --git a/src/.dockerignore b/src/.dockerignore index 930c628..b6767ad 100644 --- a/src/.dockerignore +++ b/src/.dockerignore @@ -1,49 +1,49 @@ -# Build outputs -**/bin/ -**/obj/ -**/out/ -**/build/ -**/wwwroot/ - -# Node modules and build artifacts -**/node_modules/ -**/dist/ -**/.vite/ -**/.stamp - -# IDE and OS files -.vs/ -.vscode/ -.idea/ -*.user -*.suo -*.userosscache -*.sln.docstates -.DS_Store -Thumbs.db - -# Git -.git/ -.gitignore -.gitattributes - -# Docker -Dockerfile -.dockerignore -docker-compose*.yml - -# Documentation -*.md -LICENSE - -# Test files -**/*Tests/ -**/*Test/ - -# Logs -**/logs/ -*.log - -# Temporary files -**/tmp/ -**/temp/ +# Build outputs +**/bin/ +**/obj/ +**/out/ +**/build/ +**/wwwroot/ + +# Node modules and build artifacts +**/node_modules/ +**/dist/ +**/.vite/ +**/.stamp + +# IDE and OS files +.vs/ +.vscode/ +.idea/ +*.user +*.suo +*.userosscache +*.sln.docstates +.DS_Store +Thumbs.db + +# Git +.git/ +.gitignore +.gitattributes + +# Docker +Dockerfile +.dockerignore +docker-compose*.yml + +# Documentation +*.md +LICENSE + +# Test files +**/*Tests/ +**/*Test/ + +# Logs +**/logs/ +*.log + +# Temporary files +**/tmp/ +**/temp/ diff --git a/src/.env b/src/.env index 3aeebcb..80228b5 100644 --- a/src/.env +++ b/src/.env @@ -1,3 +1,3 @@ -VERSION=2025.1.0 -BUILD_DATE=2025-11-26T06:00:00Z -VCS_REF=local-dev +VERSION=2025.1.0 +BUILD_DATE=2025-11-26T06:00:00Z +VCS_REF=local-dev diff --git a/src/Dockerfile b/src/Dockerfile index ef88ddd..275f12f 100644 --- a/src/Dockerfile +++ b/src/Dockerfile @@ -1,115 +1,115 @@ -# Stage 1: Build the React SPA -FROM --platform=$BUILDPLATFORM node:20-alpine AS spa-build -WORKDIR /src/WebUI - -# Update Alpine packages to latest security patches -RUN apk update && \ - apk upgrade --no-cache && \ - rm -rf /var/cache/apk/* - -# Copy package files and install dependencies -COPY WebUI/package*.json ./ -RUN npm ci && \ - npm cache clean --force - -# Copy the rest of the SPA source code -COPY WebUI/ ./ - -# Build the SPA for production -RUN npm run build - -# Stage 2: Build the .NET application - use latest Noble variant - Better security -# Build on the target platform so publish output matches the runtime image architecture -FROM mcr.microsoft.com/dotnet/sdk:10.0-noble AS dotnet-build -WORKDIR /src - -# Copy Directory.Build.props file -COPY Directory.Build.props Server/ - -# Copy project file and restore dependencies -COPY Server/RestServer.csproj Server/ - -# Pass IsDockerBuild=true so the .esproj reference is skipped -RUN dotnet restore Server/RestServer.csproj \ - /p:IsDockerBuild=true - -# Copy the rest of the application source code -COPY Server/ Server/ - -# Copy the built SPA from the previous stage to wwwroot -COPY --from=spa-build /src/WebUI/dist /src/Server/wwwroot - -# Build and publish the application (skip SPA build targets, mark Docker build) -WORKDIR /src/Server -RUN dotnet publish RestServer.csproj \ - -c Release \ - -o /app/publish \ - --no-restore \ - /p:UseAppHost=false \ - /p:RunSpaInDebug=false \ - /p:BuildProjectReferences=false \ - /p:IsDockerBuild=true - -# Stage 3: Runtime image with Java and .NET -FROM mcr.microsoft.com/dotnet/aspnet:10.0-noble AS runtime - -# First install security updates, then OpenJDK 17 (required for Mustang CLI) and curl (for health check) -RUN apt-get update && \ - apt-get upgrade -y && \ - apt-get install -y --no-install-recommends \ - openjdk-17-jre-headless \ - curl \ - ca-certificates && \ - apt-get autoremove -y && \ - apt-get clean && \ - rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* - -# OCI Image labels (standardized metadata) -ARG VERSION="25.1.0" -ARG BUILD_DATE="$(date -u +'%Y-%m-%dT%H:%M:%SZ')" -ARG VCS_REF="$(git rev-parse --short HEAD)" - -LABEL org.opencontainers.image.title="Docentric e-Invoice Validator" \ - org.opencontainers.image.description=".NET 10 REST API wrapper for Mustang-Project CLI to validate e-invoices (ZuGFeRD, Factur-X and UBL)." \ - org.opencontainers.image.version="${VERSION}" \ - org.opencontainers.image.created="${BUILD_DATE}" \ - org.opencontainers.image.vendor="Docentric" \ - org.opencontainers.image.url="https://hub.docker.com/r/docentric/e-invoice-validator" \ - org.opencontainers.image.source="https://github.com/docentric/docentric-e-invoice-validator" \ - org.opencontainers.image.licenses="MIT" \ - maintainer="klemen.novak@docentric.com" - -# Create non-root user -RUN groupadd -r zugferdappuser --gid=1001 && \ - useradd -r -g zugferdappuser --uid=1001 --home-dir=/app --shell=/sbin/nologin zugferdappuser - -# Create app directory -WORKDIR /app - -# Copy published application from build stage -COPY --from=dotnet-build --chown=zugferdappuser:zugferdappuser /app/publish . - -# Ensure wwwroot is copied (should already be there from publish) -# COPY --from=spa-build /src/WebUI/dist ./wwwroot - -# Create logs directory with appropriate permissions -RUN mkdir -p /app/logs && \ - chown zugferdappuser:zugferdappuser /app/logs && \ - chmod 750 /app/logs - -# Set environment variables -ENV ASPNETCORE_ENVIRONMENT=Docker -ENV ASPNETCORE_URLS=http://+:8080 -ENV ASPNETCORE_HTTP_PORTS=8080 -# Switch to non-root user -USER zugferdappuser - -# Expose HTTP -EXPOSE 8080 - -# Health check -HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ - CMD curl --fail --silent --max-time 3 http://localhost:8080/api/health || exit 1 - -# Run the application -ENTRYPOINT ["dotnet", "Docentric.EInvoice.Validator.RestServer.dll"] +# Stage 1: Build the React SPA +FROM --platform=$BUILDPLATFORM node:20-alpine AS spa-build +WORKDIR /src/WebUI + +# Update Alpine packages to latest security patches +RUN apk update && \ + apk upgrade --no-cache && \ + rm -rf /var/cache/apk/* + +# Copy package files and install dependencies +COPY WebUI/package*.json ./ +RUN npm ci && \ + npm cache clean --force + +# Copy the rest of the SPA source code +COPY WebUI/ ./ + +# Build the SPA for production +RUN npm run build + +# Stage 2: Build the .NET application - use latest Noble variant - Better security +# Build on the target platform so publish output matches the runtime image architecture +FROM mcr.microsoft.com/dotnet/sdk:10.0-noble AS dotnet-build +WORKDIR /src + +# Copy Directory.Build.props file +COPY Directory.Build.props Server/ + +# Copy project file and restore dependencies +COPY Server/RestServer.csproj Server/ + +# Pass IsDockerBuild=true so the .esproj reference is skipped +RUN dotnet restore Server/RestServer.csproj \ + /p:IsDockerBuild=true + +# Copy the rest of the application source code +COPY Server/ Server/ + +# Copy the built SPA from the previous stage to wwwroot +COPY --from=spa-build /src/WebUI/dist /src/Server/wwwroot + +# Build and publish the application (skip SPA build targets, mark Docker build) +WORKDIR /src/Server +RUN dotnet publish RestServer.csproj \ + -c Release \ + -o /app/publish \ + --no-restore \ + /p:UseAppHost=false \ + /p:RunSpaInDebug=false \ + /p:BuildProjectReferences=false \ + /p:IsDockerBuild=true + +# Stage 3: Runtime image with Java and .NET +FROM mcr.microsoft.com/dotnet/aspnet:10.0-noble AS runtime + +# First install security updates, then OpenJDK 17 (required for Mustang CLI) and curl (for health check) +RUN apt-get update && \ + apt-get upgrade -y && \ + apt-get install -y --no-install-recommends \ + openjdk-17-jre-headless \ + curl \ + ca-certificates && \ + apt-get autoremove -y && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* + +# OCI Image labels (standardized metadata) +ARG VERSION="25.1.0" +ARG BUILD_DATE="$(date -u +'%Y-%m-%dT%H:%M:%SZ')" +ARG VCS_REF="$(git rev-parse --short HEAD)" + +LABEL org.opencontainers.image.title="Docentric e-Invoice Validator" \ + org.opencontainers.image.description=".NET 10 REST API wrapper for Mustang-Project CLI to validate e-invoices (ZuGFeRD, Factur-X and UBL)." \ + org.opencontainers.image.version="${VERSION}" \ + org.opencontainers.image.created="${BUILD_DATE}" \ + org.opencontainers.image.vendor="Docentric" \ + org.opencontainers.image.url="https://hub.docker.com/r/docentric/e-invoice-validator" \ + org.opencontainers.image.source="https://github.com/docentric/docentric-e-invoice-validator" \ + org.opencontainers.image.licenses="MIT" \ + maintainer="klemen.novak@docentric.com" + +# Create non-root user +RUN groupadd -r zugferdappuser --gid=1001 && \ + useradd -r -g zugferdappuser --uid=1001 --home-dir=/app --shell=/sbin/nologin zugferdappuser + +# Create app directory +WORKDIR /app + +# Copy published application from build stage +COPY --from=dotnet-build --chown=zugferdappuser:zugferdappuser /app/publish . + +# Ensure wwwroot is copied (should already be there from publish) +# COPY --from=spa-build /src/WebUI/dist ./wwwroot + +# Create logs directory with appropriate permissions +RUN mkdir -p /app/logs && \ + chown zugferdappuser:zugferdappuser /app/logs && \ + chmod 750 /app/logs + +# Set environment variables +ENV ASPNETCORE_ENVIRONMENT=Docker +ENV ASPNETCORE_URLS=http://+:8080 +ENV ASPNETCORE_HTTP_PORTS=8080 +# Switch to non-root user +USER zugferdappuser + +# Expose HTTP +EXPOSE 8080 + +# Health check +HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ + CMD curl --fail --silent --max-time 3 http://localhost:8080/api/health || exit 1 + +# Run the application +ENTRYPOINT ["dotnet", "Docentric.EInvoice.Validator.RestServer.dll"] diff --git a/src/IgnoredWords.dic b/src/IgnoredWords.dic new file mode 100644 index 0000000..b1790c8 --- /dev/null +++ b/src/IgnoredWords.dic @@ -0,0 +1,3 @@ +Cli +Mustang +Uri diff --git a/src/Server.Tests/Endpoints/EndpointRouteBuilderExtensionsTests.cs b/src/Server.Tests/Endpoints/EndpointRouteBuilderExtensionsTests.cs new file mode 100644 index 0000000..961ed8b --- /dev/null +++ b/src/Server.Tests/Endpoints/EndpointRouteBuilderExtensionsTests.cs @@ -0,0 +1,33 @@ +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.Routing.Patterns; +using Microsoft.Extensions.DependencyInjection; + +namespace Docentric.EInvoice.Validator.RestServer.Tests.Endpoints; + +/// +/// Verifies that the API endpoints are registered by the application routing configuration. +/// +/// The in-memory server factory shared by the tests in this class. +public class EndpointRouteBuilderExtensionsTests(RestServerFactory factory) : IClassFixture +{ + /// + /// Verifies that the given API route is registered when the application starts. + /// + /// The route pattern expected to be registered. + [Theory] + [InlineData("/api/health")] + [InlineData("/api/pdf/validate-zugferd")] + [InlineData("/api/pdf/extract-zugferd-xml")] + [InlineData("/api/xml/validate")] + [InlineData("/api/xml/convert-to-pdf")] + public void WhenApplicationStartsThenApiRouteIsRegistered(string route) + { + EndpointDataSource dataSource = factory.Services.GetRequiredService(); + + IEnumerable patterns = dataSource.Endpoints + .OfType() + .Select(e => "/" + e.RoutePattern.RawText?.TrimStart('/')); + + Assert.Contains(route, patterns); + } +} diff --git a/src/Server.Tests/Endpoints/HealthEndpointsTests.cs b/src/Server.Tests/Endpoints/HealthEndpointsTests.cs new file mode 100644 index 0000000..f7a0bce --- /dev/null +++ b/src/Server.Tests/Endpoints/HealthEndpointsTests.cs @@ -0,0 +1,56 @@ +using System.Net; +using System.Net.Http.Json; + +using Docentric.EInvoice.Validator.RestServer.Contracts; +using Docentric.EInvoice.Validator.RestServer.Tests.Services; + +namespace Docentric.EInvoice.Validator.RestServer.Tests.Endpoints; + +/// +/// Tests the health endpoint of the REST server. +/// +/// The in-memory server factory shared by the tests in this class. +public class HealthEndpointsTests(RestServerFactory factory) : IClassFixture +{ + /// + /// Verifies that the health endpoint responds with 200 OK when Java is installed. + /// + [Fact] + public async Task WhenJavaIsInstalledThenHealthReturnsOk() + { + Assert.SkipUnless(JavaTestHelper.IsJavaInstalled, JavaTestHelper.SkipReason); + using HttpClient client = factory.CreateApiClient(); + + using HttpResponseMessage response = await client.GetAsync("/api/health", TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + /// + /// Verifies that the health endpoint reports Java as present when Java is installed. + /// + [Fact] + public async Task WhenJavaIsInstalledThenHealthReportsJavaPresent() + { + Assert.SkipUnless(JavaTestHelper.IsJavaInstalled, JavaTestHelper.SkipReason); + using HttpClient client = factory.CreateApiClient(); + + ServerHealthResponse? health = await client.GetFromJsonAsync("/api/health", TestContext.Current.CancellationToken); + + Assert.True(health?.JavaPresent); + } + + /// + /// Verifies that the health endpoint reports Mustang CLI as present when Java is installed. + /// + [Fact] + public async Task WhenJavaIsInstalledThenHealthReportsMustangCliPresent() + { + Assert.SkipUnless(JavaTestHelper.IsJavaInstalled, JavaTestHelper.SkipReason); + using HttpClient client = factory.CreateApiClient(); + + ServerHealthResponse? health = await client.GetFromJsonAsync("/api/health", TestContext.Current.CancellationToken); + + Assert.True(health?.MustangCliPresent); + } +} diff --git a/src/Server.Tests/Endpoints/MultipartContentFactory.cs b/src/Server.Tests/Endpoints/MultipartContentFactory.cs new file mode 100644 index 0000000..e4c8012 --- /dev/null +++ b/src/Server.Tests/Endpoints/MultipartContentFactory.cs @@ -0,0 +1,26 @@ +using System.Text; + +namespace Docentric.EInvoice.Validator.RestServer.Tests.Endpoints; + +internal static class MultipartContentFactory +{ + /// + /// Builds a multipart/form-data body with a single "file" part. + /// + public static MultipartFormDataContent CreateFile(string content, string fileName) + { + MultipartFormDataContent form = new(); + form.Add(new ByteArrayContent(Encoding.UTF8.GetBytes(content)), "file", fileName); + return form; + } + + /// + /// Builds a multipart/form-data body with a single "file" part containing the bytes of an existing file. + /// + public static MultipartFormDataContent CreateFromPath(string path) + { + MultipartFormDataContent form = new(); + form.Add(new ByteArrayContent(File.ReadAllBytes(path)), "file", Path.GetFileName(path)); + return form; + } +} diff --git a/src/Server.Tests/Endpoints/OpenApiDocumentTests.cs b/src/Server.Tests/Endpoints/OpenApiDocumentTests.cs new file mode 100644 index 0000000..20bfc0f --- /dev/null +++ b/src/Server.Tests/Endpoints/OpenApiDocumentTests.cs @@ -0,0 +1,106 @@ +using System.Text.Json; + +namespace Docentric.EInvoice.Validator.RestServer.Tests.Endpoints; + +/// +/// Tests that the generated OpenAPI document describes every endpoint and every parameter of the REST server. +/// +/// The in-memory server factory shared by the tests in this class. +public class OpenApiDocumentTests(RestServerFactory factory) : IClassFixture +{ + private static readonly string[] ExpectedOperations = + [ + "GET /api/health", + "POST /api/pdf/extract-zugferd-xml", + "POST /api/pdf/validate-zugferd", + "POST /api/xml/convert-to-pdf", + "POST /api/xml/validate", + ]; + + /// + /// Verifies that the OpenAPI document contains exactly the expected endpoints and HTTP methods. + /// + [Fact] + public async Task WhenOpenApiDocumentIsRequestedThenItContainsAllEndpoints() + { + using JsonDocument document = await GetOpenApiDocumentAsync(); + + string[] operations = [.. GetOperations(document).Select(o => o.Name).Order(StringComparer.Ordinal)]; + + Assert.Equal(ExpectedOperations, operations); + } + + /// + /// Verifies that every operation has a non-empty summary. + /// + [Fact] + public async Task WhenOpenApiDocumentIsRequestedThenEveryOperationHasSummary() + { + using JsonDocument document = await GetOpenApiDocumentAsync(); + + string[] withoutSummary = [.. GetOperations(document).Where(o => string.IsNullOrWhiteSpace(GetString(o.Element, "summary"))).Select(o => o.Name)]; + + Assert.Empty(withoutSummary); + } + + /// + /// Verifies that every operation has a non-empty description. + /// + [Fact] + public async Task WhenOpenApiDocumentIsRequestedThenEveryOperationHasDescription() + { + using JsonDocument document = await GetOpenApiDocumentAsync(); + + string[] withoutDescription = [.. GetOperations(document).Where(o => string.IsNullOrWhiteSpace(GetString(o.Element, "description"))).Select(o => o.Name)]; + + Assert.Empty(withoutDescription); + } + + /// + /// Verifies that every operation parameter has a non-empty description. + /// + [Fact] + public async Task WhenOpenApiDocumentIsRequestedThenEveryParameterHasDescription() + { + using JsonDocument document = await GetOpenApiDocumentAsync(); + + string[] withoutDescription = + [ + .. GetOperations(document) + .SelectMany(o => GetParameters(o.Element).Select(p => (o.Name, Parameter: p))) + .Where(x => string.IsNullOrWhiteSpace(GetString(x.Parameter, "description"))) + .Select(x => $"{x.Name} {GetString(x.Parameter, "name")}") + ]; + + Assert.Empty(withoutDescription); + } + + /// + /// Verifies that the convert-to-pdf operation documents the optional language query parameter. + /// + [Fact] + public async Task WhenOpenApiDocumentIsRequestedThenConvertToPdfDocumentsLanguageQueryParameter() + { + using JsonDocument document = await GetOpenApiDocumentAsync(); + JsonElement convertToPdf = GetOperations(document).Single(o => o.Name == "POST /api/xml/convert-to-pdf").Element; + + Assert.Contains(GetParameters(convertToPdf), p => GetString(p, "name") == "language" && GetString(p, "in") == "query"); + } + + private async Task GetOpenApiDocumentAsync() + { + using HttpClient client = factory.CreateApiClient(); + return JsonDocument.Parse(await client.GetStringAsync("/openapi/v1.json", TestContext.Current.CancellationToken)); + } + + private static IEnumerable<(string Name, JsonElement Element)> GetOperations(JsonDocument document) + => document.RootElement.GetProperty("paths").EnumerateObject() + .SelectMany(path => path.Value.EnumerateObject() + .Select(method => ($"{method.Name.ToUpperInvariant()} {path.Name}", method.Value))); + + private static IEnumerable GetParameters(JsonElement operation) + => operation.TryGetProperty("parameters", out JsonElement parameters) ? parameters.EnumerateArray() : []; + + private static string? GetString(JsonElement element, string propertyName) + => element.TryGetProperty(propertyName, out JsonElement value) ? value.GetString() : null; +} diff --git a/src/Server.Tests/Endpoints/PdfEndpointsTests.cs b/src/Server.Tests/Endpoints/PdfEndpointsTests.cs new file mode 100644 index 0000000..0084d53 --- /dev/null +++ b/src/Server.Tests/Endpoints/PdfEndpointsTests.cs @@ -0,0 +1,125 @@ +using System.Net; +using System.Net.Http.Json; + +using Docentric.EInvoice.Validator.RestServer.Contracts; +using Docentric.EInvoice.Validator.RestServer.Tests.Services; + +namespace Docentric.EInvoice.Validator.RestServer.Tests.Endpoints; + +/// +/// Tests the PDF endpoints of the REST server using requests that are rejected before Mustang CLI is invoked. +/// +/// The in-memory server factory shared by the tests in this class. +public class PdfEndpointsTests(RestServerFactory factory) : IClassFixture +{ + /// + /// Verifies that uploading a non-PDF file to a PDF endpoint returns 400 Bad Request. + /// + /// The PDF endpoint URL under test. + [Theory] + [InlineData("/api/pdf/validate-zugferd")] + [InlineData("/api/pdf/extract-zugferd-xml")] + public async Task WhenUploadedFileIsNotPdfThenReturnsBadRequest(string url) + { + using HttpClient client = factory.CreateApiClient(); + using MultipartFormDataContent form = MultipartContentFactory.CreateFile("plain text", "invoice.pdf"); + + using HttpResponseMessage response = await client.PostAsync(url, form, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + /// + /// Verifies that validating a non-PDF file returns the InvalidFileFormat error code. + /// + [Fact] + public async Task WhenValidatedFileIsNotPdfThenErrorCodeIsInvalidFileFormat() + { + using HttpClient client = factory.CreateApiClient(); + using MultipartFormDataContent form = MultipartContentFactory.CreateFile("plain text", "invoice.pdf"); + + using HttpResponseMessage response = await client.PostAsync("/api/pdf/validate-zugferd", form, TestContext.Current.CancellationToken); + PdfFileValidationResponse? body = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + + Assert.Equal(ErrorCode.InvalidFileFormat, body?.ErrorCode); + } + + /// + /// Verifies that extracting XML from a non-PDF file returns the InvalidFileFormat error code. + /// + [Fact] + public async Task WhenExtractedFileIsNotPdfThenErrorCodeIsInvalidFileFormat() + { + using HttpClient client = factory.CreateApiClient(); + using MultipartFormDataContent form = MultipartContentFactory.CreateFile("plain text", "invoice.pdf"); + + using HttpResponseMessage response = await client.PostAsync("/api/pdf/extract-zugferd-xml", form, TestContext.Current.CancellationToken); + ExtractXmlFromPdfResponse? body = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + + Assert.Equal(ErrorCode.InvalidFileFormat, body?.ErrorCode); + } + + /// + /// Verifies that a request that is not multipart/form-data is not processed successfully. + /// + /// The PDF endpoint URL under test. + [Theory] + [InlineData("/api/pdf/validate-zugferd")] + [InlineData("/api/pdf/extract-zugferd-xml")] + public async Task WhenRequestIsNotMultipartThenDoesNotSucceed(string url) + { + using HttpClient client = factory.CreateApiClient(); + using StringContent content = new("%PDF-1.7", System.Text.Encoding.UTF8, "application/pdf"); + + using HttpResponseMessage response = await client.PostAsync(url, content, TestContext.Current.CancellationToken); + + Assert.False(response.IsSuccessStatusCode); + } + + /// + /// Verifies that validating a valid ZUGFeRD PDF returns 200 OK. + /// + [Fact] + public async Task WhenZugferdPdfIsValidThenValidateReturnsOk() + { + Assert.SkipUnless(JavaTestHelper.IsJavaInstalled, JavaTestHelper.SkipReason); + using HttpClient client = factory.CreateApiClient(); + using MultipartFormDataContent form = MultipartContentFactory.CreateFromPath(SampleFiles.ZugferdPdfPath); + + using HttpResponseMessage response = await client.PostAsync("/api/pdf/validate-zugferd", form, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + /// + /// Verifies that validating a valid ZUGFeRD PDF reports it as valid. + /// + [Fact] + public async Task WhenZugferdPdfIsValidThenValidateReportsIsValid() + { + Assert.SkipUnless(JavaTestHelper.IsJavaInstalled, JavaTestHelper.SkipReason); + using HttpClient client = factory.CreateApiClient(); + using MultipartFormDataContent form = MultipartContentFactory.CreateFromPath(SampleFiles.ZugferdPdfPath); + + using HttpResponseMessage response = await client.PostAsync("/api/pdf/validate-zugferd", form, TestContext.Current.CancellationToken); + PdfFileValidationResponse? body = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + + Assert.True(body?.IsValid); + } + + /// + /// Verifies that the XML embedded in a valid ZUGFeRD PDF is extracted. + /// + [Fact] + public async Task WhenZugferdPdfIsValidThenExtractReturnsXml() + { + Assert.SkipUnless(JavaTestHelper.IsJavaInstalled, JavaTestHelper.SkipReason); + using HttpClient client = factory.CreateApiClient(); + using MultipartFormDataContent form = MultipartContentFactory.CreateFromPath(SampleFiles.ZugferdPdfPath); + + using HttpResponseMessage response = await client.PostAsync("/api/pdf/extract-zugferd-xml", form, TestContext.Current.CancellationToken); + ExtractXmlFromPdfResponse? body = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + + Assert.False(string.IsNullOrWhiteSpace(body?.Xml)); + } +} diff --git a/src/Server.Tests/Endpoints/RestServerFactory.cs b/src/Server.Tests/Endpoints/RestServerFactory.cs new file mode 100644 index 0000000..a9a798e --- /dev/null +++ b/src/Server.Tests/Endpoints/RestServerFactory.cs @@ -0,0 +1,24 @@ +using Docentric.EInvoice.Validator.RestServer.Contracts; + +using Microsoft.AspNetCore.Mvc.Testing; + +namespace Docentric.EInvoice.Validator.RestServer.Tests.Endpoints; + +/// +/// Hosts the REST server in memory for endpoint tests. +/// +/// +/// The generated Program class is internal, so a public type from the same assembly is used +/// to locate the entry point instead of making Program public. +/// +public sealed class RestServerFactory : WebApplicationFactory +{ + /// + /// Creates a client that talks HTTPS and does not follow redirects, so HTTPS redirection does not interfere. + /// + public HttpClient CreateApiClient() => CreateClient(new WebApplicationFactoryClientOptions + { + BaseAddress = new Uri("https://localhost"), + AllowAutoRedirect = false, + }); +} diff --git a/src/Server.Tests/Endpoints/XmlEndpointsTests.cs b/src/Server.Tests/Endpoints/XmlEndpointsTests.cs new file mode 100644 index 0000000..0020b7b --- /dev/null +++ b/src/Server.Tests/Endpoints/XmlEndpointsTests.cs @@ -0,0 +1,159 @@ +using System.Net; +using System.Net.Http.Json; + +using Docentric.EInvoice.Validator.RestServer.Contracts; +using Docentric.EInvoice.Validator.RestServer.Tests.Services; + +namespace Docentric.EInvoice.Validator.RestServer.Tests.Endpoints; + +/// +/// Tests the XML endpoints of the REST server using requests that are rejected before Mustang CLI is invoked. +/// +/// The in-memory server factory shared by the tests in this class. +public class XmlEndpointsTests(RestServerFactory factory) : IClassFixture +{ + /// + /// Verifies that uploading a non-XML file to an XML endpoint returns 400 Bad Request. + /// + /// The XML endpoint URL under test. + [Theory] + [InlineData("/api/xml/validate")] + [InlineData("/api/xml/convert-to-pdf")] + public async Task WhenUploadedFileIsNotXmlThenReturnsBadRequest(string url) + { + using HttpClient client = factory.CreateApiClient(); + using MultipartFormDataContent form = MultipartContentFactory.CreateFile("plain text", "invoice.xml"); + + using HttpResponseMessage response = await client.PostAsync(url, form, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + /// + /// Verifies that validating a non-XML file returns the InvalidFileFormat error code. + /// + [Fact] + public async Task WhenValidatedFileIsNotXmlThenErrorCodeIsInvalidFileFormat() + { + using HttpClient client = factory.CreateApiClient(); + using MultipartFormDataContent form = MultipartContentFactory.CreateFile("plain text", "invoice.xml"); + + using HttpResponseMessage response = await client.PostAsync("/api/xml/validate", form, TestContext.Current.CancellationToken); + FileValidationResponse? body = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + + Assert.Equal(ErrorCode.InvalidFileFormat, body?.ErrorCode); + } + + /// + /// Verifies that converting a non-XML file to PDF returns the InvalidFileFormat error code. + /// + [Fact] + public async Task WhenConvertedFileIsNotXmlThenErrorCodeIsInvalidFileFormat() + { + using HttpClient client = factory.CreateApiClient(); + using MultipartFormDataContent form = MultipartContentFactory.CreateFile("plain text", "invoice.xml"); + + using HttpResponseMessage response = await client.PostAsync("/api/xml/convert-to-pdf", form, TestContext.Current.CancellationToken); + ConvertXmlToPdfResponse? body = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + + Assert.Equal(ErrorCode.InvalidFileFormat, body?.ErrorCode); + } + + /// + /// Verifies that converting with an unsupported language returns the InvalidRequest error code. + /// + [Fact] + public async Task WhenConvertLanguageIsUnsupportedThenErrorCodeIsInvalidRequest() + { + using HttpClient client = factory.CreateApiClient(); + using MultipartFormDataContent form = MultipartContentFactory.CreateFromPath(SampleFiles.FacturXXmlPath); + + using HttpResponseMessage response = await client.PostAsync("/api/xml/convert-to-pdf?language=xx", form, TestContext.Current.CancellationToken); + ConvertXmlToPdfResponse? body = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + + Assert.Equal(ErrorCode.InvalidRequest, body?.ErrorCode); + } + + /// + /// Verifies that converting a valid Factur-X XML with an explicit supported language returns PDF content. + /// + [Fact] + public async Task WhenConvertLanguageIsSpecifiedThenConvertToPdfReturnsPdfContent() + { + Assert.SkipUnless(JavaTestHelper.IsJavaInstalled, JavaTestHelper.SkipReason); + using HttpClient client = factory.CreateApiClient(); + using MultipartFormDataContent form = MultipartContentFactory.CreateFromPath(SampleFiles.FacturXXmlPath); + + using HttpResponseMessage response = await client.PostAsync("/api/xml/convert-to-pdf?language=de", form, TestContext.Current.CancellationToken); + ConvertXmlToPdfResponse? body = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + + Assert.NotEmpty(body?.Pdf ?? []); + } + + /// + /// Verifies that a request that is not multipart/form-data is not processed successfully. + /// + /// The XML endpoint URL under test. + [Theory] + [InlineData("/api/xml/validate")] + [InlineData("/api/xml/convert-to-pdf")] + public async Task WhenRequestIsNotMultipartThenDoesNotSucceed(string url) + { + using HttpClient client = factory.CreateApiClient(); + using StringContent content = new("", System.Text.Encoding.UTF8, "application/xml"); + + using HttpResponseMessage response = await client.PostAsync(url, content, TestContext.Current.CancellationToken); + + Assert.False(response.IsSuccessStatusCode); + } + + /// + /// Verifies that validating a valid Factur-X invoice returns 200 OK. + /// + [Fact] + public async Task WhenFacturXXmlIsValidThenValidateReturnsOk() + { + Assert.SkipUnless(JavaTestHelper.IsJavaInstalled, JavaTestHelper.SkipReason); + using HttpClient client = factory.CreateApiClient(); + using MultipartFormDataContent form = MultipartContentFactory.CreateFromPath(SampleFiles.FacturXXmlPath); + + using HttpResponseMessage response = await client.PostAsync("/api/xml/validate", form, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + /// + /// Verifies that validating a valid Factur-X invoice reports it as valid. + /// + [Fact] + public async Task WhenFacturXXmlIsValidThenValidateReportsIsValid() + { + Assert.SkipUnless(JavaTestHelper.IsJavaInstalled, JavaTestHelper.SkipReason); + using HttpClient client = factory.CreateApiClient(); + using MultipartFormDataContent form = MultipartContentFactory.CreateFromPath(SampleFiles.FacturXXmlPath); + + using HttpResponseMessage response = await client.PostAsync("/api/xml/validate", form, TestContext.Current.CancellationToken); + FileValidationResponse? body = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + + Assert.True(body?.IsValid); + } + + /// + /// Verifies that XML extracted from a valid ZUGFeRD PDF can be converted back to a PDF. + /// + [Fact] + public async Task WhenXmlIsExtractedFromZugferdPdfThenConvertToPdfReturnsPdfContent() + { + Assert.SkipUnless(JavaTestHelper.IsJavaInstalled, JavaTestHelper.SkipReason); + using HttpClient client = factory.CreateApiClient(); + using MultipartFormDataContent pdfForm = MultipartContentFactory.CreateFromPath(SampleFiles.ZugferdPdfPath); + using HttpResponseMessage extractResponse = await client.PostAsync("/api/pdf/extract-zugferd-xml", pdfForm, TestContext.Current.CancellationToken); + ExtractXmlFromPdfResponse? extracted = await extractResponse.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + using MultipartFormDataContent xmlForm = MultipartContentFactory.CreateFile(extracted?.Xml ?? string.Empty, "extracted.xml"); + + using HttpResponseMessage response = await client.PostAsync("/api/xml/convert-to-pdf", xmlForm, TestContext.Current.CancellationToken); + ConvertXmlToPdfResponse? body = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + + Assert.NotEmpty(body?.Pdf ?? []); + } +} diff --git a/src/Server.Tests/IO/FileTypeValidatorTests.cs b/src/Server.Tests/IO/FileTypeValidatorTests.cs new file mode 100644 index 0000000..bd0364e --- /dev/null +++ b/src/Server.Tests/IO/FileTypeValidatorTests.cs @@ -0,0 +1,161 @@ +using System.Text; + +using Docentric.EInvoice.Validator.RestServer.IO; + +using Microsoft.AspNetCore.Http; + +namespace Docentric.EInvoice.Validator.RestServer.Tests.IO; + +/// +/// Tests the magic-byte file type detection of FileTypeValidator. +/// +public class FileTypeValidatorTests +{ + private static FormFile CreateFile(byte[] content) => + new(new MemoryStream(content), 0, content.Length, "file", "test.bin"); + + private static FormFile CreateFile(string content) => CreateFile(Encoding.UTF8.GetBytes(content)); + + /// + /// Verifies that content starting with the PDF signature is recognized as PDF. + /// + [Fact] + public async Task WhenContentStartsWithPdfSignatureThenIsPdfReturnsTrue() + { + FormFile file = CreateFile("%PDF-1.7 content"); + + bool result = await FileTypeValidator.IsPdfAsync(file, TestContext.Current.CancellationToken); + + Assert.True(result); + } + + /// + /// Verifies that content shorter than the PDF signature is not recognized as PDF. + /// + [Fact] + public async Task WhenContentIsShorterThanPdfSignatureThenIsPdfReturnsFalse() + { + FormFile file = CreateFile("%PDF"); + + bool result = await FileTypeValidator.IsPdfAsync(file, TestContext.Current.CancellationToken); + + Assert.False(result); + } + + /// + /// Verifies that empty content is not recognized as PDF. + /// + [Fact] + public async Task WhenContentIsEmptyThenIsPdfReturnsFalse() + { + FormFile file = CreateFile([]); + + bool result = await FileTypeValidator.IsPdfAsync(file, TestContext.Current.CancellationToken); + + Assert.False(result); + } + + /// + /// Verifies that non-PDF content is not recognized as PDF. + /// + [Fact] + public async Task WhenContentIsNotPdfThenIsPdfReturnsFalse() + { + FormFile file = CreateFile(""); + + bool result = await FileTypeValidator.IsPdfAsync(file, TestContext.Current.CancellationToken); + + Assert.False(result); + } + + /// + /// Verifies that PDF detection observes a canceled token. + /// + [Fact] + public async Task WhenCancellationIsRequestedThenIsPdfThrows() + { + FormFile file = CreateFile("%PDF-1.7 content"); + using CancellationTokenSource cts = new(); + await cts.CancelAsync(); + + await Assert.ThrowsAnyAsync(() => FileTypeValidator.IsPdfAsync(file, cts.Token)); + } + + /// + /// Verifies that content that looks like XML is recognized as XML. + /// + /// The file content under test. + [Theory] + [InlineData("")] + [InlineData("")] + [InlineData("")] + [InlineData("<_root/>")] + [InlineData("< root/>")] + public async Task WhenContentIsXmlThenIsXmlReturnsTrue(string content) + { + FormFile file = CreateFile(content); + + bool result = await FileTypeValidator.IsXmlAsync(file, TestContext.Current.CancellationToken); + + Assert.True(result); + } + + /// + /// Verifies that XML starting with a UTF-8 byte order mark is recognized as XML. + /// + [Fact] + public async Task WhenXmlHasUtf8BomThenIsXmlReturnsTrue() + { + byte[] content = [0xEF, 0xBB, 0xBF, .. Encoding.UTF8.GetBytes("")]; + FormFile file = CreateFile(content); + + bool result = await FileTypeValidator.IsXmlAsync(file, TestContext.Current.CancellationToken); + + Assert.True(result); + } + + /// + /// Verifies that empty content is not recognized as XML. + /// + [Fact] + public async Task WhenContentIsEmptyThenIsXmlReturnsFalse() + { + FormFile file = CreateFile([]); + + bool result = await FileTypeValidator.IsXmlAsync(file, TestContext.Current.CancellationToken); + + Assert.False(result); + } + + /// + /// Verifies that content that does not look like XML is not recognized as XML. + /// + /// The file content under test. + [Theory] + [InlineData("%PDF-1.7 content")] + [InlineData("plain text")] + [InlineData("<1root/>")] + [InlineData("<")] + [InlineData("< ")] + public async Task WhenContentIsNotXmlThenIsXmlReturnsFalse(string content) + { + FormFile file = CreateFile(content); + + bool result = await FileTypeValidator.IsXmlAsync(file, TestContext.Current.CancellationToken); + + Assert.False(result); + } + + /// + /// Verifies that XML detection observes a canceled token. + /// + [Fact] + public async Task WhenCancellationIsRequestedThenIsXmlThrows() + { + FormFile file = CreateFile(""); + using CancellationTokenSource cts = new(); + await cts.CancelAsync(); + + await Assert.ThrowsAnyAsync(() => FileTypeValidator.IsXmlAsync(file, cts.Token)); + } +} diff --git a/src/Server.Tests/IO/TemporaryUploadedFileTests.cs b/src/Server.Tests/IO/TemporaryUploadedFileTests.cs new file mode 100644 index 0000000..70e916d --- /dev/null +++ b/src/Server.Tests/IO/TemporaryUploadedFileTests.cs @@ -0,0 +1,187 @@ +using System.Text; + +using Docentric.EInvoice.Validator.RestServer.Contracts; +using Docentric.EInvoice.Validator.RestServer.IO; + +using Microsoft.AspNetCore.Http; + +namespace Docentric.EInvoice.Validator.RestServer.Tests.IO; + +/// +/// Tests the temporary file handling of TemporaryUploadedFile. +/// +public class TemporaryUploadedFileTests +{ + private static FileUploadRequest CreateRequest(string content, string fileName) + { + byte[] bytes = Encoding.UTF8.GetBytes(content); + return new FileUploadRequest { File = new FormFile(new MemoryStream(bytes), 0, bytes.Length, "file", fileName) }; + } + + private static FileUploadRequest CreateRequest(FailingFormFile file) => new() { File = file }; + + /// + /// Verifies that the uploaded content is written to the temporary file. + /// + [Fact] + public async Task WhenFileIsValidThenCreateAsyncWritesContentToDisk() + { + await using TemporaryUploadedFile file = await TemporaryUploadedFile.CreateAsync( + CreateRequest("hello", "invoice.xml"), TestContext.Current.CancellationToken); + + string content = await File.ReadAllTextAsync(file.FilePath, TestContext.Current.CancellationToken); + + Assert.Equal("hello", content); + } + + /// + /// Verifies that the temporary file keeps the extension of the uploaded file. + /// + [Fact] + public async Task WhenFileHasExtensionThenCreateAsyncKeepsExtension() + { + await using TemporaryUploadedFile file = await TemporaryUploadedFile.CreateAsync( + CreateRequest("hello", "invoice.xml"), TestContext.Current.CancellationToken); + + Assert.EndsWith(".xml", file.FilePath); + } + + /// + /// Verifies that disposing the instance deletes the temporary file. + /// + [Fact] + public async Task WhenDisposedThenFileIsDeleted() + { + TemporaryUploadedFile file = await TemporaryUploadedFile.CreateAsync( + CreateRequest("hello", "invoice.xml"), TestContext.Current.CancellationToken); + + file.Dispose(); + + Assert.False(File.Exists(file.FilePath)); + } + + /// + /// Verifies that disposing the instance asynchronously deletes the temporary file. + /// + [Fact] + public async Task WhenDisposedAsyncThenFileIsDeleted() + { + TemporaryUploadedFile file = await TemporaryUploadedFile.CreateAsync( + CreateRequest("hello", "invoice.xml"), TestContext.Current.CancellationToken); + + await file.DisposeAsync(); + + Assert.False(File.Exists(file.FilePath)); + } + + /// + /// Verifies that disposing the instance more than once does not throw. + /// + [Fact] + public async Task WhenDisposedTwiceThenNoExceptionIsThrown() + { + TemporaryUploadedFile file = await TemporaryUploadedFile.CreateAsync( + CreateRequest("hello", "invoice.xml"), TestContext.Current.CancellationToken); + + file.Dispose(); + Exception? exception = Record.Exception(file.Dispose); + + Assert.Null(exception); + } + + /// + /// Verifies that a request without a file is rejected. + /// + [Fact] + public async Task WhenFileIsNullThenCreateAsyncThrowsArgumentException() + { + FileUploadRequest request = new(); + + await Assert.ThrowsAsync(() => + TemporaryUploadedFile.CreateAsync(request, TestContext.Current.CancellationToken)); + } + + /// + /// Verifies that an empty uploaded file is rejected. + /// + [Fact] + public async Task WhenFileIsEmptyThenCreateAsyncThrowsArgumentException() + { + FileUploadRequest request = CreateRequest(string.Empty, "invoice.xml"); + + await Assert.ThrowsAsync(() => + TemporaryUploadedFile.CreateAsync(request, TestContext.Current.CancellationToken)); + } + + /// + /// Verifies that an uploaded file without a file name is rejected. + /// + [Fact] + public async Task WhenFileNameIsBlankThenCreateAsyncThrowsArgumentException() + { + FileUploadRequest request = CreateRequest("hello", " "); + + await Assert.ThrowsAsync(() => + TemporaryUploadedFile.CreateAsync(request, TestContext.Current.CancellationToken)); + } + + /// + /// Verifies that a failure while copying the upload propagates and the partially written file is deleted. + /// + [Fact] + public async Task WhenCopyFailsThenExceptionPropagatesAndPartialFileIsDeleted() + { + FailingFormFile formFile = new(new InvalidOperationException("copy failed")); + + await Assert.ThrowsAsync(() => + TemporaryUploadedFile.CreateAsync(CreateRequest(formFile), TestContext.Current.CancellationToken)); + + Assert.False(File.Exists(formFile.TargetPath)); + } + + /// + /// Verifies that a cancellation while copying the upload propagates and the partially written file is deleted. + /// + [Fact] + public async Task WhenCopyIsCanceledThenCancellationPropagatesAndPartialFileIsDeleted() + { + FailingFormFile formFile = new(new OperationCanceledException()); + + await Assert.ThrowsAsync(() => + TemporaryUploadedFile.CreateAsync(CreateRequest(formFile), TestContext.Current.CancellationToken)); + + Assert.False(File.Exists(formFile.TargetPath)); + } + + /// + /// Form file test double that writes some bytes to the target stream and then throws. + /// + private sealed class FailingFormFile(Exception exception) : IFormFile + { + public string? TargetPath { get; private set; } + + public string ContentType => "application/xml"; + + public string ContentDisposition => string.Empty; + + public IHeaderDictionary Headers => new HeaderDictionary(); + + public long Length => 5; + + public string Name => "file"; + + public string FileName => "invoice.xml"; + + public async Task CopyToAsync(Stream target, CancellationToken cancellationToken = default) + { + TargetPath = ((FileStream)target).Name; + await target.WriteAsync("hello"u8.ToArray(), cancellationToken); + await target.FlushAsync(cancellationToken); + throw exception; + } + + public void CopyTo(Stream target) => throw new NotSupportedException(); + + public Stream OpenReadStream() => throw new NotSupportedException(); + } +} diff --git a/src/Server.Tests/RestServer.Tests.csproj b/src/Server.Tests/RestServer.Tests.csproj index 9ce1fb1..4a3d5dd 100644 --- a/src/Server.Tests/RestServer.Tests.csproj +++ b/src/Server.Tests/RestServer.Tests.csproj @@ -6,6 +6,7 @@ + @@ -16,6 +17,17 @@ + + + + + + + + + + + diff --git a/src/Server.Tests/SampleFiles.cs b/src/Server.Tests/SampleFiles.cs new file mode 100644 index 0000000..da0ceb6 --- /dev/null +++ b/src/Server.Tests/SampleFiles.cs @@ -0,0 +1,16 @@ +namespace Docentric.EInvoice.Validator.RestServer.Tests; + +internal static class SampleFiles +{ + /// + /// Path to a valid Factur-X XML invoice copied next to the test binaries. + /// + public static string FacturXXmlPath { get; } = GetPath("factur-x e-invoice.xml"); + + /// + /// Path to a valid ZUGFeRD PDF invoice copied next to the test binaries. + /// + public static string ZugferdPdfPath { get; } = GetPath("ZuGFeRD e-Invoice.pdf"); + + private static string GetPath(string fileName) => Path.Combine(AppContext.BaseDirectory, "Samples", fileName); +} diff --git a/src/Server.Tests/Services/JavaServiceTests.cs b/src/Server.Tests/Services/JavaServiceTests.cs new file mode 100644 index 0000000..a0e8dac --- /dev/null +++ b/src/Server.Tests/Services/JavaServiceTests.cs @@ -0,0 +1,55 @@ +using Docentric.EInvoice.Validator.RestServer.Services; + +using Microsoft.Extensions.Logging.Abstractions; + +namespace Docentric.EInvoice.Validator.RestServer.Tests.Services; + +/// +/// Tests JavaService against the Java runtime installed on the machine. Tests are skipped when Java is not available. +/// +public class JavaServiceTests +{ + private readonly JavaService _service = new(NullLogger.Instance); + + /// + /// Verifies that Java is reported as available together with its system properties. + /// + [Fact] + public async Task WhenJavaIsInstalledThenGetJavaInfoReturnsAvailableWithProperties() + { + Assert.SkipUnless(JavaTestHelper.IsJavaInstalled, JavaTestHelper.SkipReason); + + JavaInfoResult result = await _service.GetJavaInfoAsync(TestContext.Current.CancellationToken); + + Assert.True(result.IsAvailable); + Assert.NotEmpty(result.Properties); + } + + /// + /// Verifies that the returned Java properties contain java.version. + /// + [Fact] + public async Task WhenJavaIsInstalledThenGetJavaInfoContainsJavaVersionProperty() + { + Assert.SkipUnless(JavaTestHelper.IsJavaInstalled, JavaTestHelper.SkipReason); + + JavaInfoResult result = await _service.GetJavaInfoAsync(TestContext.Current.CancellationToken); + + Assert.True(result.Properties.ContainsKey("java.version")); + } + + /// + /// Verifies that a canceled request reports Java as not available. + /// + [Fact] + public async Task WhenCancellationIsRequestedThenGetJavaInfoReturnsNotAvailable() + { + Assert.SkipUnless(JavaTestHelper.IsJavaInstalled, JavaTestHelper.SkipReason); + using CancellationTokenSource cts = new(); + await cts.CancelAsync(); + + JavaInfoResult result = await _service.GetJavaInfoAsync(cts.Token); + + Assert.False(result.IsAvailable); + } +} diff --git a/src/Server.Tests/Services/JavaTestHelper.cs b/src/Server.Tests/Services/JavaTestHelper.cs new file mode 100644 index 0000000..10d4669 --- /dev/null +++ b/src/Server.Tests/Services/JavaTestHelper.cs @@ -0,0 +1,42 @@ +using System.Diagnostics; + +namespace Docentric.EInvoice.Validator.RestServer.Tests.Services; + +internal static class JavaTestHelper +{ + public const string SkipReason = "Java is not installed or not available on PATH."; + + public static bool IsJavaInstalled { get; } = DetectJava(); + + private static bool DetectJava() + { + try + { + ProcessStartInfo startInfo = new() + { + FileName = "java", + Arguments = "-version", + RedirectStandardOutput = false, + RedirectStandardError = false, + UseShellExecute = false, + CreateNoWindow = true, + }; + + using Process? process = Process.Start(startInfo); + if (process is null) + return false; + + if (!process.WaitForExit(TimeSpan.FromSeconds(10))) + { + process.Kill(entireProcessTree: true); + return false; + } + + return process.ExitCode == 0; + } + catch + { + return false; + } + } +} diff --git a/src/Server.Tests/Services/MustangCliServiceTests.cs b/src/Server.Tests/Services/MustangCliServiceTests.cs new file mode 100644 index 0000000..2eeee04 --- /dev/null +++ b/src/Server.Tests/Services/MustangCliServiceTests.cs @@ -0,0 +1,160 @@ +using Docentric.EInvoice.Validator.RestServer.Contracts; +using Docentric.EInvoice.Validator.RestServer.Services; + +using Microsoft.Extensions.Logging.Abstractions; + +namespace Docentric.EInvoice.Validator.RestServer.Tests.Services; + +/// +/// Tests MustangCliService against the Mustang CLI jar deployed next to the tests. Tests that start Java are skipped when Java is not available. +/// +public class MustangCliServiceTests +{ + private readonly MustangCliService _service = new(NullLogger.Instance); + + private static string CreateTempFile(string extension, string content) + { + string path = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + extension); + File.WriteAllText(path, content); + return path; + } + + /// + /// Verifies that the version of the deployed Mustang CLI jar is returned. + /// + [Fact] + public void WhenJarIsDeployedThenGetMustangCliVersionReturnsVersion() + { + string? version = _service.GetMustangCliVersion(); + + Assert.False(string.IsNullOrWhiteSpace(version)); + } + + /// + /// Verifies that Mustang CLI is reported as available when Java is installed. + /// + [Fact] + public async Task WhenJavaIsInstalledThenCheckAvailabilityReturnsTrue() + { + Assert.SkipUnless(JavaTestHelper.IsJavaInstalled, JavaTestHelper.SkipReason); + + bool result = await _service.CheckAvailabilityAsync(TestContext.Current.CancellationToken); + + Assert.True(result); + } + + /// + /// Verifies that a canceled availability check reports Mustang CLI as not available. + /// + [Fact] + public async Task WhenCancellationIsRequestedThenCheckAvailabilityReturnsFalse() + { + Assert.SkipUnless(JavaTestHelper.IsJavaInstalled, JavaTestHelper.SkipReason); + using CancellationTokenSource cts = new(); + await cts.CancelAsync(); + + bool result = await _service.CheckAvailabilityAsync(cts.Token); + + Assert.False(result); + } + + /// + /// Verifies that a canceled validation returns the ProcessingTimeout exit code. + /// + [Fact] + public async Task WhenCancellationIsRequestedThenValidateReturnsProcessingTimeout() + { + Assert.SkipUnless(JavaTestHelper.IsJavaInstalled, JavaTestHelper.SkipReason); + using CancellationTokenSource cts = new(); + await cts.CancelAsync(); + + MustangCliResult result = await _service.ValidateAsync("missing.pdf", cts.Token); + + Assert.Equal((int)ErrorCode.ProcessingTimeout, result.ExitCode); + } + + /// + /// Verifies that validating a missing file starts the process and returns diagnostic output. + /// + [Fact] + public async Task WhenSourceFileDoesNotExistThenValidateDoesNotSucceed() + { + Assert.SkipUnless(JavaTestHelper.IsJavaInstalled, JavaTestHelper.SkipReason); + string missingPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + ".pdf"); + + MustangCliResult result = await _service.ValidateAsync(missingPath, TestContext.Current.CancellationToken); + + Assert.False(result.ProcessStartFailed); + Assert.False(string.IsNullOrWhiteSpace(result.StandardOutput + result.StandardError)); + } + + /// + /// Verifies that extracting XML from an invalid PDF still starts the Mustang CLI process. + /// + [Fact] + public async Task WhenPdfIsInvalidThenExtractXmlStillStartsProcess() + { + Assert.SkipUnless(JavaTestHelper.IsJavaInstalled, JavaTestHelper.SkipReason); + string source = CreateTempFile(".pdf", "not a pdf"); + string output = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + ".xml"); + + try + { + MustangCliResult result = await _service.ExtractXmlAsync(source, output, TestContext.Current.CancellationToken); + + Assert.Null(result.ErrorMessage); + } + finally + { + File.Delete(source); + File.Delete(output); + } + } + + /// + /// Verifies that a valid Factur-X invoice is validated successfully. + /// + [Fact] + public async Task WhenFacturXXmlIsValidThenValidateSucceeds() + { + Assert.SkipUnless(JavaTestHelper.IsJavaInstalled, JavaTestHelper.SkipReason); + + MustangCliResult result = await _service.ValidateAsync(SampleFiles.FacturXXmlPath, TestContext.Current.CancellationToken); + + Assert.True(result.IsSuccess); + } + + /// + /// Verifies that a valid ZUGFeRD PDF is validated successfully. + /// + [Fact] + public async Task WhenZugferdPdfIsValidThenValidateSucceeds() + { + Assert.SkipUnless(JavaTestHelper.IsJavaInstalled, JavaTestHelper.SkipReason); + + MustangCliResult result = await _service.ValidateAsync(SampleFiles.ZugferdPdfPath, TestContext.Current.CancellationToken); + + Assert.True(result.IsSuccess); + } + + /// + /// Verifies that the XML embedded in a valid ZUGFeRD PDF is extracted to the output file. + /// + [Fact] + public async Task WhenZugferdPdfIsValidThenExtractXmlCreatesOutputFile() + { + Assert.SkipUnless(JavaTestHelper.IsJavaInstalled, JavaTestHelper.SkipReason); + string output = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + ".xml"); + + try + { + await _service.ExtractXmlAsync(SampleFiles.ZugferdPdfPath, output, TestContext.Current.CancellationToken); + + Assert.True(new FileInfo(output).Length > 0); + } + finally + { + File.Delete(output); + } + } +} diff --git a/src/Server.Tests/Services/MustangJarLocatorTests.cs b/src/Server.Tests/Services/MustangJarLocatorTests.cs new file mode 100644 index 0000000..ee44dd3 --- /dev/null +++ b/src/Server.Tests/Services/MustangJarLocatorTests.cs @@ -0,0 +1,174 @@ +using System.IO.Compression; + +using Docentric.EInvoice.Validator.RestServer.Services; + +namespace Docentric.EInvoice.Validator.RestServer.Tests.Services; + +/// +/// Tests how MustangJarLocator finds the Mustang CLI jar and reads its version. +/// +public class MustangJarLocatorTests +{ + private const string PomPath = "META-INF/maven/org.mustangproject/validator/pom.properties"; + + private static string CreateDirectory() + { + string directory = Path.Combine(Path.GetTempPath(), "mustang-jar-tests-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(directory); + return directory; + } + + private static void CreateJar(string directory, string fileName, string? entryPath, string entryContent) + { + using ZipArchive archive = ZipFile.Open(Path.Combine(directory, fileName), ZipArchiveMode.Create); + + if (entryPath is null) + { + archive.CreateEntry("placeholder.txt"); + return; + } + + ZipArchiveEntry entry = archive.CreateEntry(entryPath); + using StreamWriter writer = new(entry.Open()); + writer.Write(entryContent); + } + + private static string Pom(string version) => $"artifactId=validator\r\ngroupId=org.mustangproject\r\nversion={version}\r\n"; + + /// + /// Verifies that the file name of an existing jar is returned. + /// + [Fact] + public void WhenJarExistsThenFindJarFileNameReturnsFileName() + { + string directory = CreateDirectory(); + CreateJar(directory, "Mustang-CLI-2.26.0.jar", PomPath, Pom("2.26.0")); + + Assert.Equal("Mustang-CLI-2.26.0.jar", MustangJarLocator.FindJarFileName(directory)); + } + + /// + /// Verifies that the jar file name is returned even when the file is not a valid archive. + /// + [Fact] + public void WhenJarIsNotAValidArchiveThenFindJarFileNameStillReturnsFileName() + { + string directory = CreateDirectory(); + File.WriteAllText(Path.Combine(directory, "Mustang-CLI-2.26.0.jar"), "not a zip"); + + Assert.Equal("Mustang-CLI-2.26.0.jar", MustangJarLocator.FindJarFileName(directory)); + } + + /// + /// Verifies that the last jar by ordinal file name is returned when several jars exist. + /// + [Fact] + public void WhenMultipleJarsExistThenFindJarFileNameReturnsLastByOrdinalName() + { + string directory = CreateDirectory(); + CreateJar(directory, "Mustang-CLI-a.jar", PomPath, Pom("2.9.0")); + CreateJar(directory, "Mustang-CLI-b.jar", PomPath, Pom("2.26.0")); + + Assert.Equal("Mustang-CLI-b.jar", MustangJarLocator.FindJarFileName(directory)); + } + + /// + /// Verifies that null is returned when the directory contains no jar. + /// + [Fact] + public void WhenNoJarExistsThenFindJarFileNameReturnsNull() + { + string directory = CreateDirectory(); + CreateJar(directory, "other.jar", PomPath, Pom("2.26.0")); + + Assert.Null(MustangJarLocator.FindJarFileName(directory)); + } + + /// + /// Verifies that null is returned when the directory does not exist. + /// + [Fact] + public void WhenDirectoryDoesNotExistThenFindJarFileNameReturnsNull() + { + string directory = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + + Assert.Null(MustangJarLocator.FindJarFileName(directory)); + } + + /// + /// Verifies that a whitespace directory is rejected when finding the jar. + /// + [Fact] + public void WhenDirectoryIsWhitespaceThenFindJarFileNameThrowsArgumentException() + { + Assert.Throws(() => MustangJarLocator.FindJarFileName(" ")); + } + + /// + /// Verifies that the version is read from pom.properties inside the jar. + /// + [Fact] + public void WhenJarContainsPomPropertiesThenReadVersionReturnsJarVersion() + { + string directory = CreateDirectory(); + CreateJar(directory, "Mustang-CLI-9.9.9.jar", PomPath, Pom("2.26.0")); + + Assert.Equal("2.26.0", MustangJarLocator.ReadVersion(directory)); + } + + /// + /// Verifies that null is returned when the jar has no pom.properties. + /// + [Fact] + public void WhenJarHasNoPomPropertiesThenReadVersionReturnsNull() + { + string directory = CreateDirectory(); + CreateJar(directory, "Mustang-CLI-2.26.0.jar", null, string.Empty); + + Assert.Null(MustangJarLocator.ReadVersion(directory)); + } + + /// + /// Verifies that null is returned when pom.properties has no valid version. + /// + [Fact] + public void WhenPomPropertiesHasNoValidVersionThenReadVersionReturnsNull() + { + string directory = CreateDirectory(); + CreateJar(directory, "Mustang-CLI-2.26.0.jar", PomPath, Pom("latest")); + + Assert.Null(MustangJarLocator.ReadVersion(directory)); + } + + /// + /// Verifies that null is returned when the jar is not a valid archive. + /// + [Fact] + public void WhenJarIsNotAValidArchiveThenReadVersionReturnsNull() + { + string directory = CreateDirectory(); + File.WriteAllText(Path.Combine(directory, "Mustang-CLI-2.26.0.jar"), "not a zip"); + + Assert.Null(MustangJarLocator.ReadVersion(directory)); + } + + /// + /// Verifies that null is returned when the directory contains no jar. + /// + [Fact] + public void WhenNoJarExistsThenReadVersionReturnsNull() + { + string directory = CreateDirectory(); + + Assert.Null(MustangJarLocator.ReadVersion(directory)); + } + + /// + /// Verifies that a whitespace directory is rejected when reading the version. + /// + [Fact] + public void WhenDirectoryIsWhitespaceThenReadVersionThrowsArgumentException() + { + Assert.Throws(() => MustangJarLocator.ReadVersion(" ")); + } +} diff --git a/src/Server.Tests/Services/MustangValidationReportParserTests.cs b/src/Server.Tests/Services/MustangValidationReportParserTests.cs index 1186a18..23e149f 100644 --- a/src/Server.Tests/Services/MustangValidationReportParserTests.cs +++ b/src/Server.Tests/Services/MustangValidationReportParserTests.cs @@ -4,6 +4,9 @@ namespace Docentric.EInvoice.Validator.RestServer.Tests.Services; +/// +/// Tests how MustangValidationReportParser extracts statuses from a Mustang validation report. +/// public class MustangValidationReportParserTests { private static string Report(string overall, string? pdf, string? xml, string extra = "") => @@ -16,6 +19,12 @@ private static string Report(string overall, string? pdf, string? xml, string ex """; + /// + /// Verifies that the overall, PDF and XML statuses are returned when all sections are reported. + /// + /// The PDF status in the report. + /// The overall status in the report. + /// The XML status in the report. [Theory] [InlineData("valid", "valid", "valid")] [InlineData("invalid", "valid", "invalid")] @@ -25,18 +34,24 @@ public void WhenAllSectionsReportedThenStatusesAreReturned(string overall, strin { MustangValidationStatuses result = MustangValidationReportParser.Parse(Report(overall, pdf, xml)); - Assert.Equal(new MustangValidationStatuses(overall, pdf, xml), result); + Assert.Equal(new MustangValidationStatuses(overall, pdf, xml, "unknown"), result); } + /// + /// Verifies that an invalid PDF section does not change a valid overall status. + /// [Fact] public void WhenPdfInvalidButOverallValidThenPdfIsInvalidAndOverallIsValid() { // BR-FX-DE-03: PDF/A errors are warnings for German invoices. MustangValidationStatuses result = MustangValidationReportParser.Parse(Report("valid", "invalid", "valid")); - Assert.Equal(new MustangValidationStatuses("valid", "invalid", "valid"), result); + Assert.Equal(new MustangValidationStatuses("valid", "invalid", "valid", "unknown"), result); } + /// + /// Verifies that a missing PDF section results in an unknown PDF status. + /// [Fact] public void WhenPdfSectionMissingThenPdfStatusIsUnknown() { @@ -45,6 +60,9 @@ public void WhenPdfSectionMissingThenPdfStatusIsUnknown() Assert.Equal("unknown", result.Pdf); } + /// + /// Verifies that a missing XML section results in an unknown XML status. + /// [Fact] public void WhenXmlSectionMissingThenXmlStatusIsUnknown() { @@ -53,6 +71,9 @@ public void WhenXmlSectionMissingThenXmlStatusIsUnknown() Assert.Equal("unknown", result.Xml); } + /// + /// Verifies that a nested summary is not mistaken for the overall status. + /// [Fact] public void WhenOnlyNestedSummaryExistsThenOverallStatusIsUnknown() { @@ -63,6 +84,10 @@ public void WhenOnlyNestedSummaryExistsThenOverallStatusIsUnknown() Assert.Equal("unknown", result.Overall); } + /// + /// Verifies that a null or whitespace report results in unknown statuses. + /// + /// The report text under test. [Theory] [InlineData(null)] [InlineData("")] @@ -74,6 +99,56 @@ public void WhenReportIsNullOrWhitespaceThenAllStatusesAreUnknown(string? report Assert.Equal(MustangValidationStatuses.Unknown, result); } + /// + /// Verifies that the signature status is returned when the signature element is present. + /// + /// The signature status in the report. + [Theory] + [InlineData("valid")] + [InlineData("invalid")] + public void WhenSignatureElementPresentThenSignatureStatusIsReturned(string signature) + { + MustangValidationStatuses result = MustangValidationReportParser.Parse(Report("valid", "valid", "valid", $"{signature}")); + + Assert.Equal(signature, result.Signature); + } + + /// + /// Verifies that whitespace around the signature status is trimmed. + /// + [Fact] + public void WhenSignatureElementHasSurroundingWhitespaceThenItIsTrimmed() + { + MustangValidationStatuses result = MustangValidationReportParser.Parse(Report("valid", "valid", "valid", " valid ")); + + Assert.Equal("valid", result.Signature); + } + + /// + /// Verifies that a missing signature element results in an unknown signature status. + /// + [Fact] + public void WhenSignatureElementMissingThenSignatureStatusIsUnknown() + { + MustangValidationStatuses result = MustangValidationReportParser.Parse(Report("valid", "valid", "valid")); + + Assert.Equal("unknown", result.Signature); + } + + /// + /// Verifies that an empty signature element results in an unknown signature status. + /// + [Fact] + public void WhenSignatureElementEmptyThenSignatureStatusIsUnknown() + { + MustangValidationStatuses result = MustangValidationReportParser.Parse(Report("valid", "valid", "valid", "")); + + Assert.Equal("unknown", result.Signature); + } + + /// + /// Verifies that a malformed report throws an XmlException. + /// [Fact] public void WhenReportIsMalformedThenXmlExceptionIsThrown() => Assert.ThrowsAny(() => MustangValidationReportParser.Parse("")); } diff --git a/src/Server/Configuration/Constants.cs b/src/Server/Configuration/Constants.cs index 4d52d67..e45a56e 100644 --- a/src/Server/Configuration/Constants.cs +++ b/src/Server/Configuration/Constants.cs @@ -1,55 +1,55 @@ -namespace Docentric.EInvoice.Validator.RestServer.Configuration; - -/// -/// Contains application-wide constant values. -/// -public static class Constants -{ - /// - /// Application-level constants. - /// - public static class Application - { - /// - /// The default title for the application. - /// - public const string DefaultTitle = "Docentric e-Invoice Document Validator"; - } - - /// - /// OpenAPI specification constants. - /// - public static class OpenApi - { - /// - /// The API version identifier. - /// - public const string Version = "v1"; - - /// - /// The URI suffix for the OpenAPI specification document. - /// - public const string V1UriSuffix = $"/openapi/{Version}.json"; - } - - /// - /// Request timeout configuration values. - /// - public static class RequestTimeouts - { - /// - /// Default timeout. - /// - public static TimeSpan DefaultTimeout => TimeSpan.FromSeconds(30); - - /// - /// Timeout for long-running operations. - /// - public static TimeSpan LongRunning => TimeSpan.FromSeconds(300); - - /// - /// Policy name for long-running operations. - /// - public const string LongRunningPolicy = "LongRunning"; - } -} +namespace Docentric.EInvoice.Validator.RestServer.Configuration; + +/// +/// Contains application-wide constant values. +/// +public static class Constants +{ + /// + /// Application-level constants. + /// + public static class Application + { + /// + /// The default title for the application. + /// + public const string DefaultTitle = "Docentric e-Invoice Document Validator"; + } + + /// + /// OpenAPI specification constants. + /// + public static class OpenApi + { + /// + /// The API version identifier. + /// + public const string Version = "v1"; + + /// + /// The URI suffix for the OpenAPI specification document. + /// + public const string V1UriSuffix = $"/openapi/{Version}.json"; + } + + /// + /// Request timeout configuration values. + /// + public static class RequestTimeouts + { + /// + /// Default timeout. + /// + public static TimeSpan DefaultTimeout => TimeSpan.FromSeconds(30); + + /// + /// Timeout for long-running operations. + /// + public static TimeSpan LongRunning => TimeSpan.FromSeconds(300); + + /// + /// Policy name for long-running operations. + /// + public const string LongRunningPolicy = "LongRunning"; + } +} diff --git a/src/Server/Configuration/EndpointRouteBuilderExtensions.cs b/src/Server/Configuration/EndpointRouteBuilderExtensions.cs index 0fe364f..fbf7e27 100644 --- a/src/Server/Configuration/EndpointRouteBuilderExtensions.cs +++ b/src/Server/Configuration/EndpointRouteBuilderExtensions.cs @@ -1,20 +1,20 @@ -using Docentric.EInvoice.Validator.RestServer.Endpoints; - -namespace Docentric.EInvoice.Validator.RestServer.Configuration; - -/// -/// Provides extension methods for configuring API endpoints. -/// -internal static class EndpointRouteBuilderExtensions -{ - /// - /// Maps all API endpoints including health checks, PDF, and XML endpoints. - /// - /// The endpoint route builder. - internal static void MapApiEndpoints(this IEndpointRouteBuilder app) - { - app.MapHealthEndpoints(); - app.MapPdfEndpoints(); - app.MapXmlEndpoints(); - } -} +using Docentric.EInvoice.Validator.RestServer.Endpoints; + +namespace Docentric.EInvoice.Validator.RestServer.Configuration; + +/// +/// Provides extension methods for configuring API endpoints. +/// +internal static class EndpointRouteBuilderExtensions +{ + /// + /// Maps all API endpoints including health checks, PDF, and XML endpoints. + /// + /// The endpoint route builder. + internal static void MapApiEndpoints(this IEndpointRouteBuilder app) + { + app.MapHealthEndpoints(); + app.MapPdfEndpoints(); + app.MapXmlEndpoints(); + } +} diff --git a/src/Server/Contracts/BaseFileOperationResponse.cs b/src/Server/Contracts/BaseFileOperationResponse.cs index e006525..4229d16 100644 --- a/src/Server/Contracts/BaseFileOperationResponse.cs +++ b/src/Server/Contracts/BaseFileOperationResponse.cs @@ -1,42 +1,42 @@ -using System.Text.Json.Serialization; - -namespace Docentric.EInvoice.Validator.RestServer.Contracts; - -/// -/// Represents the base response for file operations performed by the ZuGFeRD/Factur-X/UBL validator. -/// This class provides standard success/error information that can be extended by specific operation responses. -/// -public class BaseFileOperationResponse -{ - /// - /// Indicates whether the extraction was successful. - /// - [JsonPropertyName("success")] - public bool Success => ErrorCode == ErrorCode.Success; - - /// - /// The error code associated with the extraction result. - /// A value of indicates no error. - /// - [JsonPropertyName("errorCode")] - [JsonConverter(typeof(JsonStringEnumConverter))] - public ErrorCode ErrorCode { get; set; } - - /// - /// The error message describing any extraction failures or issues. - /// - [JsonPropertyName("errorMessage")] - public string ErrorMessage - { - get => string.IsNullOrWhiteSpace(field) && ErrorCode != ErrorCode.Success - ? ErrorCode.GetFriendlyMessage() - : field; - set; - } = string.Empty; - - /// - /// Additional diagnostics message - /// - [JsonPropertyName("diagnosticsErrorMessage")] - public string? DiagnosticsErrorMessage { get; set; } = null; -} +using System.Text.Json.Serialization; + +namespace Docentric.EInvoice.Validator.RestServer.Contracts; + +/// +/// Represents the base response for file operations performed by the ZuGFeRD/Factur-X/UBL validator. +/// This class provides standard success/error information that can be extended by specific operation responses. +/// +public class BaseFileOperationResponse +{ + /// + /// Indicates whether the extraction was successful. + /// + [JsonPropertyName("success")] + public bool Success => ErrorCode == ErrorCode.Success; + + /// + /// The error code associated with the extraction result. + /// A value of indicates no error. + /// + [JsonPropertyName("errorCode")] + [JsonConverter(typeof(JsonStringEnumConverter))] + public ErrorCode ErrorCode { get; set; } + + /// + /// The error message describing any extraction failures or issues. + /// + [JsonPropertyName("errorMessage")] + public string ErrorMessage + { + get => string.IsNullOrWhiteSpace(field) && ErrorCode != ErrorCode.Success + ? ErrorCode.GetFriendlyMessage() + : field; + set; + } = string.Empty; + + /// + /// Additional diagnostics message + /// + [JsonPropertyName("diagnosticsErrorMessage")] + public string? DiagnosticsErrorMessage { get; set; } = null; +} diff --git a/src/Server/Contracts/ConvertXmlToPdfResponse.cs b/src/Server/Contracts/ConvertXmlToPdfResponse.cs index c7c79fd..89a30b8 100644 --- a/src/Server/Contracts/ConvertXmlToPdfResponse.cs +++ b/src/Server/Contracts/ConvertXmlToPdfResponse.cs @@ -1,15 +1,15 @@ -using System.Text.Json.Serialization; - -namespace Docentric.EInvoice.Validator.RestServer.Contracts; - -/// -/// The result of Factur-X or UBL XML to PDF conversion operation. -/// -public class ConvertXmlToPdfResponse : BaseFileOperationResponse -{ - /// - /// The generated PDF file as a byte array. - /// - [JsonPropertyName("pdf")] - public byte[]? Pdf { get; set; } -} +using System.Text.Json.Serialization; + +namespace Docentric.EInvoice.Validator.RestServer.Contracts; + +/// +/// The result of Factur-X or UBL XML to PDF conversion operation. +/// +public class ConvertXmlToPdfResponse : BaseFileOperationResponse +{ + /// + /// The generated PDF file as a byte array. + /// + [JsonPropertyName("pdf")] + public byte[]? Pdf { get; set; } +} diff --git a/src/Server/Contracts/ErrorCode.Examples.md b/src/Server/Contracts/ErrorCode.Examples.md index 81ddcdb..06eb1d3 100644 --- a/src/Server/Contracts/ErrorCode.Examples.md +++ b/src/Server/Contracts/ErrorCode.Examples.md @@ -1,147 +1,147 @@ -# ErrorCode Usage Examples - -This document provides examples of how to use the `ErrorCode` enum in the ZuGFeRD Validator REST API. - -## Overview - -The `ErrorCode` enum contains validation error codes from two sources: -- **Mustang CLI errors (1-27)**: Standard validation errors from the Mustang validator -- **Custom application errors (1000+)**: Application-specific errors - -## API Response Format - -When validation fails, the API returns a `FileValidationResponse` with an error code: - -```json -{ - "success": false, - "errorCode": "FileNotFound", - "errorMessage": "File not found", - "validationReport": null -} -``` - -The `errorCode` field is serialized as a string (e.g., "FileNotFound") for better API usability. - -## C# Usage Examples - -### Getting a Friendly Error Message - -```csharp -using Docentric.EInvoice.Validator.RestServer.Contracts; - -var errorCode = ErrorCode.FileNotFound; -string friendlyMessage = errorCode.GetFriendlyMessage(); -// Output: "File not found" -``` - -### Checking Error Type - -```csharp -var errorCode = ErrorCode.SchematronRuleFailed; - -if (errorCode.IsMustangError()) -{ - Console.WriteLine("This is a Mustang CLI validation error"); -} - -if (errorCode.IsCustomError()) -{ - Console.WriteLine("This is a custom application error"); -} -``` - -### Creating a Validation Response - -```csharp -var response = new FileValidationResponse -{ - Success = false, - ErrorCode = ErrorCode.XmlDataNotFound, - ErrorMessage = ErrorCode.XmlDataNotFound.GetFriendlyMessage(), - ValidationReport = null -}; -``` - -## Common Error Codes - -### File-Related Errors -- `FileNotFound` (1): The specified file could not be found -- `FileTooSmall` (5): File is too small to be a valid ZuGFeRD/Factur-X document -- `InvalidFileFormat` (8): File doesn't appear to be PDF or XML - -### PDF-Related Errors -- `NotAPdf` (20): File is not a valid PDF -- `NotAPdfA3` (23): PDF is not in PDF/A-3 format (required for ZuGFeRD) -- `VeraPdfException` (6): PDF validation failed - -### XML/Schema Errors -- `XmlDataNotFound` (3): No embedded XML found in PDF -- `SchemaValidationFailed` (18): XML doesn't conform to schema -- `SchematronRuleFailed` (4): Business rule validation failed - -### Validation Errors -- `ArithmeticalError` (10): Calculated totals don't match document totals -- `En16931SchematronCheckFailed` (24): EN16931 standard validation failed -- `XRechnungSchematronValidationFailed` (27): XRechnung validation failed - -### XMP Metadata Errors -- `XmpConformanceLevelNotFound` (11): Required conformance level not found -- `XmpVersionNotFound` (15): Required version not found -- `XmpDocumentTypeNotFound` (13): Required document type not found - -### Custom Application Errors -- `UnknownError` (1000): Unspecified error -- `InvalidRequest` (1001): Invalid request parameters -- `ProcessingTimeout` (1002): Processing took too long -- `InternalServerError` (1003): Server error during processing - -## JSON/Form Deserialization - -When sending requests to the API, error codes can be specified as: - -### JSON Request -```json -{ - "errorCode": "FileNotFound" -} -``` - -Or as numeric values: -```json -{ - "errorCode": 1 -} -``` - -Both formats are supported thanks to `JsonStringEnumConverter`. - -### HTML Form -```html - -``` - -## Error Code Reference Table - -| Code | Name | Description | -|------|------|-------------| -| 0 | None | No error | -| 1 | FileNotFound | File not found | -| 2 | AdditionalDataSchemaValidationFailed | Additional data schema validation failed | -| 3 | XmlDataNotFound | XML data not found | -| 4 | SchematronRuleFailed | Schematron rule failed | -| 5 | FileTooSmall | File too small | -| 6 | VeraPdfException | VeraPDF exception | -| 7 | IoExceptionPdf | IOException while processing PDF | -| 8 | InvalidFileFormat | File does not appear to be PDF or XML | -| 9 | IoExceptionXml | IOException while processing XML | -| 10 | ArithmeticalError | Calculated sums/totals do not match | -| 11-27 | [Various XMP/Validation Errors] | See code documentation | -| 1000+ | [Custom Application Errors] | Application-specific errors | - -For the complete list of error codes and their descriptions, refer to the `ErrorCode` enum in the source code. +# ErrorCode Usage Examples + +This document provides examples of how to use the `ErrorCode` enum in the ZuGFeRD Validator REST API. + +## Overview + +The `ErrorCode` enum contains validation error codes from two sources: +- **Mustang CLI errors (1-27)**: Standard validation errors from the Mustang validator +- **Custom application errors (1000+)**: Application-specific errors + +## API Response Format + +When validation fails, the API returns a `FileValidationResponse` with an error code: + +```json +{ + "success": false, + "errorCode": "FileNotFound", + "errorMessage": "File not found", + "validationReport": null +} +``` + +The `errorCode` field is serialized as a string (e.g., "FileNotFound") for better API usability. + +## C# Usage Examples + +### Getting a Friendly Error Message + +```csharp +using Docentric.EInvoice.Validator.RestServer.Contracts; + +var errorCode = ErrorCode.FileNotFound; +string friendlyMessage = errorCode.GetFriendlyMessage(); +// Output: "File not found" +``` + +### Checking Error Type + +```csharp +var errorCode = ErrorCode.SchematronRuleFailed; + +if (errorCode.IsMustangError()) +{ + Console.WriteLine("This is a Mustang CLI validation error"); +} + +if (errorCode.IsCustomError()) +{ + Console.WriteLine("This is a custom application error"); +} +``` + +### Creating a Validation Response + +```csharp +var response = new FileValidationResponse +{ + Success = false, + ErrorCode = ErrorCode.XmlDataNotFound, + ErrorMessage = ErrorCode.XmlDataNotFound.GetFriendlyMessage(), + ValidationReport = null +}; +``` + +## Common Error Codes + +### File-Related Errors +- `FileNotFound` (1): The specified file could not be found +- `FileTooSmall` (5): File is too small to be a valid ZuGFeRD/Factur-X document +- `InvalidFileFormat` (8): File doesn't appear to be PDF or XML + +### PDF-Related Errors +- `NotAPdf` (20): File is not a valid PDF +- `NotAPdfA3` (23): PDF is not in PDF/A-3 format (required for ZuGFeRD) +- `VeraPdfException` (6): PDF validation failed + +### XML/Schema Errors +- `XmlDataNotFound` (3): No embedded XML found in PDF +- `SchemaValidationFailed` (18): XML doesn't conform to schema +- `SchematronRuleFailed` (4): Business rule validation failed + +### Validation Errors +- `ArithmeticalError` (10): Calculated totals don't match document totals +- `En16931SchematronCheckFailed` (24): EN16931 standard validation failed +- `XRechnungSchematronValidationFailed` (27): XRechnung validation failed + +### XMP Metadata Errors +- `XmpConformanceLevelNotFound` (11): Required conformance level not found +- `XmpVersionNotFound` (15): Required version not found +- `XmpDocumentTypeNotFound` (13): Required document type not found + +### Custom Application Errors +- `UnknownError` (1000): Unspecified error +- `InvalidRequest` (1001): Invalid request parameters +- `ProcessingTimeout` (1002): Processing took too long +- `InternalServerError` (1003): Server error during processing + +## JSON/Form Deserialization + +When sending requests to the API, error codes can be specified as: + +### JSON Request +```json +{ + "errorCode": "FileNotFound" +} +``` + +Or as numeric values: +```json +{ + "errorCode": 1 +} +``` + +Both formats are supported thanks to `JsonStringEnumConverter`. + +### HTML Form +```html + +``` + +## Error Code Reference Table + +| Code | Name | Description | +|------|------|-------------| +| 0 | None | No error | +| 1 | FileNotFound | File not found | +| 2 | AdditionalDataSchemaValidationFailed | Additional data schema validation failed | +| 3 | XmlDataNotFound | XML data not found | +| 4 | SchematronRuleFailed | Schematron rule failed | +| 5 | FileTooSmall | File too small | +| 6 | VeraPdfException | VeraPDF exception | +| 7 | IoExceptionPdf | IOException while processing PDF | +| 8 | InvalidFileFormat | File does not appear to be PDF or XML | +| 9 | IoExceptionXml | IOException while processing XML | +| 10 | ArithmeticalError | Calculated sums/totals do not match | +| 11-27 | [Various XMP/Validation Errors] | See code documentation | +| 1000+ | [Custom Application Errors] | Application-specific errors | + +For the complete list of error codes and their descriptions, refer to the `ErrorCode` enum in the source code. diff --git a/src/Server/Contracts/ErrorCode.cs b/src/Server/Contracts/ErrorCode.cs index 3f924eb..2456248 100644 --- a/src/Server/Contracts/ErrorCode.cs +++ b/src/Server/Contracts/ErrorCode.cs @@ -1,260 +1,261 @@ -using System.ComponentModel; - -namespace Docentric.EInvoice.Validator.RestServer.Contracts; - -/// -/// Defines error codes used during ZuGFeRD/Factur-X validation. -/// Error codes 1-27 correspond to Mustang CLI validator error types. -/// Error codes 1000+ are custom application errors. -/// -public enum ErrorCode -{ - /// - /// Operation has failed. - /// - [Description("Operation has failed.")] - ExecutionError = -1, - - /// - /// Operation completed successfully. - /// - [Description("Operation completed successfully.")] - Success = 0, - - // Mustang CLI Error Codes (1-27) - - /// - /// File not found. - /// - [Description("File not found")] - FileNotFound = 1, - - /// - /// Additional data schema validation fails. - /// - [Description("Additional data schema validation failed")] - AdditionalDataSchemaValidationFailed = 2, - - /// - /// XML data not found in the file. - /// - [Description("XML data not found")] - XmlDataNotFound = 3, - - /// - /// Schematron rule failed. - /// - [Description("Schematron rule failed")] - SchematronRuleFailed = 4, - - /// - /// File too small to be valid. - /// - [Description("File too small")] - FileTooSmall = 5, - - /// - /// VeraPDF exception occurred during PDF validation. - /// - [Description("VeraPDF exception")] - VeraPdfException = 6, - - /// - /// IOException occurred while processing PDF. - /// - [Description("IOException while processing PDF")] - IoExceptionPdf = 7, - - /// - /// File does not look like PDF nor XML (contains neither %PDF nor <?xml). - /// - [Description("File does not appear to be PDF or XML")] - InvalidFileFormat = 8, - - /// - /// IOException occurred while processing XML. - /// - [Description("IOException while processing XML")] - IoExceptionXml = 9, - - /// - /// Arithmetical unreproducability - Mustang calculates different sums and/or totals. - /// - [Description("Calculated sums/totals do not match (arithmetical error)")] - ArithmeticalError = 10, - - /// - /// XMP Metadata: ConformanceLevel not found. - /// - [Description("XMP Metadata: ConformanceLevel not found")] - XmpConformanceLevelNotFound = 11, - - /// - /// XMP Metadata: ConformanceLevel contains invalid value. - /// - [Description("XMP Metadata: ConformanceLevel contains invalid value")] - XmpConformanceLevelInvalid = 12, - - /// - /// XMP Metadata: DocumentType not found. - /// - [Description("XMP Metadata: DocumentType not found")] - XmpDocumentTypeNotFound = 13, - - /// - /// XMP Metadata: DocumentType invalid. - /// - [Description("XMP Metadata: DocumentType invalid")] - XmpDocumentTypeInvalid = 14, - - /// - /// XMP Metadata: Version not found. - /// - [Description("XMP Metadata: Version not found")] - XmpVersionNotFound = 15, - - /// - /// XMP Metadata: Version contains invalid value. - /// - [Description("XMP Metadata: Version contains invalid value")] - XmpVersionInvalid = 16, - - /// - /// Context error - parts may be valid but not in this combination. - /// - [Description("Context error - parts may be valid but not in this combination")] - ContextError = 17, - - /// - /// Schema validation failed. - /// - [Description("Schema validation failed")] - SchemaValidationFailed = 18, - - /// - /// XMP Metadata: DocumentFileName contains invalid value. - /// - [Description("XMP Metadata: DocumentFileName contains invalid value")] - XmpDocumentFileNameInvalid = 19, - - /// - /// Not a PDF file. - /// - [Description("Not a PDF file")] - NotAPdf = 20, - - /// - /// XMP Metadata: DocumentFileName not found. - /// - [Description("XMP Metadata: DocumentFileName not found")] - XmpDocumentFileNameNotFound = 21, - - /// - /// Generic XML validation exception. - /// - [Description("Generic XML validation exception")] - GenericXmlValidationException = 22, - - /// - /// Not a PDF/A-3 file. - /// - [Description("Not a PDF/A-3 file")] - NotAPdfA3 = 23, - - /// - /// Issues in CEN EN16931 Schematron Check. - /// - [Description("Issues in CEN EN16931 Schematron Check")] - En16931SchematronCheckFailed = 24, - - /// - /// Unsupported profile type. - /// - [Description("Unsupported profile type")] - UnsupportedProfileType = 25, - - /// - /// No rules matched, XML too minimal. - /// - [Description("No rules matched - XML may be too minimal")] - NoRulesMatched = 26, - - /// - /// XRechnung schematron validation failed. - /// - [Description("XRechnung schematron validation failed")] - XRechnungSchematronValidationFailed = 27, - - // Custom Application Error Codes (1000+) - - /// - /// Unknown or unspecified error occurred. - /// - [Description("Unknown error occurred")] - UnknownError = 1000, - - /// - /// Invalid request or missing required parameters. - /// - [Description("Invalid request or missing required parameters")] - InvalidRequest = 1001, - - /// - /// Processing timeout occurred. - /// - [Description("Processing timeout")] - ProcessingTimeout = 1002, - - /// - /// Internal server error during validation. - /// - [Description("Internal server error")] - InternalServerError = 1003 -} - -/// -/// Extension methods for the enum. -/// -public static class ErrorCodeExtensions -{ - /// - /// Gets a friendly, human-readable error message for the specified error code. - /// - /// The error code. - /// A friendly error message describing the error. - public static string GetFriendlyMessage(this ErrorCode errorCode) - { - var field = errorCode.GetType().GetField(errorCode.ToString()); - if (field != null) - { - var attribute = (DescriptionAttribute?)Attribute.GetCustomAttribute(field, typeof(DescriptionAttribute)); - if (attribute != null) - { - return attribute.Description; - } - } - return errorCode.ToString(); - } - - /// - /// Determines whether the error code represents a Mustang CLI validation error (codes 1-27). - /// - /// The error code. - /// true if the error code is from Mustang CLI; otherwise, false. - public static bool IsMustangError(this ErrorCode errorCode) - { - int code = (int)errorCode; - return code >= 1 && code <= 27; - } - - /// - /// Determines whether the error code represents a custom application error (codes 1000+). - /// - /// The error code. - /// true if the error code is a custom application error; otherwise, false. - public static bool IsCustomError(this ErrorCode errorCode) - { - int code = (int)errorCode; - return code >= 1000; - } -} +using System.ComponentModel; +using System.Reflection; + +namespace Docentric.EInvoice.Validator.RestServer.Contracts; + +/// +/// Defines error codes used during ZuGFeRD/Factur-X validation. +/// Error codes 1-27 correspond to Mustang CLI validator error types. +/// Error codes 1000+ are custom application errors. +/// +public enum ErrorCode +{ + /// + /// Operation has failed. + /// + [Description("Operation has failed.")] + ExecutionError = -1, + + /// + /// Operation completed successfully. + /// + [Description("Operation completed successfully.")] + Success = 0, + + // Mustang CLI Error Codes (1-27) + + /// + /// File not found. + /// + [Description("File not found")] + FileNotFound = 1, + + /// + /// Additional data schema validation fails. + /// + [Description("Additional data schema validation failed")] + AdditionalDataSchemaValidationFailed = 2, + + /// + /// XML data not found in the file. + /// + [Description("XML data not found")] + XmlDataNotFound = 3, + + /// + /// Schematron rule failed. + /// + [Description("Schematron rule failed")] + SchematronRuleFailed = 4, + + /// + /// File too small to be valid. + /// + [Description("File too small")] + FileTooSmall = 5, + + /// + /// VeraPDF exception occurred during PDF validation. + /// + [Description("VeraPDF exception")] + VeraPdfException = 6, + + /// + /// IOException occurred while processing PDF. + /// + [Description("IOException while processing PDF")] + IoExceptionPdf = 7, + + /// + /// File does not look like PDF nor XML (contains neither %PDF nor <?xml). + /// + [Description("File does not appear to be PDF or XML")] + InvalidFileFormat = 8, + + /// + /// IOException occurred while processing XML. + /// + [Description("IOException while processing XML")] + IoExceptionXml = 9, + + /// + /// Arithmetical unreproducability - Mustang calculates different sums and/or totals. + /// + [Description("Calculated sums/totals do not match (arithmetical error)")] + ArithmeticalError = 10, + + /// + /// XMP Metadata: ConformanceLevel not found. + /// + [Description("XMP Metadata: ConformanceLevel not found")] + XmpConformanceLevelNotFound = 11, + + /// + /// XMP Metadata: ConformanceLevel contains invalid value. + /// + [Description("XMP Metadata: ConformanceLevel contains invalid value")] + XmpConformanceLevelInvalid = 12, + + /// + /// XMP Metadata: DocumentType not found. + /// + [Description("XMP Metadata: DocumentType not found")] + XmpDocumentTypeNotFound = 13, + + /// + /// XMP Metadata: DocumentType invalid. + /// + [Description("XMP Metadata: DocumentType invalid")] + XmpDocumentTypeInvalid = 14, + + /// + /// XMP Metadata: Version not found. + /// + [Description("XMP Metadata: Version not found")] + XmpVersionNotFound = 15, + + /// + /// XMP Metadata: Version contains invalid value. + /// + [Description("XMP Metadata: Version contains invalid value")] + XmpVersionInvalid = 16, + + /// + /// Context error - parts may be valid but not in this combination. + /// + [Description("Context error - parts may be valid but not in this combination")] + ContextError = 17, + + /// + /// Schema validation failed. + /// + [Description("Schema validation failed")] + SchemaValidationFailed = 18, + + /// + /// XMP Metadata: DocumentFileName contains invalid value. + /// + [Description("XMP Metadata: DocumentFileName contains invalid value")] + XmpDocumentFileNameInvalid = 19, + + /// + /// Not a PDF file. + /// + [Description("Not a PDF file")] + NotAPdf = 20, + + /// + /// XMP Metadata: DocumentFileName not found. + /// + [Description("XMP Metadata: DocumentFileName not found")] + XmpDocumentFileNameNotFound = 21, + + /// + /// Generic XML validation exception. + /// + [Description("Generic XML validation exception")] + GenericXmlValidationException = 22, + + /// + /// Not a PDF/A-3 file. + /// + [Description("Not a PDF/A-3 file")] + NotAPdfA3 = 23, + + /// + /// Issues in CEN EN16931 Schematron Check. + /// + [Description("Issues in CEN EN16931 Schematron Check")] + En16931SchematronCheckFailed = 24, + + /// + /// Unsupported profile type. + /// + [Description("Unsupported profile type")] + UnsupportedProfileType = 25, + + /// + /// No rules matched, XML too minimal. + /// + [Description("No rules matched - XML may be too minimal")] + NoRulesMatched = 26, + + /// + /// XRechnung schematron validation failed. + /// + [Description("XRechnung schematron validation failed")] + XRechnungSchematronValidationFailed = 27, + + // Custom Application Error Codes (1000+) + + /// + /// Unknown or unspecified error occurred. + /// + [Description("Unknown error occurred")] + UnknownError = 1000, + + /// + /// Invalid request or missing required parameters. + /// + [Description("Invalid request or missing required parameters")] + InvalidRequest = 1001, + + /// + /// Processing timeout occurred. + /// + [Description("Processing timeout")] + ProcessingTimeout = 1002, + + /// + /// Internal server error during validation. + /// + [Description("Internal server error")] + InternalServerError = 1003 +} + +/// +/// Extension methods for the enum. +/// +public static class ErrorCodeExtensions +{ + /// + /// Gets a friendly, human-readable error message for the specified error code. + /// + /// The error code. + /// A friendly error message describing the error. + public static string GetFriendlyMessage(this ErrorCode errorCode) + { + FieldInfo? field = errorCode.GetType().GetField(errorCode.ToString()); + if (field != null) + { + var attribute = (DescriptionAttribute?)Attribute.GetCustomAttribute(field, typeof(DescriptionAttribute)); + if (attribute != null) + { + return attribute.Description; + } + } + return errorCode.ToString(); + } + + /// + /// Determines whether the error code represents a Mustang CLI validation error (codes 1-27). + /// + /// The error code. + /// true if the error code is from Mustang CLI; otherwise, false. + public static bool IsMustangError(this ErrorCode errorCode) + { + int code = (int)errorCode; + return code >= 1 && code <= 27; + } + + /// + /// Determines whether the error code represents a custom application error (codes 1000+). + /// + /// The error code. + /// true if the error code is a custom application error; otherwise, false. + public static bool IsCustomError(this ErrorCode errorCode) + { + int code = (int)errorCode; + return code >= 1000; + } +} diff --git a/src/Server/Contracts/ExtractXmlFromPdfResponse.cs b/src/Server/Contracts/ExtractXmlFromPdfResponse.cs index aef0c7f..b27de14 100644 --- a/src/Server/Contracts/ExtractXmlFromPdfResponse.cs +++ b/src/Server/Contracts/ExtractXmlFromPdfResponse.cs @@ -1,15 +1,15 @@ -using System.Text.Json.Serialization; - -namespace Docentric.EInvoice.Validator.RestServer.Contracts; - -/// -/// The result of a ZuGFeRD PDF XML extraction operation. -/// -public class ExtractXmlFromPdfResponse : BaseFileOperationResponse -{ - /// - /// The extracted XML content. - /// - [JsonPropertyName("xml")] - public string? Xml { get; set; } = null; -} +using System.Text.Json.Serialization; + +namespace Docentric.EInvoice.Validator.RestServer.Contracts; + +/// +/// The result of a ZuGFeRD PDF XML extraction operation. +/// +public class ExtractXmlFromPdfResponse : BaseFileOperationResponse +{ + /// + /// The extracted XML content. + /// + [JsonPropertyName("xml")] + public string? Xml { get; set; } = null; +} diff --git a/src/Server/Contracts/FileUploadRequest.cs b/src/Server/Contracts/FileUploadRequest.cs index 51007e2..6e7debc 100644 --- a/src/Server/Contracts/FileUploadRequest.cs +++ b/src/Server/Contracts/FileUploadRequest.cs @@ -1,15 +1,15 @@ -using Microsoft.AspNetCore.Mvc; - -namespace Docentric.EInvoice.Validator.RestServer.Contracts; - -/// -/// Request to upload a file with optional checksum verification. -/// -public sealed class FileUploadRequest -{ - /// - /// The file to be uploaded. - /// - [FromForm(Name = "file")] - public IFormFile File { get; set; } = default!; -} +using Microsoft.AspNetCore.Mvc; + +namespace Docentric.EInvoice.Validator.RestServer.Contracts; + +/// +/// Request to upload a file with optional checksum verification. +/// +public sealed class FileUploadRequest +{ + /// + /// The file to be uploaded. + /// + [FromForm(Name = "file")] + public IFormFile File { get; set; } = default!; +} diff --git a/src/Server/Contracts/FileValidationResponse.cs b/src/Server/Contracts/FileValidationResponse.cs index 8bef8bd..dd0cb81 100644 --- a/src/Server/Contracts/FileValidationResponse.cs +++ b/src/Server/Contracts/FileValidationResponse.cs @@ -1,21 +1,21 @@ -using System.Text.Json.Serialization; - -namespace Docentric.EInvoice.Validator.RestServer.Contracts; - -/// -/// The result of a XML file validation operation. -/// -public class FileValidationResponse : BaseFileOperationResponse -{ - /// - /// Indicates whether the file content is valid according to the ZuGFeRD/Factur-X standard. - /// - [JsonPropertyName("isValid")] - public bool IsValid { get; set; } = false; - - /// - /// The detailed validation report containing the results of the validation process. - /// - [JsonPropertyName("validationReport")] - public string ValidationReport { get; set; } = default!; -} +using System.Text.Json.Serialization; + +namespace Docentric.EInvoice.Validator.RestServer.Contracts; + +/// +/// The result of a XML file validation operation. +/// +public class FileValidationResponse : BaseFileOperationResponse +{ + /// + /// Indicates whether the file content is valid according to the ZuGFeRD/Factur-X standard. + /// + [JsonPropertyName("isValid")] + public bool IsValid { get; set; } = false; + + /// + /// The detailed validation report containing the results of the validation process. + /// + [JsonPropertyName("validationReport")] + public string ValidationReport { get; set; } = default!; +} diff --git a/src/Server/Contracts/PdfFileValidationResponse.cs b/src/Server/Contracts/PdfFileValidationResponse.cs index bf1ec00..75ee39c 100644 --- a/src/Server/Contracts/PdfFileValidationResponse.cs +++ b/src/Server/Contracts/PdfFileValidationResponse.cs @@ -1,30 +1,30 @@ -using System.Text.Json.Serialization; - -namespace Docentric.EInvoice.Validator.RestServer.Contracts; - -/// -/// The result of a ZuGFeRD file validation operation. -/// -public sealed class PdfFileValidationResponse : FileValidationResponse -{ - /// - /// Indicates whether the digital signature of the file is valid. - /// - [JsonPropertyName("isSignatureValid")] - public bool IsSignatureValid { get; set; } = false; - - /// - /// Indicates whether the PDF part of the hybrid document is valid (e.g. PDF/A-3 conformant). - /// Note: under ZuGFeRD rule BR-FX-DE-03, PDF/A compliance errors are treated as warnings (not fatal) - /// when both buyer and seller are in Germany, so may be - /// true even when this value is false. - /// - [JsonPropertyName("isPdfValid")] - public bool IsPdfValid { get; set; } = false; - - /// - /// Indicates whether the embedded XML invoice data is valid according to the applicable standard. - /// - [JsonPropertyName("isXmlValid")] - public bool IsXmlValid { get; set; } = false; -} +using System.Text.Json.Serialization; + +namespace Docentric.EInvoice.Validator.RestServer.Contracts; + +/// +/// The result of a ZuGFeRD file validation operation. +/// +public sealed class PdfFileValidationResponse : FileValidationResponse +{ + /// + /// Indicates whether the digital signature of the file is valid. + /// + [JsonPropertyName("isSignatureValid")] + public bool IsSignatureValid { get; set; } = false; + + /// + /// Indicates whether the PDF part of the hybrid document is valid (e.g. PDF/A-3 conformant). + /// Note: under ZuGFeRD rule BR-FX-DE-03, PDF/A compliance errors are treated as warnings (not fatal) + /// when both buyer and seller are in Germany, so may be + /// true even when this value is false. + /// + [JsonPropertyName("isPdfValid")] + public bool IsPdfValid { get; set; } = false; + + /// + /// Indicates whether the embedded XML invoice data is valid according to the applicable standard. + /// + [JsonPropertyName("isXmlValid")] + public bool IsXmlValid { get; set; } = false; +} diff --git a/src/Server/Contracts/ServerHealthResponse.cs b/src/Server/Contracts/ServerHealthResponse.cs index b20f5b8..f0696fa 100644 --- a/src/Server/Contracts/ServerHealthResponse.cs +++ b/src/Server/Contracts/ServerHealthResponse.cs @@ -1,39 +1,39 @@ -using System.Text.Json.Serialization; - -namespace Docentric.EInvoice.Validator.RestServer.Contracts; - -/// -/// Server health status information. -/// -public class ServerHealthResponse -{ - /// - /// Overall server health status. True when all required dependencies are available. - /// - [JsonPropertyName("healthy")] - public bool Healthy => JavaPresent && MustangCliPresent; - - /// - /// Indicates whether Java runtime is available. - /// - [JsonPropertyName("javaPresent")] - public bool JavaPresent { get; set; } - - /// - /// Java runtime version. - /// - [JsonPropertyName("javaVersion")] - public string? JavaVersion { get; set; } - - /// - /// Indicates whether Mustang CLI tool is available. - /// - [JsonPropertyName("mustangCliPresent")] - public bool MustangCliPresent { get; set; } - - /// - /// Mustang CLI tool version. - /// - [JsonPropertyName("mustangCliVersion")] - public string? MustangCliVersion { get; set; } -} +using System.Text.Json.Serialization; + +namespace Docentric.EInvoice.Validator.RestServer.Contracts; + +/// +/// Server health status information. +/// +public class ServerHealthResponse +{ + /// + /// Overall server health status. True when all required dependencies are available. + /// + [JsonPropertyName("healthy")] + public bool Healthy => JavaPresent && MustangCliPresent; + + /// + /// Indicates whether Java runtime is available. + /// + [JsonPropertyName("javaPresent")] + public bool JavaPresent { get; set; } + + /// + /// Java runtime version. + /// + [JsonPropertyName("javaVersion")] + public string? JavaVersion { get; set; } + + /// + /// Indicates whether Mustang CLI tool is available. + /// + [JsonPropertyName("mustangCliPresent")] + public bool MustangCliPresent { get; set; } + + /// + /// Mustang CLI tool version. + /// + [JsonPropertyName("mustangCliVersion")] + public string? MustangCliVersion { get; set; } +} diff --git a/src/Server/Endpoints/HealthEndpoints.cs b/src/Server/Endpoints/HealthEndpoints.cs index 171c803..ac00c2e 100644 --- a/src/Server/Endpoints/HealthEndpoints.cs +++ b/src/Server/Endpoints/HealthEndpoints.cs @@ -1,78 +1,78 @@ -using Docentric.EInvoice.Validator.RestServer.Configuration; -using Docentric.EInvoice.Validator.RestServer.Contracts; -using Docentric.EInvoice.Validator.RestServer.Services; - -namespace Docentric.EInvoice.Validator.RestServer.Endpoints; - -/// -/// Provides health check endpoints for monitoring server status and dependencies. -/// -public static class HealthEndpoints -{ - /// - /// Maps health check endpoints to the application's routing configuration. - /// - /// The endpoint route builder. - /// The endpoint route builder for chaining. - public static IEndpointRouteBuilder MapHealthEndpoints(this IEndpointRouteBuilder endpoints) - { - endpoints.MapGet("api/health", HealthHandler) - .WithTags("Server health") - .Produces(StatusCodes.Status200OK, "application/json") - .Produces(StatusCodes.Status404NotFound, "application/json"); - - return endpoints; - } - - /// - /// Handles health check requests by verifying the availability of required dependencies. - /// - /// - /// Returns 200 OK with health status when all dependencies are available, - /// or 404 Not Found when dependencies are missing. - /// - private static async Task HealthHandler(JavaService javaService, MustangCliService mustangCliService, CancellationToken cancellationToken) - { - (bool javaPresent, string? javaVersion) = await CheckJavaVersionAsync(javaService, cancellationToken); - (bool mustangPresent, string? mustangVersion) = await CheckMustangCliVersionAsync(mustangCliService, cancellationToken); - - ServerHealthResponse response = new() - { - JavaPresent = javaPresent, - JavaVersion = javaVersion ?? string.Empty, - MustangCliPresent = mustangPresent, - MustangCliVersion = mustangVersion - }; - - return Results.Ok(response); - } - - /// - /// Checks if Java runtime is available and retrieves its version. - /// - /// A tuple indicating Java presence and version information. - private static async Task<(bool present, string? version)> CheckJavaVersionAsync(JavaService javaService, CancellationToken cancellationToken) - { - try - { - JavaInfoResult javaInfo = await javaService.GetJavaInfoAsync(cancellationToken); - return (javaInfo.IsAvailable, javaInfo.RuntimeVersion); - } - catch - { - return (false, null); - } - } - - /// - /// Checks if Mustang CLI tool is available and functional. - /// - /// True if Mustang CLI is available, false otherwise. - /// - private static async Task<(bool present, string? version)> CheckMustangCliVersionAsync(MustangCliService mustangCliService, CancellationToken cancellationToken) - { - bool isAvailable = await mustangCliService.CheckAvailabilityAsync(cancellationToken); - - return (isAvailable, isAvailable ? MustangCliService.MustangCliVersion : null); - } -} +using Docentric.EInvoice.Validator.RestServer.Configuration; +using Docentric.EInvoice.Validator.RestServer.Contracts; +using Docentric.EInvoice.Validator.RestServer.Services; + +namespace Docentric.EInvoice.Validator.RestServer.Endpoints; + +/// +/// Provides health check endpoints for monitoring server status and dependencies. +/// +public static class HealthEndpoints +{ + /// + /// Maps health check endpoints to the application's routing configuration. + /// + /// The endpoint route builder. + /// The endpoint route builder for chaining. + public static IEndpointRouteBuilder MapHealthEndpoints(this IEndpointRouteBuilder endpoints) + { + endpoints.MapGet("api/health", HealthHandler) + .WithTags("Server health") + .WithSummary("Checks server health") + .WithDescription("Verifies that the required dependencies (Java and the Mustang CLI jar) are available. Always returns 200; check the presence flags in the response to detect a missing dependency.") + .Produces(StatusCodes.Status200OK, "application/json"); + + return endpoints; + } + + /// + /// Handles health check requests by verifying the availability of required dependencies. + /// + /// + /// Always returns 200 OK with the health status; missing dependencies are reported in the response body. + /// + private static async Task HealthHandler(JavaService javaService, MustangCliService mustangCliService, CancellationToken cancellationToken) + { + (bool javaPresent, string? javaVersion) = await CheckJavaVersionAsync(javaService, cancellationToken); + (bool mustangPresent, string? mustangVersion) = await CheckMustangCliVersionAsync(mustangCliService, cancellationToken); + + ServerHealthResponse response = new() + { + JavaPresent = javaPresent, + JavaVersion = javaVersion ?? string.Empty, + MustangCliPresent = mustangPresent, + MustangCliVersion = mustangVersion + }; + + return Results.Ok(response); + } + + /// + /// Checks if Java runtime is available and retrieves its version. + /// + /// A tuple indicating Java presence and version information. + private static async Task<(bool present, string? version)> CheckJavaVersionAsync(JavaService javaService, CancellationToken cancellationToken) + { + try + { + JavaInfoResult javaInfo = await javaService.GetJavaInfoAsync(cancellationToken); + return (javaInfo.IsAvailable, javaInfo.RuntimeVersion); + } + catch + { + return (false, null); + } + } + + /// + /// Checks if Mustang CLI tool is available and functional. + /// + /// True if Mustang CLI is available, false otherwise. + /// + private static async Task<(bool present, string? version)> CheckMustangCliVersionAsync(MustangCliService mustangCliService, CancellationToken cancellationToken) + { + bool isAvailable = await mustangCliService.CheckAvailabilityAsync(cancellationToken); + + return (isAvailable, isAvailable ? mustangCliService.GetMustangCliVersion() : null); + } +} diff --git a/src/Server/Endpoints/PdfEndpoints.cs b/src/Server/Endpoints/PdfEndpoints.cs index 932f1e4..83eef91 100644 --- a/src/Server/Endpoints/PdfEndpoints.cs +++ b/src/Server/Endpoints/PdfEndpoints.cs @@ -1,225 +1,229 @@ -using System.Xml; - -using Docentric.EInvoice.Validator.RestServer.Configuration; -using Docentric.EInvoice.Validator.RestServer.Contracts; -using Docentric.EInvoice.Validator.RestServer.IO; -using Docentric.EInvoice.Validator.RestServer.Services; - -using Microsoft.AspNetCore.Mvc; - -namespace Docentric.EInvoice.Validator.RestServer.Endpoints; - -/// -/// Provides extension methods for mapping PDF-related API endpoints to an ASP.NET Core application's routing -/// configuration. -/// -public static class PdfEndpoints -{ - /// - /// Maps PDF-related endpoints to the specified endpoint route builder. - /// - /// The to add the routes to. - /// The so that additional calls can be chained. - public static IEndpointRouteBuilder MapPdfEndpoints(this IEndpointRouteBuilder endpoints) - { - RouteGroupBuilder group = endpoints - .MapGroup("/api/pdf") - .WithTags("ZuGFeRD PDF"); - - group.MapPost("/validate-zugferd", ValidateZuGFeRDPdfHandler) - .Accepts("multipart/form-data") - .DisableAntiforgery() - .Produces(StatusCodes.Status200OK, "application/json") - .Produces(StatusCodes.Status400BadRequest, "application/json") - .Produces(StatusCodes.Status408RequestTimeout) - .WithRequestTimeout(Constants.RequestTimeouts.LongRunningPolicy); - - group.MapPost("/extract-zugferd-xml", ExtractZuGFeRDXmlHandler) - .Accepts("multipart/form-data") - .DisableAntiforgery() - .Produces(StatusCodes.Status200OK, "application/json") - .Produces(StatusCodes.Status400BadRequest, "application/json") - .Produces(StatusCodes.Status408RequestTimeout) - .WithRequestTimeout(Constants.RequestTimeouts.LongRunningPolicy); - - return endpoints; - } - - /// - /// Handles validation of ZuGFeRD PDF files using the Mustang CLI validator. - /// - /// The HTTP request containing the uploaded file. - /// The Mustang CLI service for executing commands. - /// The file upload request with the PDF file to validate. - /// The logger factory for creating loggers. - /// The cancellation token for managing task cancellation. - /// - /// An containing a with validation results, - /// including whether the file is valid, signature status, and a detailed validation report. - /// - /// - /// This endpoint validates ZuGFeRD PDF files by invoking the Mustang CLI tool as an external Java process. - /// The validation report is returned as XML format within the response. - /// - private static async Task ValidateZuGFeRDPdfHandler(HttpRequest request, MustangCliService mustangCliService, [FromForm] FileUploadRequest uploadedFile, ILoggerFactory loggerFactory, CancellationToken cancellationToken) - { - ILogger logger = loggerFactory.CreateLogger(nameof(ValidateZuGFeRDPdfHandler)); - if (!request.HasFormContentType) - return Results.BadRequest(new PdfFileValidationResponse - { - ErrorCode = ErrorCode.InvalidRequest, - ErrorMessage = "The request content type must be 'multipart/form-data'." - }); - - if (!await FileTypeValidator.IsPdfAsync(uploadedFile.File, cancellationToken)) - return Results.BadRequest(new PdfFileValidationResponse - { - ErrorCode = ErrorCode.InvalidFileFormat, - ErrorMessage = "The uploaded file is not a valid PDF file." - }); - - try - { - await using TemporaryUploadedFile temporaryFile = await TemporaryUploadedFile.CreateAsync(uploadedFile, cancellationToken); - - MustangCliResult mustangCliResult = await mustangCliService.ValidateAsync(temporaryFile.FilePath, cancellationToken); - - if (mustangCliResult.ProcessStartFailed) - { - return Results.InternalServerError(new PdfFileValidationResponse - { - ErrorCode = ErrorCode.InternalServerError, - ErrorMessage = mustangCliResult.ErrorMessage - }); - } - - MustangValidationStatuses statuses = MustangValidationStatuses.Unknown; - try - { - statuses = MustangValidationReportParser.Parse(mustangCliResult.StandardOutput); - } - catch (XmlException ex) - { - /* keep status=unknown on parse issues */ - logger.LogError(ex, "Failed to parse Mustang CLI XML output"); - } - - int statusCode = StatusCodes.Status400BadRequest; - if (mustangCliResult.ExitCode == (int)ErrorCode.Success) - statusCode = StatusCodes.Status200OK; - - var result = new PdfFileValidationResponse - { - ErrorCode = (ErrorCode)mustangCliResult.ExitCode, - IsValid = statuses.Overall == "valid", - IsPdfValid = statuses.Pdf == "valid", - IsXmlValid = statuses.Xml == "valid", - IsSignatureValid = mustangCliResult.StandardOutput.Contains("valid"), - ValidationReport = mustangCliResult.StandardOutput, - DiagnosticsErrorMessage = mustangCliResult.ExitCode != (int)ErrorCode.Success ? mustangCliResult.StandardError : null - }; - - return Results.Json(result, statusCode: statusCode); - } - catch (Exception ex) - { - logger.LogError(ex, "Failed to validate ZuGFeRD PDF file"); - return Results.InternalServerError(new PdfFileValidationResponse - { - ErrorCode = ErrorCode.InvalidRequest, - ErrorMessage = $"Failed to process the uploaded file. Error: {ex.Message}" - }); - } - } - - /// - /// Handles extraction of embedded ZuGFeRD XML invoice data from PDF files using the Mustang CLI tool. - /// - /// The HTTP request containing the uploaded file. - /// The Mustang CLI service for executing commands. - /// The file upload request with the PDF file to extract XML from. - /// The logger factory for creating loggers. - /// The cancellation token for managing task cancellation. - /// - /// An containing an with the extracted XML content - /// if successful, or an error message if extraction fails. - /// - /// - /// This endpoint extracts the embedded ZuGFeRD XML invoice data from a PDF file by invoking the Mustang CLI tool - /// as an external Java process. The extracted XML file is automatically cleaned up after reading. - /// - private static async Task ExtractZuGFeRDXmlHandler(HttpRequest request, MustangCliService mustangCliService, [FromForm] FileUploadRequest uploadedFile, ILoggerFactory loggerFactory, CancellationToken cancellationToken) - { - ILogger logger = loggerFactory.CreateLogger(nameof(ExtractZuGFeRDXmlHandler)); - if (!request.HasFormContentType) - return Results.BadRequest(new ExtractXmlFromPdfResponse - { - ErrorCode = ErrorCode.InvalidRequest, - ErrorMessage = "The request content type must be 'multipart/form-data'." - }); - - if (!await FileTypeValidator.IsPdfAsync(uploadedFile.File, cancellationToken)) - return Results.BadRequest(new ExtractXmlFromPdfResponse - { - ErrorCode = ErrorCode.InvalidFileFormat, - ErrorMessage = "The uploaded file is not a valid PDF file." - }); - - string? outputXml = null; - - try - { - await using TemporaryUploadedFile temporaryFile = await TemporaryUploadedFile.CreateAsync(uploadedFile, cancellationToken); - - outputXml = Path.ChangeExtension(temporaryFile.FilePath, ".xml"); - - MustangCliResult mustangCliResult = await mustangCliService.ExtractXmlAsync(temporaryFile.FilePath, outputXml, cancellationToken); - - if (mustangCliResult.ProcessStartFailed) - { - return Results.BadRequest(new ExtractXmlFromPdfResponse - { - ErrorCode = ErrorCode.InternalServerError, - ErrorMessage = mustangCliResult.ErrorMessage - }); - } - - int statusCode = StatusCodes.Status400BadRequest; - if (mustangCliResult.ExitCode == 0) - statusCode = StatusCodes.Status200OK; - - return Results.Json(new ExtractXmlFromPdfResponse - { - ErrorCode = (ErrorCode)mustangCliResult.ExitCode, - DiagnosticsErrorMessage = mustangCliResult.ExitCode != (int)ErrorCode.Success ? mustangCliResult.StandardError : null, - Xml = mustangCliResult.ExitCode == 0 && File.Exists(outputXml) - ? await File.ReadAllTextAsync(outputXml, cancellationToken) - : null, - }, statusCode: statusCode); - } - catch (Exception ex) - { - logger.LogError(ex, "Failed to extract XML from ZuGFeRD PDF file"); - return Results.InternalServerError(new ExtractXmlFromPdfResponse - { - ErrorCode = ErrorCode.InvalidRequest, - ErrorMessage = $"Failed to process the uploaded file. Error: {ex.Message}" - }); - } - finally - { - if (outputXml != null && File.Exists(outputXml)) - { - try - { - File.Delete(outputXml); - } - catch(Exception ex) - { - logger.LogError(ex, "Error during cleanup of temporary XML file on the path: {Path}.", outputXml); - // Ignore any errors during cleanup - } - } - } - } -} +using System.Xml; + +using Docentric.EInvoice.Validator.RestServer.Configuration; +using Docentric.EInvoice.Validator.RestServer.Contracts; +using Docentric.EInvoice.Validator.RestServer.IO; +using Docentric.EInvoice.Validator.RestServer.Services; + +using Microsoft.AspNetCore.Mvc; + +namespace Docentric.EInvoice.Validator.RestServer.Endpoints; + +/// +/// Provides extension methods for mapping PDF-related API endpoints to an ASP.NET Core application's routing +/// configuration. +/// +public static class PdfEndpoints +{ + /// + /// Maps PDF-related endpoints to the specified endpoint route builder. + /// + /// The to add the routes to. + /// The so that additional calls can be chained. + public static IEndpointRouteBuilder MapPdfEndpoints(this IEndpointRouteBuilder endpoints) + { + RouteGroupBuilder group = endpoints + .MapGroup("/api/pdf") + .WithTags("ZuGFeRD PDF"); + + group.MapPost("/validate-zugferd", ValidateZuGFeRDPdfHandler) + .WithSummary("Validates a ZuGFeRD PDF file") + .WithDescription("Validates the uploaded ZuGFeRD or Factur-X PDF, including its embedded XML, with Mustang CLI and returns the validation report.") + .Accepts("multipart/form-data") + .DisableAntiforgery() + .Produces(StatusCodes.Status200OK, "application/json") + .Produces(StatusCodes.Status400BadRequest, "application/json") + .Produces(StatusCodes.Status408RequestTimeout) + .WithRequestTimeout(Constants.RequestTimeouts.LongRunningPolicy); + + group.MapPost("/extract-zugferd-xml", ExtractZuGFeRDXmlHandler) + .WithSummary("Extracts the XML from a ZuGFeRD PDF file") + .WithDescription("Extracts the embedded Factur-X or ZuGFeRD XML invoice from the uploaded PDF using Mustang CLI.") + .Accepts("multipart/form-data") + .DisableAntiforgery() + .Produces(StatusCodes.Status200OK, "application/json") + .Produces(StatusCodes.Status400BadRequest, "application/json") + .Produces(StatusCodes.Status408RequestTimeout) + .WithRequestTimeout(Constants.RequestTimeouts.LongRunningPolicy); + + return endpoints; + } + + /// + /// Handles validation of ZuGFeRD PDF files using the Mustang CLI validator. + /// + /// The HTTP request containing the uploaded file. + /// The Mustang CLI service for executing commands. + /// The file upload request with the PDF file to validate. + /// The logger factory for creating loggers. + /// The cancellation token for managing task cancellation. + /// + /// An containing a with validation results, + /// including whether the file is valid, signature status, and a detailed validation report. + /// + /// + /// This endpoint validates ZuGFeRD PDF files by invoking the Mustang CLI tool as an external Java process. + /// The validation report is returned as XML format within the response. + /// + private static async Task ValidateZuGFeRDPdfHandler(HttpRequest request, MustangCliService mustangCliService, [FromForm] FileUploadRequest uploadedFile, ILoggerFactory loggerFactory, CancellationToken cancellationToken) + { + ILogger logger = loggerFactory.CreateLogger(nameof(ValidateZuGFeRDPdfHandler)); + if (!request.HasFormContentType) + return Results.BadRequest(new PdfFileValidationResponse + { + ErrorCode = ErrorCode.InvalidRequest, + ErrorMessage = "The request content type must be 'multipart/form-data'." + }); + + if (!await FileTypeValidator.IsPdfAsync(uploadedFile.File, cancellationToken)) + return Results.BadRequest(new PdfFileValidationResponse + { + ErrorCode = ErrorCode.InvalidFileFormat, + ErrorMessage = "The uploaded file is not a valid PDF file." + }); + + try + { + await using TemporaryUploadedFile temporaryFile = await TemporaryUploadedFile.CreateAsync(uploadedFile, cancellationToken); + + MustangCliResult mustangCliResult = await mustangCliService.ValidateAsync(temporaryFile.FilePath, cancellationToken); + + if (mustangCliResult.ProcessStartFailed) + { + return Results.InternalServerError(new PdfFileValidationResponse + { + ErrorCode = ErrorCode.InternalServerError, + ErrorMessage = mustangCliResult.ErrorMessage + }); + } + + MustangValidationStatuses statuses = MustangValidationStatuses.Unknown; + try + { + statuses = MustangValidationReportParser.Parse(mustangCliResult.StandardOutput); + } + catch (XmlException ex) + { + /* keep status=unknown on parse issues */ + logger.LogError(ex, "Failed to parse Mustang CLI XML output"); + } + + int statusCode = StatusCodes.Status400BadRequest; + if (mustangCliResult.ExitCode == (int)ErrorCode.Success) + statusCode = StatusCodes.Status200OK; + + var result = new PdfFileValidationResponse + { + ErrorCode = (ErrorCode)mustangCliResult.ExitCode, + IsValid = statuses.Overall == "valid", + IsPdfValid = statuses.Pdf == "valid", + IsXmlValid = statuses.Xml == "valid", + IsSignatureValid = statuses.Signature == "valid", + ValidationReport = mustangCliResult.StandardOutput, + DiagnosticsErrorMessage = mustangCliResult.ExitCode != (int)ErrorCode.Success ? mustangCliResult.StandardError : null + }; + + return Results.Json(result, statusCode: statusCode); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to validate ZuGFeRD PDF file"); + return Results.InternalServerError(new PdfFileValidationResponse + { + ErrorCode = ErrorCode.InvalidRequest, + ErrorMessage = $"Failed to process the uploaded file. Error: {ex.Message}" + }); + } + } + + /// + /// Handles extraction of embedded ZuGFeRD XML invoice data from PDF files using the Mustang CLI tool. + /// + /// The HTTP request containing the uploaded file. + /// The Mustang CLI service for executing commands. + /// The file upload request with the PDF file to extract XML from. + /// The logger factory for creating loggers. + /// The cancellation token for managing task cancellation. + /// + /// An containing an with the extracted XML content + /// if successful, or an error message if extraction fails. + /// + /// + /// This endpoint extracts the embedded ZuGFeRD XML invoice data from a PDF file by invoking the Mustang CLI tool + /// as an external Java process. The extracted XML file is automatically cleaned up after reading. + /// + private static async Task ExtractZuGFeRDXmlHandler(HttpRequest request, MustangCliService mustangCliService, [FromForm] FileUploadRequest uploadedFile, ILoggerFactory loggerFactory, CancellationToken cancellationToken) + { + ILogger logger = loggerFactory.CreateLogger(nameof(ExtractZuGFeRDXmlHandler)); + if (!request.HasFormContentType) + return Results.BadRequest(new ExtractXmlFromPdfResponse + { + ErrorCode = ErrorCode.InvalidRequest, + ErrorMessage = "The request content type must be 'multipart/form-data'." + }); + + if (!await FileTypeValidator.IsPdfAsync(uploadedFile.File, cancellationToken)) + return Results.BadRequest(new ExtractXmlFromPdfResponse + { + ErrorCode = ErrorCode.InvalidFileFormat, + ErrorMessage = "The uploaded file is not a valid PDF file." + }); + + string? outputXml = null; + + try + { + await using TemporaryUploadedFile temporaryFile = await TemporaryUploadedFile.CreateAsync(uploadedFile, cancellationToken); + + outputXml = Path.ChangeExtension(temporaryFile.FilePath, ".xml"); + + MustangCliResult mustangCliResult = await mustangCliService.ExtractXmlAsync(temporaryFile.FilePath, outputXml, cancellationToken); + + if (mustangCliResult.ProcessStartFailed) + { + return Results.BadRequest(new ExtractXmlFromPdfResponse + { + ErrorCode = ErrorCode.InternalServerError, + ErrorMessage = mustangCliResult.ErrorMessage + }); + } + + int statusCode = StatusCodes.Status400BadRequest; + if (mustangCliResult.ExitCode == 0) + statusCode = StatusCodes.Status200OK; + + return Results.Json(new ExtractXmlFromPdfResponse + { + ErrorCode = (ErrorCode)mustangCliResult.ExitCode, + DiagnosticsErrorMessage = mustangCliResult.ExitCode != (int)ErrorCode.Success ? mustangCliResult.StandardError : null, + Xml = mustangCliResult.ExitCode == 0 && File.Exists(outputXml) + ? await File.ReadAllTextAsync(outputXml, cancellationToken) + : null, + }, statusCode: statusCode); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to extract XML from ZuGFeRD PDF file"); + return Results.InternalServerError(new ExtractXmlFromPdfResponse + { + ErrorCode = ErrorCode.InvalidRequest, + ErrorMessage = $"Failed to process the uploaded file. Error: {ex.Message}" + }); + } + finally + { + if (outputXml != null && File.Exists(outputXml)) + { + try + { + File.Delete(outputXml); + } + catch (Exception ex) + { + logger.LogError(ex, "Error during cleanup of temporary XML file on the path: {Path}.", outputXml); + // Ignore any errors during cleanup + } + } + } + } +} diff --git a/src/Server/Endpoints/XmlEndpoints.cs b/src/Server/Endpoints/XmlEndpoints.cs index be84fbd..5837359 100644 --- a/src/Server/Endpoints/XmlEndpoints.cs +++ b/src/Server/Endpoints/XmlEndpoints.cs @@ -1,227 +1,233 @@ -using System.Xml.Linq; - -using Docentric.EInvoice.Validator.RestServer.Configuration; -using Docentric.EInvoice.Validator.RestServer.Contracts; -using Docentric.EInvoice.Validator.RestServer.IO; -using Docentric.EInvoice.Validator.RestServer.Services; - -using Microsoft.AspNetCore.Mvc; - -namespace Docentric.EInvoice.Validator.RestServer.Endpoints; - -/// -/// Provides extension methods for mapping XML-related API endpoints to an ASP.NET Core application's routing -/// configuration. -/// -public static class XmlEndpoints -{ - /// - /// Maps XML-related endpoints to the specified endpoint route builder. - /// - /// The to add the routes to. - /// The so that additional calls can be chained. - public static IEndpointRouteBuilder MapXmlEndpoints(this IEndpointRouteBuilder endpoints) - { - RouteGroupBuilder group = endpoints - .MapGroup("/api/xml") - .WithTags("Factur-X or UBL XML"); - - group.MapPost("/validate", ValidateXmlHandler) - .Accepts("multipart/form-data") - .DisableAntiforgery() - .Produces(StatusCodes.Status200OK, "application/json") - .Produces(StatusCodes.Status400BadRequest, "application/json") - .Produces(StatusCodes.Status408RequestTimeout) - .WithRequestTimeout(Constants.RequestTimeouts.LongRunningPolicy); - - group.MapPost("/convert-to-pdf", ConvertXmlToPdfHandler) - .Accepts("multipart/form-data") - .DisableAntiforgery() - .Produces(StatusCodes.Status200OK, "application/json") - .Produces(StatusCodes.Status400BadRequest, "application/json") - .Produces(StatusCodes.Status408RequestTimeout) - .WithRequestTimeout(Constants.RequestTimeouts.LongRunningPolicy); - - return endpoints; - } - - /// - /// Handles validation of Factur-X or UBL XML files using the Mustang CLI validator. - /// - /// The HTTP request containing the uploaded file. - /// The Mustang CLI service for executing commands. - /// The file upload request with the XML file to validate. - /// The logger factory for creating loggers. - /// The cancellation token for managing task cancellation. - /// - /// An containing a with validation results, - /// including whether the file is valid and a detailed validation report. - /// - /// - /// This endpoint validates Factur-X or UBL XML files by invoking the Mustang CLI tool as an external Java process. - /// The validation report is returned as XML format within the response. - /// - private static async Task ValidateXmlHandler(HttpRequest request, MustangCliService mustangCliService, [FromForm] FileUploadRequest uploadedFile, ILoggerFactory loggerFactory, CancellationToken cancellationToken) - { - Thread.Sleep(TimeSpan.FromSeconds(10)); - ILogger logger = loggerFactory.CreateLogger(nameof(XmlEndpoints)); - if (!request.HasFormContentType) - return Results.BadRequest(new FileValidationResponse - { - ErrorCode = ErrorCode.InvalidRequest, - ErrorMessage = "The request content type must be 'multipart/form-data'." - }); - - if (!await FileTypeValidator.IsXmlAsync(uploadedFile.File, cancellationToken)) - return Results.BadRequest(new FileValidationResponse - { - ErrorCode = ErrorCode.InvalidFileFormat, - ErrorMessage = "The uploaded file is not a valid XML file." - }); - - try - { - await using TemporaryUploadedFile temporaryFile = await TemporaryUploadedFile.CreateAsync(uploadedFile, cancellationToken); - - MustangCliResult mustangCliResult = await mustangCliService.ValidateAsync(temporaryFile.FilePath, cancellationToken); - - if (mustangCliResult.ProcessStartFailed) - { - return Results.BadRequest(new FileValidationResponse - { - ErrorCode = ErrorCode.InternalServerError, - ErrorMessage = mustangCliResult.ErrorMessage - }); - } - - string status = "unknown"; - try - { - if (!string.IsNullOrWhiteSpace(mustangCliResult.StandardOutput)) - { - var mustangCliXmlResult = XDocument.Parse(mustangCliResult.StandardOutput); - XElement? summary = mustangCliXmlResult.Root?.Descendants("summary").FirstOrDefault(); - if (summary?.Attribute("status") is { } attributeValue) - status = attributeValue.Value; - } - } - catch(Exception ex) - { - /* keep status=unknown on parse issues */ - logger.LogError(ex, "Failed to parse Mustang CLI XML output"); - } - - int statusCode = StatusCodes.Status400BadRequest; - if (mustangCliResult.ExitCode == (int)ErrorCode.Success) - statusCode = StatusCodes.Status200OK; - - return Results.Json(new FileValidationResponse - { - ErrorCode = (ErrorCode)mustangCliResult.ExitCode, - IsValid = status == "valid", - ValidationReport = mustangCliResult.StandardOutput, - DiagnosticsErrorMessage = mustangCliResult.ExitCode != (int)ErrorCode.Success ? mustangCliResult.StandardError : null - }, statusCode: statusCode); - } - catch (Exception ex) - { - logger.LogError(ex, "Failed to process the uploaded file."); - return Results.BadRequest(new FileValidationResponse - { - ErrorCode = ErrorCode.InvalidRequest, - ErrorMessage = $"Failed to process the uploaded file. Error: {ex.Message}" - }); - } - } - - /// - /// Handles conversion of Factur-X or UBL XML files to PDF documents using the Mustang CLI tool. - /// - /// The HTTP request containing the uploaded file. - /// The Mustang CLI service for executing commands. - /// The file upload request with the XML file to convert. - /// The logger factory for creating loggers. - /// The cancellation token for managing task cancellation. - /// - /// An containing a with the generated PDF content - /// if successful, or an error message if conversion fails. - /// - /// - /// This endpoint converts Factur-X or UBL XML invoice data to a PDF document by invoking the Mustang CLI tool - /// as an external Java process. The generated PDF file is automatically cleaned up after reading. - /// - private static async Task ConvertXmlToPdfHandler(HttpRequest request, MustangCliService mustangCliService, [FromForm] FileUploadRequest uploadedFile, ILoggerFactory loggerFactory, CancellationToken cancellationToken) - { - ILogger logger = loggerFactory.CreateLogger(nameof(XmlEndpoints)); - if (!request.HasFormContentType) - return Results.BadRequest(new ConvertXmlToPdfResponse - { - ErrorCode = ErrorCode.InvalidRequest, - ErrorMessage = "The request content type must be 'multipart/form-data'." - }); - - if (!await FileTypeValidator.IsXmlAsync(uploadedFile.File, cancellationToken)) - return Results.BadRequest(new ConvertXmlToPdfResponse - { - ErrorCode = ErrorCode.InvalidFileFormat, - ErrorMessage = "The uploaded file is not a valid XML file." - }); - - string? outputPdf = null; - - try - { - await using TemporaryUploadedFile temporaryFile = await TemporaryUploadedFile.CreateAsync(uploadedFile, cancellationToken); - - outputPdf = Path.ChangeExtension(temporaryFile.FilePath, ".pdf"); - - MustangCliResult mustangCliResult = await mustangCliService.ConvertXmlToPdfAsync(temporaryFile.FilePath, outputPdf, cancellationToken); - - if (mustangCliResult.ProcessStartFailed) - { - return Results.BadRequest(new ConvertXmlToPdfResponse - { - ErrorCode = ErrorCode.InternalServerError, - ErrorMessage = mustangCliResult.ErrorMessage - }); - } - - int statusCode = StatusCodes.Status400BadRequest; - if (mustangCliResult.ExitCode == 0) - statusCode = StatusCodes.Status200OK; - - return Results.Json(new ConvertXmlToPdfResponse - { - ErrorCode = (ErrorCode)mustangCliResult.ExitCode, - DiagnosticsErrorMessage = mustangCliResult.ExitCode != (int)ErrorCode.Success ? mustangCliResult.StandardError : null, - Pdf = mustangCliResult.ExitCode == 0 && File.Exists(outputPdf) - ? await File.ReadAllBytesAsync(outputPdf, cancellationToken) - : null, - }, statusCode: statusCode); - } - catch (Exception ex) - { - logger.LogError(ex, "Failed to process the uploaded file."); - return Results.BadRequest(new ConvertXmlToPdfResponse - { - ErrorCode = ErrorCode.InvalidRequest, - ErrorMessage = $"Failed to process the uploaded file. Error: {ex.Message}" - }); - } - finally - { - if (outputPdf != null && File.Exists(outputPdf)) - { - try - { - File.Delete(outputPdf); - } - catch(Exception ex) - { - logger.LogError(ex, "Error during cleanup of temporary PDF file on the path: {Path}.", outputPdf); - // Ignore any errors during cleanup - } - } - } - } -} +using System.ComponentModel; +using System.Xml.Linq; + +using Docentric.EInvoice.Validator.RestServer.Configuration; +using Docentric.EInvoice.Validator.RestServer.Contracts; +using Docentric.EInvoice.Validator.RestServer.IO; +using Docentric.EInvoice.Validator.RestServer.Services; + +using Microsoft.AspNetCore.Mvc; + +namespace Docentric.EInvoice.Validator.RestServer.Endpoints; + +/// +/// Provides extension methods for mapping XML-related API endpoints to an ASP.NET Core application's routing +/// configuration. +/// +public static class XmlEndpoints +{ + /// + /// Maps XML-related endpoints to the specified endpoint route builder. + /// + /// The to add the routes to. + /// The so that additional calls can be chained. + public static IEndpointRouteBuilder MapXmlEndpoints(this IEndpointRouteBuilder endpoints) + { + RouteGroupBuilder group = endpoints + .MapGroup("/api/xml") + .WithTags("Factur-X or UBL XML"); + + group.MapPost("/validate", ValidateXmlHandler) + .WithSummary("Validates a Factur-X or UBL XML file") + .WithDescription("Validates the uploaded Factur-X or UBL XML invoice with Mustang CLI and returns the validation report.") + .Accepts("multipart/form-data") + .DisableAntiforgery() + .Produces(StatusCodes.Status200OK, "application/json") + .Produces(StatusCodes.Status400BadRequest, "application/json") + .Produces(StatusCodes.Status408RequestTimeout) + .WithRequestTimeout(Constants.RequestTimeouts.LongRunningPolicy); + + group.MapPost("/convert-to-pdf", ConvertXmlToPdfHandler) + .WithSummary("Converts a Factur-X or UBL XML file to PDF") + .WithDescription("Converts the uploaded Factur-X or UBL XML invoice to a human-readable PDF using Mustang CLI. The optional language query parameter selects the output language.") + .Accepts("multipart/form-data") + .DisableAntiforgery() + .Produces(StatusCodes.Status200OK, "application/json") + .Produces(StatusCodes.Status400BadRequest, "application/json") + .Produces(StatusCodes.Status408RequestTimeout) + .WithRequestTimeout(Constants.RequestTimeouts.LongRunningPolicy); + + return endpoints; + } + + /// + /// Handles validation of Factur-X or UBL XML files using the Mustang CLI validator. + /// + /// The HTTP request containing the uploaded file. + /// The Mustang CLI service for executing commands. + /// The file upload request with the XML file to validate. + /// The logger factory for creating loggers. + /// The cancellation token for managing task cancellation. + /// + /// An containing a with validation results, + /// including whether the file is valid and a detailed validation report. + /// + /// + /// This endpoint validates Factur-X or UBL XML files by invoking the Mustang CLI tool as an external Java process. + /// The validation report is returned as XML format within the response. + /// + private static async Task ValidateXmlHandler(HttpRequest request, MustangCliService mustangCliService, [FromForm] FileUploadRequest uploadedFile, ILoggerFactory loggerFactory, CancellationToken cancellationToken) + { + ILogger logger = loggerFactory.CreateLogger(nameof(XmlEndpoints)); + if (!request.HasFormContentType) + return Results.BadRequest(new FileValidationResponse + { + ErrorCode = ErrorCode.InvalidRequest, + ErrorMessage = "The request content type must be 'multipart/form-data'." + }); + + if (!await FileTypeValidator.IsXmlAsync(uploadedFile.File, cancellationToken)) + return Results.BadRequest(new FileValidationResponse + { + ErrorCode = ErrorCode.InvalidFileFormat, + ErrorMessage = "The uploaded file is not a valid XML file." + }); + + try + { + await using TemporaryUploadedFile temporaryFile = await TemporaryUploadedFile.CreateAsync(uploadedFile, cancellationToken); + + MustangCliResult mustangCliResult = await mustangCliService.ValidateAsync(temporaryFile.FilePath, cancellationToken); + + if (mustangCliResult.ProcessStartFailed) + { + return Results.BadRequest(new FileValidationResponse + { + ErrorCode = ErrorCode.InternalServerError, + ErrorMessage = mustangCliResult.ErrorMessage + }); + } + + string status = "unknown"; + try + { + status = MustangValidationReportParser.Parse(mustangCliResult.StandardOutput).Overall; + } + catch (Exception ex) + { + /* keep status=unknown on parse issues */ + logger.LogError(ex, "Failed to parse Mustang CLI XML output"); + } + + int statusCode = StatusCodes.Status400BadRequest; + if (mustangCliResult.ExitCode == (int)ErrorCode.Success) + statusCode = StatusCodes.Status200OK; + + return Results.Json(new FileValidationResponse + { + ErrorCode = (ErrorCode)mustangCliResult.ExitCode, + IsValid = status == "valid", + ValidationReport = mustangCliResult.StandardOutput, + DiagnosticsErrorMessage = mustangCliResult.ExitCode != (int)ErrorCode.Success ? mustangCliResult.StandardError : null + }, statusCode: statusCode); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to process the uploaded file."); + return Results.BadRequest(new FileValidationResponse + { + ErrorCode = ErrorCode.InvalidRequest, + ErrorMessage = $"Failed to process the uploaded file. Error: {ex.Message}" + }); + } + } + + /// + /// Handles conversion of Factur-X or UBL XML files to PDF documents using the Mustang CLI tool. + /// + /// The HTTP request containing the uploaded file. + /// The Mustang CLI service for executing commands. + /// The file upload request with the XML file to convert. + /// The optional output language (en, fr or de). Defaults to en. + /// The logger factory for creating loggers. + /// The cancellation token for managing task cancellation. + /// + /// An containing a with the generated PDF content + /// if successful, or an error message if conversion fails. + /// + /// + /// This endpoint converts Factur-X or UBL XML invoice data to a PDF document by invoking the Mustang CLI tool + /// as an external Java process. The generated PDF file is automatically cleaned up after reading. + /// + private static async Task ConvertXmlToPdfHandler(HttpRequest request, MustangCliService mustangCliService, [FromForm] FileUploadRequest uploadedFile, ILoggerFactory loggerFactory, CancellationToken cancellationToken, [FromQuery, Description("Output language of the generated PDF: en, fr or de. Defaults to en.")] string? language = null) + { + ILogger logger = loggerFactory.CreateLogger(nameof(XmlEndpoints)); + if (!request.HasFormContentType) + return Results.BadRequest(new ConvertXmlToPdfResponse + { + ErrorCode = ErrorCode.InvalidRequest, + ErrorMessage = "The request content type must be 'multipart/form-data'." + }); + + if (!string.IsNullOrWhiteSpace(language) && !MustangCliService.SupportedLanguages.Contains(language)) + return Results.BadRequest(new ConvertXmlToPdfResponse + { + ErrorCode = ErrorCode.InvalidRequest, + ErrorMessage = $"The language '{language}' is not supported. Supported languages: {string.Join(", ", MustangCliService.SupportedLanguages)}." + }); + + if (!await FileTypeValidator.IsXmlAsync(uploadedFile.File, cancellationToken)) + return Results.BadRequest(new ConvertXmlToPdfResponse + { + ErrorCode = ErrorCode.InvalidFileFormat, + ErrorMessage = "The uploaded file is not a valid XML file." + }); + + string? outputPdf = null; + + try + { + await using TemporaryUploadedFile temporaryFile = await TemporaryUploadedFile.CreateAsync(uploadedFile, cancellationToken); + + outputPdf = Path.ChangeExtension(temporaryFile.FilePath, ".pdf"); + + MustangCliResult mustangCliResult = await mustangCliService.ConvertXmlToPdfAsync(temporaryFile.FilePath, outputPdf, language, cancellationToken); + + if (mustangCliResult.ProcessStartFailed) + { + return Results.BadRequest(new ConvertXmlToPdfResponse + { + ErrorCode = ErrorCode.InternalServerError, + ErrorMessage = mustangCliResult.ErrorMessage + }); + } + + int statusCode = StatusCodes.Status400BadRequest; + if (mustangCliResult.ExitCode == 0) + statusCode = StatusCodes.Status200OK; + + return Results.Json(new ConvertXmlToPdfResponse + { + ErrorCode = (ErrorCode)mustangCliResult.ExitCode, + DiagnosticsErrorMessage = mustangCliResult.ExitCode != (int)ErrorCode.Success ? mustangCliResult.StandardError : null, + Pdf = mustangCliResult.ExitCode == 0 && File.Exists(outputPdf) + ? await File.ReadAllBytesAsync(outputPdf, cancellationToken) + : null, + }, statusCode: statusCode); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to process the uploaded file."); + return Results.BadRequest(new ConvertXmlToPdfResponse + { + ErrorCode = ErrorCode.InvalidRequest, + ErrorMessage = $"Failed to process the uploaded file. Error: {ex.Message}" + }); + } + finally + { + if (outputPdf != null && File.Exists(outputPdf)) + { + try + { + File.Delete(outputPdf); + } + catch (Exception ex) + { + logger.LogError(ex, "Error during cleanup of temporary PDF file on the path: {Path}.", outputPdf); + // Ignore any errors during cleanup + } + } + } + } +} diff --git a/src/Server/IO/FileTypeValidator.cs b/src/Server/IO/FileTypeValidator.cs index a5515ca..53ede9e 100644 --- a/src/Server/IO/FileTypeValidator.cs +++ b/src/Server/IO/FileTypeValidator.cs @@ -1,101 +1,101 @@ -namespace Docentric.EInvoice.Validator.RestServer.IO; - -/// -/// Provides methods for validating file types based on their magic bytes (file signatures). -/// -public static class FileTypeValidator -{ - // PDF files start with "%PDF-" (hex: 25 50 44 46 2D) - private static readonly byte[] _pdfSignature = [0x25, 0x50, 0x44, 0x46, 0x2D]; - - // XML files can start with: - // - UTF-8 BOM + " - /// Validates that the uploaded file is a PDF by checking its magic bytes. - /// - /// The uploaded file to validate. - /// The cancellation token for managing task cancellation. - /// True if the file is a valid PDF, false otherwise. - public static async Task IsPdfAsync(IFormFile file, CancellationToken cancellationToken) - { - if (file.Length < _pdfSignature.Length) - return false; - - await using Stream stream = file.OpenReadStream(); - byte[] buffer = new byte[_pdfSignature.Length]; - int bytesRead = await stream.ReadAsync(buffer, cancellationToken); - - if (bytesRead < _pdfSignature.Length) - return false; - - return buffer.AsSpan().SequenceEqual(_pdfSignature); - } - - /// - /// Validates that the uploaded file is an XML file by checking its magic bytes. - /// - /// The uploaded file to validate. - /// The cancellation token for managing task cancellation. - /// True if the file is a valid XML file, false otherwise. - /// - /// This method checks for XML files with or without UTF-8 BOM, with or without XML declaration. - /// It supports files starting with "<?xml" or directly with a root element "<". - /// - public static async Task IsXmlAsync(IFormFile file, CancellationToken cancellationToken) - { - if (file.Length == 0) - return false; - - await using Stream stream = file.OpenReadStream(); - - // Read enough bytes to check for XML signatures - int maxSignatureLength = Math.Max(_xmlSignatureWithBom.Length, _xmlSignature.Length); - byte[] buffer = new byte[maxSignatureLength]; - int bytesRead = await stream.ReadAsync(buffer, cancellationToken); - - if (bytesRead == 0) - return false; - - // Check for UTF-8 BOM + "= _xmlSignatureWithBom.Length && - buffer.AsSpan(0, _xmlSignatureWithBom.Length).SequenceEqual(_xmlSignatureWithBom)) - { - return true; - } - - // Check for "= _xmlSignature.Length && - buffer.AsSpan(0, _xmlSignature.Length).SequenceEqual(_xmlSignature)) - { - return true; - } - - // Check for XML without declaration (starts with "<") - if (bytesRead >= 1 && buffer[0] == _xmlRootElementSignature[0]) - { - // Additional validation: check if it looks like an XML tag - // Skip whitespace after "<" - int i = 1; - while (i < bytesRead && (buffer[i] == 0x20 || buffer[i] == 0x09 || buffer[i] == 0x0A || buffer[i] == 0x0D)) - i++; - - // Check if followed by a valid XML name character (letter, underscore, or colon) - if (i < bytesRead) - { - byte nextChar = buffer[i]; - return (nextChar >= 0x41 && nextChar <= 0x5A) || // A-Z - (nextChar >= 0x61 && nextChar <= 0x7A) || // a-z - nextChar == 0x5F || // underscore - nextChar == 0x3A; // colon - } - } - - return false; - } -} +namespace Docentric.EInvoice.Validator.RestServer.IO; + +/// +/// Provides methods for validating file types based on their magic bytes (file signatures). +/// +public static class FileTypeValidator +{ + // PDF files start with "%PDF-" (hex: 25 50 44 46 2D) + private static readonly byte[] _pdfSignature = [0x25, 0x50, 0x44, 0x46, 0x2D]; + + // XML files can start with: + // - UTF-8 BOM + " + /// Validates that the uploaded file is a PDF by checking its magic bytes. + /// + /// The uploaded file to validate. + /// The cancellation token for managing task cancellation. + /// True if the file is a valid PDF, false otherwise. + public static async Task IsPdfAsync(IFormFile file, CancellationToken cancellationToken) + { + if (file.Length < _pdfSignature.Length) + return false; + + await using Stream stream = file.OpenReadStream(); + byte[] buffer = new byte[_pdfSignature.Length]; + int bytesRead = await stream.ReadAsync(buffer, cancellationToken); + + if (bytesRead < _pdfSignature.Length) + return false; + + return buffer.AsSpan().SequenceEqual(_pdfSignature); + } + + /// + /// Validates that the uploaded file is an XML file by checking its magic bytes. + /// + /// The uploaded file to validate. + /// The cancellation token for managing task cancellation. + /// True if the file is a valid XML file, false otherwise. + /// + /// This method checks for XML files with or without UTF-8 BOM, with or without XML declaration. + /// It supports files starting with "<?xml" or directly with a root element "<". + /// + public static async Task IsXmlAsync(IFormFile file, CancellationToken cancellationToken) + { + if (file.Length == 0) + return false; + + await using Stream stream = file.OpenReadStream(); + + // Read enough bytes to check for XML signatures + int maxSignatureLength = Math.Max(_xmlSignatureWithBom.Length, _xmlSignature.Length); + byte[] buffer = new byte[maxSignatureLength]; + int bytesRead = await stream.ReadAsync(buffer, cancellationToken); + + if (bytesRead == 0) + return false; + + // Check for UTF-8 BOM + "= _xmlSignatureWithBom.Length && + buffer.AsSpan(0, _xmlSignatureWithBom.Length).SequenceEqual(_xmlSignatureWithBom)) + { + return true; + } + + // Check for "= _xmlSignature.Length && + buffer.AsSpan(0, _xmlSignature.Length).SequenceEqual(_xmlSignature)) + { + return true; + } + + // Check for XML without declaration (starts with "<") + if (bytesRead >= 1 && buffer[0] == _xmlRootElementSignature[0]) + { + // Additional validation: check if it looks like an XML tag + // Skip whitespace after "<" + int i = 1; + while (i < bytesRead && (buffer[i] == 0x20 || buffer[i] == 0x09 || buffer[i] == 0x0A || buffer[i] == 0x0D)) + i++; + + // Check if followed by a valid XML name character (letter, underscore, or colon) + if (i < bytesRead) + { + byte nextChar = buffer[i]; + return (nextChar >= 0x41 && nextChar <= 0x5A) || // A-Z + (nextChar >= 0x61 && nextChar <= 0x7A) || // a-z + nextChar == 0x5F || // underscore + nextChar == 0x3A; // colon + } + } + + return false; + } +} diff --git a/src/Server/IO/TemporaryUploadedFile.cs b/src/Server/IO/TemporaryUploadedFile.cs index c50b933..b424c0f 100644 --- a/src/Server/IO/TemporaryUploadedFile.cs +++ b/src/Server/IO/TemporaryUploadedFile.cs @@ -1,78 +1,91 @@ -using Docentric.EInvoice.Validator.RestServer.Contracts; - -namespace Docentric.EInvoice.Validator.RestServer.IO; - -/// -/// Stores an uploaded file as a temporary file on disk. -/// The file is deleted when the instance is disposed. -/// -public sealed class TemporaryUploadedFile : IAsyncDisposable, IDisposable -{ - /// - /// Full path to the temporary file. - /// - public string FilePath { get; } - - private bool _disposed; - - private TemporaryUploadedFile(string filePath) - { - FilePath = filePath; - } - - /// - /// Creates a temporary file from the uploaded request and returns a disposable wrapper. - /// - public static async Task CreateAsync(FileUploadRequest request, CancellationToken cancellationToken) - { - if (request?.File == null) - throw new ArgumentNullException(nameof(request.File), "The form in POST request did not contain a file."); - - if (request.File.Length == 0) - throw new ArgumentException("Uploaded file is empty.", nameof(request.File)); - - if (string.IsNullOrWhiteSpace(request.File.FileName)) - throw new ArgumentNullException(nameof(request.File.FileName), "File uploaded is missing a filename."); - - // Keep the original extension if present - string extension = Path.GetExtension(request.File.FileName); - string tempFilePath = Path.Combine( - Path.GetTempPath(), - $"{Guid.NewGuid():N}{extension}" - ); - - await using FileStream targetStream = File.Create(tempFilePath); - await request.File.CopyToAsync(targetStream, cancellationToken); - - return new TemporaryUploadedFile(tempFilePath); - } - - /// - /// Deletes the temporary file. - /// - public void Dispose() - { - if (_disposed) return; - _disposed = true; - - try - { - if (File.Exists(FilePath)) - File.Delete(FilePath); - } - catch - { - // Swallow exceptions: cleanup best-effort - } - } - - /// - /// Async dispose support. - /// - public ValueTask DisposeAsync() - { - Dispose(); - return ValueTask.CompletedTask; - } -} - +using Docentric.EInvoice.Validator.RestServer.Contracts; + +namespace Docentric.EInvoice.Validator.RestServer.IO; + +/// +/// Stores an uploaded file as a temporary file on disk. +/// The file is deleted when the instance is disposed. +/// +public sealed class TemporaryUploadedFile : IAsyncDisposable, IDisposable +{ + /// + /// Full path to the temporary file. + /// + public string FilePath { get; } + + private bool _disposed; + + private TemporaryUploadedFile(string filePath) + { + FilePath = filePath; + } + + /// + /// Creates a temporary file from the uploaded request and returns a disposable wrapper. + /// + public static async Task CreateAsync(FileUploadRequest request, CancellationToken cancellationToken) + { + if (request?.File == null) + throw new ArgumentException("The form in POST request did not contain a file.", nameof(request)); + + if (request.File.Length == 0) + throw new ArgumentException("Uploaded file is empty.", nameof(request)); + + if (string.IsNullOrWhiteSpace(request.File.FileName)) + throw new ArgumentException("File uploaded is missing a filename.", nameof(request)); + + // Keep the original extension if present + string extension = Path.GetExtension(request.File.FileName); + string tempFilePath = Path.Combine( + Path.GetTempPath(), + $"{Guid.NewGuid():N}{extension}" + ); + + try + { + await using FileStream targetStream = File.Create(tempFilePath); + await request.File.CopyToAsync(targetStream, cancellationToken); + } + catch + { + TryDelete(tempFilePath); + throw; + } + + return new TemporaryUploadedFile(tempFilePath); + } + + /// + /// Deletes the temporary file. + /// + public void Dispose() + { + if (_disposed) return; + _disposed = true; + + TryDelete(FilePath); + } + + private static void TryDelete(string filePath) + { + try + { + if (File.Exists(filePath)) + File.Delete(filePath); + } + catch + { + // Swallow exceptions: cleanup best-effort + } + } + + /// + /// Async dispose support. + /// + public ValueTask DisposeAsync() + { + Dispose(); + return ValueTask.CompletedTask; + } +} + diff --git a/src/Server/Mustang-CLI-2.22.0.jar b/src/Server/Mustang-CLI-2.26.0.jar similarity index 91% rename from src/Server/Mustang-CLI-2.22.0.jar rename to src/Server/Mustang-CLI-2.26.0.jar index 2f8a773..f25a49e 100644 Binary files a/src/Server/Mustang-CLI-2.22.0.jar and b/src/Server/Mustang-CLI-2.26.0.jar differ diff --git a/src/Server/Program.cs b/src/Server/Program.cs index 7b1737c..d987d08 100644 --- a/src/Server/Program.cs +++ b/src/Server/Program.cs @@ -1,96 +1,99 @@ -using System.Reflection; - -using Docentric.EInvoice.Validator.RestServer.Configuration; -using Docentric.EInvoice.Validator.RestServer.Services; - -using JavaService = Docentric.EInvoice.Validator.RestServer.Services.JavaService; - -string appTitle = Assembly - .GetEntryAssembly()? - .GetCustomAttribute()? - .Title ?? Constants.Application.DefaultTitle; - - -WebApplicationBuilder builder = WebApplication.CreateBuilder(args); - -builder.Services.AddOpenApi(Constants.OpenApi.Version, options => -{ - options.OpenApiVersion = Microsoft.OpenApi.OpenApiSpecVersion.OpenApi3_0; -}); - -// Add Request Timeout services -builder.Services.AddRequestTimeouts(options => -{ - options.DefaultPolicy = new Microsoft.AspNetCore.Http.Timeouts.RequestTimeoutPolicy - { - Timeout = Constants.RequestTimeouts.DefaultTimeout, // Default 60 second timeout - TimeoutStatusCode = StatusCodes.Status408RequestTimeout - }; - - // Add named policies for specific endpoints - options.AddPolicy(Constants.RequestTimeouts.LongRunningPolicy, Constants.RequestTimeouts.LongRunning); -}); - -// Define CORS for development only -builder.Services.AddCors(options => -{ - options.AddPolicy("DevSpa", policy => - policy.WithOrigins("http://localhost:53365") - .AllowAnyHeader() - .AllowAnyMethod()); -}); - -// Add this line with your other service registrations -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); - -WebApplication app = builder.Build(); - -// Add Request Timeout middleware (must be early in pipeline) -app.UseRequestTimeouts(); - -// API endpoints registrations -app.MapApiEndpoints(); - -// Map OpenAPI endpoints from separate class -app.MapOpenApi(Constants.OpenApi.V1UriSuffix); - -// Register Swagger / ReDoc middlewares -app.UseSwaggerUI(options => -{ - options.SwaggerEndpoint(Constants.OpenApi.V1UriSuffix, $"{appTitle} API {Constants.OpenApi.Version}"); - options.RoutePrefix = "api/docs/swagger"; -}); -app.UseReDoc(options => -{ - options.SpecUrl = Constants.OpenApi.V1UriSuffix; - options.RoutePrefix = "api/docs/redoc"; -}); - -// Configure for reverse proxy scenarios (Azure App Service, nginx, etc.) -app.UseForwardedHeaders(new ForwardedHeadersOptions -{ - ForwardedHeaders = Microsoft.AspNetCore.HttpOverrides.ForwardedHeaders.XForwardedFor | - Microsoft.AspNetCore.HttpOverrides.ForwardedHeaders.XForwardedProto -}); - -if (!app.Environment.IsDevelopment()) -{ - app.UseExceptionHandler("/error"); - app.UseHsts(); -} -app.UseHttpsRedirection(); - -if (app.Environment.IsDevelopment()) -{ - app.UseCors("DevSpa"); -} - -// Serve the built SPA from wwwroot -app.UseDefaultFiles(); -app.UseStaticFiles(); - -// Fallback to index.html for any non-API route -app.MapFallbackToFile("index.html"); - -app.Run(); +using System.Reflection; + +using Docentric.EInvoice.Validator.RestServer.Configuration; +using Docentric.EInvoice.Validator.RestServer.Services; + +using JavaService = Docentric.EInvoice.Validator.RestServer.Services.JavaService; + +string appTitle = Assembly + .GetEntryAssembly()? + .GetCustomAttribute()? + .Title ?? Constants.Application.DefaultTitle; + + +WebApplicationBuilder builder = WebApplication.CreateBuilder(args); + +builder.Services.AddOpenApi(Constants.OpenApi.Version, options => +{ + options.OpenApiVersion = Microsoft.OpenApi.OpenApiSpecVersion.OpenApi3_0; +}); + +// Add Request Timeout services +builder.Services.AddRequestTimeouts(options => +{ + options.DefaultPolicy = new Microsoft.AspNetCore.Http.Timeouts.RequestTimeoutPolicy + { + Timeout = Constants.RequestTimeouts.DefaultTimeout, // Default 60 second timeout + TimeoutStatusCode = StatusCodes.Status408RequestTimeout + }; + + // Add named policies for specific endpoints + options.AddPolicy(Constants.RequestTimeouts.LongRunningPolicy, Constants.RequestTimeouts.LongRunning); +}); + +// Define CORS for development only +builder.Services.AddCors(options => +{ + options.AddPolicy("DevSpa", policy => + policy.WithOrigins("http://localhost:53365") + .AllowAnyHeader() + .AllowAnyMethod()); +}); + +// Add this line with your other service registrations +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +// Warm-up: loads the JVM and jar into the OS file cache so the first real request is not slow. +builder.Services.AddHostedService(); + +WebApplication app = builder.Build(); + +// Add Request Timeout middleware (must be early in pipeline) +app.UseRequestTimeouts(); + +// API endpoints registrations +app.MapApiEndpoints(); + +// Map OpenAPI endpoints from separate class +app.MapOpenApi(Constants.OpenApi.V1UriSuffix); + +// Register Swagger / ReDoc middlewares +app.UseSwaggerUI(options => +{ + options.SwaggerEndpoint(Constants.OpenApi.V1UriSuffix, $"{appTitle} API {Constants.OpenApi.Version}"); + options.RoutePrefix = "api/docs/swagger"; +}); +app.UseReDoc(options => +{ + options.SpecUrl = Constants.OpenApi.V1UriSuffix; + options.RoutePrefix = "api/docs/redoc"; +}); + +// Configure for reverse proxy scenarios (Azure App Service, nginx, etc.) +app.UseForwardedHeaders(new ForwardedHeadersOptions +{ + ForwardedHeaders = Microsoft.AspNetCore.HttpOverrides.ForwardedHeaders.XForwardedFor | + Microsoft.AspNetCore.HttpOverrides.ForwardedHeaders.XForwardedProto +}); + +if (!app.Environment.IsDevelopment()) +{ + app.UseExceptionHandler("/error"); + app.UseHsts(); +} +app.UseHttpsRedirection(); + +if (app.Environment.IsDevelopment()) +{ + app.UseCors("DevSpa"); +} + +// Serve the built SPA from wwwroot +app.UseDefaultFiles(); +app.UseStaticFiles(); + +// Fallback to index.html for any non-API route +app.MapFallbackToFile("index.html"); + +app.Run(); diff --git a/src/Server/Properties/launchSettings.json b/src/Server/Properties/launchSettings.json index 7354c4d..9144f6d 100644 --- a/src/Server/Properties/launchSettings.json +++ b/src/Server/Properties/launchSettings.json @@ -1,15 +1,15 @@ -{ - "profiles": { - "Docentric.EInvoice.Validator.RestServer": { - "commandName": "Project", - "launchBrowser": true, - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development", - "ASPNETCORE_HOSTINGSTARTUPASSEMBLIES": "Microsoft.AspNetCore.SpaProxy" - }, - "dotnetRunMessages": true, - "applicationUrl": "https://localhost:57456;http://localhost:57457" - } - }, - "$schema": "https://json.schemastore.org/launchsettings.json" -} +{ + "profiles": { + "Docentric.EInvoice.Validator.RestServer": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "ASPNETCORE_HOSTINGSTARTUPASSEMBLIES": "Microsoft.AspNetCore.SpaProxy" + }, + "dotnetRunMessages": true, + "applicationUrl": "https://localhost:57456;http://localhost:57457" + } + }, + "$schema": "https://json.schemastore.org/launchsettings.json" +} diff --git a/src/Server/Services/JavaInfoResult.cs b/src/Server/Services/JavaInfoResult.cs index d6172f9..d8beeac 100644 --- a/src/Server/Services/JavaInfoResult.cs +++ b/src/Server/Services/JavaInfoResult.cs @@ -1,23 +1,23 @@ -namespace Docentric.EInvoice.Validator.RestServer.Services; - -/// -/// Represents the result of a Java runtime information query. -/// -/// Indicates whether Java is available on the system. -/// A dictionary containing Java system properties. -public sealed record JavaInfoResult(bool IsAvailable, IDictionary Properties) -{ - /// - /// Gets the Java runtime version from the system properties. - /// - /// - /// The runtime version from "java.runtime.version" property if available, - /// otherwise falls back to "java.version" property, or null if neither exists. - /// - public string? RuntimeVersion - => Properties.TryGetValue("java.runtime.version", out string? runtime) - ? runtime - : Properties.TryGetValue("java.version", out string? version) - ? version - : null; -} +namespace Docentric.EInvoice.Validator.RestServer.Services; + +/// +/// Represents the result of a Java runtime information query. +/// +/// Indicates whether Java is available on the system. +/// A dictionary containing Java system properties. +public sealed record JavaInfoResult(bool IsAvailable, IDictionary Properties) +{ + /// + /// Gets the Java runtime version from the system properties. + /// + /// + /// The runtime version from "java.runtime.version" property if available, + /// otherwise falls back to "java.version" property, or null if neither exists. + /// + public string? RuntimeVersion + => Properties.TryGetValue("java.runtime.version", out string? runtime) + ? runtime + : Properties.TryGetValue("java.version", out string? version) + ? version + : null; +} diff --git a/src/Server/Services/JavaService.cs b/src/Server/Services/JavaService.cs index dde87f6..6c58a1b 100644 --- a/src/Server/Services/JavaService.cs +++ b/src/Server/Services/JavaService.cs @@ -1,121 +1,121 @@ -using System.Diagnostics; -using System.Text.RegularExpressions; - -namespace Docentric.EInvoice.Validator.RestServer.Services; - -/// -/// Provides services for querying Java runtime information on the system. -/// -/// The logger instance for logging information and errors. -public sealed partial class JavaService(ILogger logger) -{ - /// - /// Retrieves information about the Java runtime installed on the system. - /// - /// The cancellation token for managing task cancellation. - /// - /// A indicating whether Java is available and containing - /// any discovered Java system properties. - /// - public async Task GetJavaInfoAsync(CancellationToken cancellationToken) - { - var properties = new Dictionary(StringComparer.OrdinalIgnoreCase); - - ProcessStartInfo processStartInfo = new() - { - FileName = "java", - Arguments = "-XshowSettings:properties -version", - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true, - }; - - using var process = Process.Start(processStartInfo); - - if (process == null) - { - return new JavaInfoResult(IsAvailable: false, properties); - } - - // Read both streams concurrently to avoid deadlock - Task stdoutTask = process.StandardOutput.ReadToEndAsync(cancellationToken); - Task stderrTask = process.StandardError.ReadToEndAsync(cancellationToken); - - try - { - // Wait for both reads to complete - await Task.WhenAll(stdoutTask, stderrTask); - string stdout = await stdoutTask; - string stderr = await stderrTask; - - await process.WaitForExitAsync(cancellationToken); - - string allOutput = stdout + Environment.NewLine + stderr; - properties = ParseJavaProperties(allOutput); - - // If we got here, java is at least callable - return new JavaInfoResult(IsAvailable: true, properties); - } - catch (OperationCanceledException oce) - { - logger.LogError(oce, "Operation was cancelled."); - if (process.HasExited == false) - process.Kill(entireProcessTree: true); - - return new JavaInfoResult(IsAvailable: false, properties); - } - catch(Exception ex) - { - logger.LogError(ex, "Failed to execute Java process."); - // Any failure => not available, empty properties - return new JavaInfoResult(IsAvailable: false, properties); - } - } - - /// - /// Parses Java system properties from the output of 'java -XshowSettings:properties -version'. - /// - /// The raw output text from the Java process. - /// A dictionary of property names and their values. - private static Dictionary ParseJavaProperties(string output) - { - var result = new Dictionary(StringComparer.OrdinalIgnoreCase); - - using var reader = new StringReader(output); - string? line; - - // Matches lines like: - // java.runtime.version = 25.0.0.36 - // 'java.home' = 'C:\Program Files\Java\...' - Regex regex = JavaPropertyLineRegex(); - - while ((line = reader.ReadLine()) is not null) - { - Match match = regex.Match(line); - if (!match.Success) - { - continue; - } - - string key = match.Groups[1].Value.Trim(); - string value = match.Groups[2].Value.Trim(); - - if (key.Length == 0) - { - continue; - } - - result[key] = value; - } - - return result; - } - - /// - /// Gets a compiled regular expression for matching Java property lines. - /// - /// A regex that captures property name and value from Java output lines. - [GeneratedRegex(@"^\s*'?([\w\.\-]+)'?\s*=\s*'?(.+?)'?\s*$", RegexOptions.Compiled | RegexOptions.CultureInvariant)] - private static partial Regex JavaPropertyLineRegex(); -} +using System.Diagnostics; +using System.Text.RegularExpressions; + +namespace Docentric.EInvoice.Validator.RestServer.Services; + +/// +/// Provides services for querying Java runtime information on the system. +/// +/// The logger instance for logging information and errors. +public sealed partial class JavaService(ILogger logger) +{ + /// + /// Retrieves information about the Java runtime installed on the system. + /// + /// The cancellation token for managing task cancellation. + /// + /// A indicating whether Java is available and containing + /// any discovered Java system properties. + /// + public async Task GetJavaInfoAsync(CancellationToken cancellationToken) + { + var properties = new Dictionary(StringComparer.OrdinalIgnoreCase); + + ProcessStartInfo processStartInfo = new() + { + FileName = "java", + Arguments = "-XshowSettings:properties -version", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + + using var process = Process.Start(processStartInfo); + + if (process == null) + { + return new JavaInfoResult(IsAvailable: false, properties); + } + + // Read both streams concurrently to avoid deadlock + Task stdoutTask = process.StandardOutput.ReadToEndAsync(cancellationToken); + Task stderrTask = process.StandardError.ReadToEndAsync(cancellationToken); + + try + { + // Wait for both reads to complete + await Task.WhenAll(stdoutTask, stderrTask); + string stdout = await stdoutTask; + string stderr = await stderrTask; + + await process.WaitForExitAsync(cancellationToken); + + string allOutput = stdout + Environment.NewLine + stderr; + properties = ParseJavaProperties(allOutput); + + // If we got here, java is at least callable + return new JavaInfoResult(IsAvailable: true, properties); + } + catch (OperationCanceledException oce) + { + logger.LogError(oce, "Operation was cancelled."); + if (process.HasExited == false) + process.Kill(entireProcessTree: true); + + return new JavaInfoResult(IsAvailable: false, properties); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to execute Java process."); + // Any failure => not available, empty properties + return new JavaInfoResult(IsAvailable: false, properties); + } + } + + /// + /// Parses Java system properties from the output of 'java -XshowSettings:properties -version'. + /// + /// The raw output text from the Java process. + /// A dictionary of property names and their values. + private static Dictionary ParseJavaProperties(string output) + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + + using var reader = new StringReader(output); + string? line; + + // Matches lines like: + // java.runtime.version = 25.0.0.36 + // 'java.home' = 'C:\Program Files\Java\...' + Regex regex = JavaPropertyLineRegex(); + + while ((line = reader.ReadLine()) is not null) + { + Match match = regex.Match(line); + if (!match.Success) + { + continue; + } + + string key = match.Groups[1].Value.Trim(); + string value = match.Groups[2].Value.Trim(); + + if (key.Length == 0) + { + continue; + } + + result[key] = value; + } + + return result; + } + + /// + /// Gets a compiled regular expression for matching Java property lines. + /// + /// A regex that captures property name and value from Java output lines. + [GeneratedRegex(@"^\s*'?([\w\.\-]+)'?\s*=\s*'?(.+?)'?\s*$", RegexOptions.Compiled | RegexOptions.CultureInvariant)] + private static partial Regex JavaPropertyLineRegex(); +} diff --git a/src/Server/Services/MustangCliResult.cs b/src/Server/Services/MustangCliResult.cs index f2d54a0..f558124 100644 --- a/src/Server/Services/MustangCliResult.cs +++ b/src/Server/Services/MustangCliResult.cs @@ -1,46 +1,46 @@ -namespace Docentric.EInvoice.Validator.RestServer.Services; - -/// -/// Represents the result of a Mustang CLI command execution. -/// -public sealed class MustangCliResult -{ - /// - /// The exit code returned by the Mustang CLI process. - /// - public int ExitCode { get; init; } - - /// - /// The standard output from the Mustang CLI process. - /// - public string StandardOutput { get; init; } = string.Empty; - - /// - /// The standard error from the Mustang CLI process. - /// - public string StandardError { get; init; } = string.Empty; - - /// - /// Indicates whether the command executed successfully (exit code 0). - /// - public bool IsSuccess => ExitCode == 0; - - /// - /// Error message if the process failed to start. - /// - public string ErrorMessage { get; init; } = null!; - - /// - /// Indicates whether the process failed to start. - /// - public bool ProcessStartFailed => ErrorMessage != null; - - /// - /// Creates a failed result when the process couldn't be started. - /// - internal static MustangCliResult Failed(string errorMessage) => new() - { - ExitCode = -1, - ErrorMessage = errorMessage - }; -} +namespace Docentric.EInvoice.Validator.RestServer.Services; + +/// +/// Represents the result of a Mustang CLI command execution. +/// +public sealed class MustangCliResult +{ + /// + /// The exit code returned by the Mustang CLI process. + /// + public int ExitCode { get; init; } + + /// + /// The standard output from the Mustang CLI process. + /// + public string StandardOutput { get; init; } = string.Empty; + + /// + /// The standard error from the Mustang CLI process. + /// + public string StandardError { get; init; } = string.Empty; + + /// + /// Indicates whether the command executed successfully (exit code 0). + /// + public bool IsSuccess => ExitCode == 0; + + /// + /// Error message if the process failed to start. + /// + public string ErrorMessage { get; init; } = null!; + + /// + /// Indicates whether the process failed to start. + /// + public bool ProcessStartFailed => ErrorMessage != null; + + /// + /// Creates a failed result when the process couldn't be started. + /// + internal static MustangCliResult Failed(string errorMessage) => new() + { + ExitCode = -1, + ErrorMessage = errorMessage + }; +} diff --git a/src/Server/Services/MustangCliService.cs b/src/Server/Services/MustangCliService.cs index 18d8104..55c5dec 100644 --- a/src/Server/Services/MustangCliService.cs +++ b/src/Server/Services/MustangCliService.cs @@ -1,144 +1,171 @@ -using System.Diagnostics; - -using Docentric.EInvoice.Validator.RestServer.Contracts; - -namespace Docentric.EInvoice.Validator.RestServer.Services; - -/// -/// Provides methods for executing Mustang CLI commands to validate and extract ZuGFeRD/Factur-X data. -/// -/// The logger instance for logging information and errors. -public sealed class MustangCliService(ILogger logger) -{ - /// - /// The version of Mustang CLI being used. - /// - public const string MustangCliVersion = "2.22.0"; - private const string MustangJarFile = $"Mustang-CLI-{MustangCliVersion}.jar"; - private const string JavaMaxMemory = "-Xmx1G"; - private const string FileEncoding = "-Dfile.encoding=UTF-8"; - - /// - /// Validates a ZuGFeRD/Factur-X PDF file. - /// - /// The path to the PDF file to validate. - /// The cancellation token for managing task cancellation. - /// A containing validation results. - public Task ValidateAsync(string sourceFilePath, CancellationToken cancellationToken) - => ExecuteAsync("validate", ["--source", sourceFilePath], cancellationToken); - - /// - /// Extracts embedded XML from a ZuGFeRD/Factur-X PDF file. - /// - /// The path to the PDF file. - /// The path where the extracted XML should be saved. - /// The cancellation token for managing task cancellation. - /// A containing extraction results. - public Task ExtractXmlAsync(string sourceFilePath, string outputFilePath, CancellationToken cancellationToken) - => ExecuteAsync("extract", ["--source", sourceFilePath, "--out", outputFilePath], cancellationToken); - - /// - /// Converts Factur-X or UBL XML file to a PDF document. - /// - /// The path to the XML file. - /// The path where the generated PDF should be saved. - /// The cancellation token for managing task cancellation. - /// A containing conversion results. - public Task ConvertXmlToPdfAsync(string sourceFilePath, string outputFilePath, CancellationToken cancellationToken) - => ExecuteAsync("pdf", ["--source", sourceFilePath, "--out", outputFilePath], cancellationToken); - - - /// - /// Checks if Mustang CLI is available by executing the help command. - /// - /// The cancellation token for managing task cancellation. - /// True if Mustang CLI executed successfully, false otherwise. - public async Task CheckAvailabilityAsync(CancellationToken cancellationToken) - { - try - { - MustangCliResult result = await ExecuteAsync("help", [], cancellationToken); - return result.IsSuccess; - } - catch - { - return false; - } - } - - /// - /// Executes a Mustang CLI command with the specified action and arguments. - /// - /// The Mustang CLI action to perform (e.g., "validate", "extract", "help"). - /// Additional command-line arguments to pass to Mustang CLI. - /// The cancellation token for managing task cancellation. - /// A containing the exit code, stdout, and stderr. - private async Task ExecuteAsync(string action, string[] additionalArguments, CancellationToken cancellationToken) - { - ProcessStartInfo processStartInfo = new() - { - FileName = "java", - ArgumentList = { JavaMaxMemory, FileEncoding, "-jar", MustangJarFile }, - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true, - WorkingDirectory = AppContext.BaseDirectory - }; - - // Add common Mustang CLI flags - processStartInfo.ArgumentList.Add("--no-notices"); - processStartInfo.ArgumentList.Add("--disable-file-logging"); - - // Add action - processStartInfo.ArgumentList.Add("--action"); - processStartInfo.ArgumentList.Add(action); - - // Add additional arguments - foreach (string arg in additionalArguments) - { - processStartInfo.ArgumentList.Add(arg); - } - - using var process = Process.Start(processStartInfo); - - if (process == null) - { - return MustangCliResult.Failed($"Failed to start Java process with Mustang CLI (action: {action})."); - } - - // Read both streams concurrently to avoid deadlock - Task stdoutTask = process.StandardOutput.ReadToEndAsync(cancellationToken); - Task stderrTask = process.StandardError.ReadToEndAsync(cancellationToken); - - try - { - // Wait for both reads to complete - await Task.WhenAll(stdoutTask, stderrTask); - string stdout = await stdoutTask; - string stderr = await stderrTask; - - await process.WaitForExitAsync(cancellationToken); - - return new MustangCliResult - { - ExitCode = process.ExitCode, - StandardOutput = stdout, - StandardError = stderr - }; - } - catch (OperationCanceledException oce) - { - logger.LogError(oce, "Operation was cancelled."); - if (process.HasExited == false) - process.Kill(entireProcessTree: true); - - return new MustangCliResult - { - ExitCode = (int)ErrorCode.ProcessingTimeout, - StandardOutput = string.Empty, - StandardError = string.Empty - }; - } - } -} +using System.Diagnostics; + +using Docentric.EInvoice.Validator.RestServer.Contracts; + +namespace Docentric.EInvoice.Validator.RestServer.Services; + +/// +/// Provides methods for executing Mustang CLI commands to validate and extract ZuGFeRD/Factur-X data. +/// +/// The logger instance for logging information and errors. +public sealed class MustangCliService(ILogger logger) +{ + /// + /// The output language used when none is specified. + /// + public const string DefaultLanguage = "en"; + + /// + /// The output languages supported by Mustang CLI. + /// + public static readonly IReadOnlySet SupportedLanguages = new HashSet(["en", "fr", "de"], StringComparer.OrdinalIgnoreCase); + + private const string JavaMaxMemory = "-Xmx1G"; + private const string FileEncoding = "-Dfile.encoding=UTF-8"; + + // Short-lived CLI process: fast startup matters more than peak throughput. + private const string JavaFastStartup = "-XX:TieredStopAtLevel=1"; + private const string JavaSerialGc = "-XX:+UseSerialGC"; + + /// + /// Validates a ZuGFeRD/Factur-X PDF file. + /// + /// The path to the PDF file to validate. + /// The cancellation token for managing task cancellation. + /// A containing validation results. + public Task ValidateAsync(string sourceFilePath, CancellationToken cancellationToken) + => ExecuteAsync("validate", ["--source", sourceFilePath], cancellationToken); + + /// + /// Extracts embedded XML from a ZuGFeRD/Factur-X PDF file. + /// + /// The path to the PDF file. + /// The path where the extracted XML should be saved. + /// The cancellation token for managing task cancellation. + /// A containing extraction results. + public Task ExtractXmlAsync(string sourceFilePath, string outputFilePath, CancellationToken cancellationToken) + => ExecuteAsync("extract", ["--source", sourceFilePath, "--out", outputFilePath], cancellationToken); + + /// + /// Converts Factur-X or UBL XML file to a PDF document. + /// + /// The path to the XML file. + /// The path where the generated PDF should be saved. + /// The output language (en, fr or de). Falls back to when null or empty. + /// The cancellation token for managing task cancellation. + /// A containing conversion results. + /// + /// The language is always passed explicitly, because Mustang CLI otherwise prompts for it on stdin and blocks. + /// + public Task ConvertXmlToPdfAsync(string sourceFilePath, string outputFilePath, string? language, CancellationToken cancellationToken) + => ExecuteAsync("pdf", ["--source", sourceFilePath, "--out", outputFilePath, "--language", string.IsNullOrWhiteSpace(language) ? DefaultLanguage : language], cancellationToken); + + + /// + /// Gets the version of the Mustang CLI jar file deployed next to the application. + /// + /// The version read from the embedded Maven pom.properties of the jar, or when the jar file is not found, cannot be read or does not contain a valid version. + public string? GetMustangCliVersion() + => MustangJarLocator.ReadVersion(AppContext.BaseDirectory); + + /// + /// Checks if Mustang CLI is available by executing the help command. + /// + /// The cancellation token for managing task cancellation. + /// True if Mustang CLI executed successfully, false otherwise. + public async Task CheckAvailabilityAsync(CancellationToken cancellationToken) + { + try + { + MustangCliResult result = await ExecuteAsync("help", [], cancellationToken); + return result.IsSuccess; + } + catch + { + return false; + } + } + + /// + /// Executes a Mustang CLI command with the specified action and arguments. + /// + /// The Mustang CLI action to perform (e.g., "validate", "extract", "help"). + /// Additional command-line arguments to pass to Mustang CLI. + /// The cancellation token for managing task cancellation. + /// A containing the exit code, stdout, and stderr. + private async Task ExecuteAsync(string action, string[] additionalArguments, CancellationToken cancellationToken) + { + string? mustangJarFile = MustangJarLocator.FindJarFileName(AppContext.BaseDirectory); + + if (mustangJarFile is null) + { + return MustangCliResult.Failed($"Mustang CLI jar file was not found (action: {action})."); + } + + ProcessStartInfo processStartInfo = new() + { + FileName = "java", + ArgumentList = { JavaMaxMemory, JavaFastStartup, JavaSerialGc, FileEncoding, "-jar", mustangJarFile }, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + WorkingDirectory = AppContext.BaseDirectory + }; + + // Add common Mustang CLI flags + processStartInfo.ArgumentList.Add("--no-notices"); + processStartInfo.ArgumentList.Add("--disable-file-logging"); + + // Add action + processStartInfo.ArgumentList.Add("--action"); + processStartInfo.ArgumentList.Add(action); + + // Add additional arguments + foreach (string arg in additionalArguments) + { + processStartInfo.ArgumentList.Add(arg); + } + + using var process = Process.Start(processStartInfo); + + if (process == null) + { + return MustangCliResult.Failed($"Failed to start Java process with Mustang CLI (action: {action})."); + } + + // Read both streams concurrently to avoid deadlock + Task stdoutTask = process.StandardOutput.ReadToEndAsync(cancellationToken); + Task stderrTask = process.StandardError.ReadToEndAsync(cancellationToken); + + try + { + // Wait for both reads to complete + await Task.WhenAll(stdoutTask, stderrTask); + string stdout = await stdoutTask; + string stderr = await stderrTask; + + await process.WaitForExitAsync(cancellationToken); + + return new MustangCliResult + { + ExitCode = process.ExitCode, + StandardOutput = stdout, + StandardError = stderr + }; + } + catch (OperationCanceledException oce) + { + logger.LogError(oce, "Operation was cancelled."); + if (process.HasExited == false) + process.Kill(entireProcessTree: true); + + return new MustangCliResult + { + ExitCode = (int)ErrorCode.ProcessingTimeout, + StandardOutput = string.Empty, + StandardError = string.Empty + }; + } + } +} diff --git a/src/Server/Services/MustangJarLocator.cs b/src/Server/Services/MustangJarLocator.cs new file mode 100644 index 0000000..ab39e5d --- /dev/null +++ b/src/Server/Services/MustangJarLocator.cs @@ -0,0 +1,97 @@ +using System.IO.Compression; + +namespace Docentric.EInvoice.Validator.RestServer.Services; + +/// +/// Locates the Mustang CLI jar file and reads its version from the jar contents. +/// +public static class MustangJarLocator +{ + private const string JarSearchPattern = "Mustang-CLI-*.jar"; + private const string PomPropertiesEntry = "META-INF/maven/org.mustangproject/validator/pom.properties"; + private const string VersionKey = "version="; + + /// + /// Finds the Mustang CLI jar file name in the given directory without opening the jar. + /// + /// The directory to search. + /// + /// The jar file name, or when the directory or the jar file does not exist. + /// When several jars match, the last one in ordinal file name order is returned. + /// + /// Thrown when is null or whitespace. + public static string? FindJarFileName(string directory) + { + string? path = FindJarPath(directory); + + return path is null ? null : Path.GetFileName(path); + } + + /// + /// Reads the Mustang CLI version from the embedded Maven pom.properties + /// of the org.mustangproject:validator artifact inside the jar. + /// + /// The directory containing the jar. + /// + /// The version, or when the jar is missing, unreadable + /// or does not contain a valid version. + /// + /// Thrown when is null or whitespace. + public static string? ReadVersion(string directory) + { + string? path = FindJarPath(directory); + + if (path is null) + { + return null; + } + + string? version = ReadVersionFromJar(path); + + return version is not null && Version.TryParse(version, out _) ? version : null; + } + + private static string? FindJarPath(string directory) + { + ArgumentException.ThrowIfNullOrWhiteSpace(directory); + + if (!Directory.Exists(directory)) + { + return null; + } + + return Directory.EnumerateFiles(directory, JarSearchPattern) + .OrderByDescending(Path.GetFileName, StringComparer.Ordinal) + .FirstOrDefault(); + } + + private static string? ReadVersionFromJar(string jarPath) + { + try + { + using ZipArchive archive = ZipFile.OpenRead(jarPath); + ZipArchiveEntry? entry = archive.GetEntry(PomPropertiesEntry); + + if (entry is null) + { + return null; + } + + using StreamReader reader = new(entry.Open()); + + while (reader.ReadLine() is { } line) + { + if (line.StartsWith(VersionKey, StringComparison.Ordinal)) + { + return line[VersionKey.Length..].Trim(); + } + } + + return null; + } + catch (Exception ex) when (ex is InvalidDataException or IOException or UnauthorizedAccessException) + { + return null; + } + } +} diff --git a/src/Server/Services/MustangValidationReportParser.cs b/src/Server/Services/MustangValidationReportParser.cs index 68acdbd..255ec7f 100644 --- a/src/Server/Services/MustangValidationReportParser.cs +++ b/src/Server/Services/MustangValidationReportParser.cs @@ -9,10 +9,11 @@ namespace Docentric.EInvoice.Validator.RestServer.Services; /// Status of the top-level summary. /// Status of the PDF section summary. /// Status of the embedded XML section summary. -public sealed record MustangValidationStatuses(string Overall, string Pdf, string Xml) +/// Value of the signature element (e.g. "valid", "invalid"). +public sealed record MustangValidationStatuses(string Overall, string Pdf, string Xml, string Signature) { /// Statuses used when the report is missing or cannot be interpreted. - public static MustangValidationStatuses Unknown { get; } = new(UnknownStatus, UnknownStatus, UnknownStatus); + public static MustangValidationStatuses Unknown { get; } = new(UnknownStatus, UnknownStatus, UnknownStatus, UnknownStatus); internal const string UnknownStatus = "unknown"; } @@ -43,7 +44,15 @@ public static MustangValidationStatuses Parse(string? reportXml) return new MustangValidationStatuses( GetSummaryStatus(root), GetSummaryStatus(root?.Element("pdf")), - GetSummaryStatus(root?.Element("xml"))); + GetSummaryStatus(root?.Element("xml")), + GetSignatureStatus(root)); + } + + // The signature element's location in the report is not fixed, so search all descendants. + private static string GetSignatureStatus(XElement? root) + { + string? value = root?.Descendants("signature").FirstOrDefault()?.Value.Trim(); + return string.IsNullOrEmpty(value) ? MustangValidationStatuses.UnknownStatus : value; } private static string GetSummaryStatus(XElement? parent) => diff --git a/src/Server/Services/MustangWarmUpService.cs b/src/Server/Services/MustangWarmUpService.cs new file mode 100644 index 0000000..69e3393 --- /dev/null +++ b/src/Server/Services/MustangWarmUpService.cs @@ -0,0 +1,32 @@ +namespace Docentric.EInvoice.Validator.RestServer.Services; + +/// +/// Loads the JVM and the Mustang CLI jar into the OS file cache after startup, so the first real request is not slow. +/// The host cancels and awaits this service on shutdown, which lets the Java process cleanup finish before services are disposed. +/// +/// The Mustang CLI service used to run the warm-up command. +/// The logger instance for logging information and errors. +internal sealed class MustangWarmUpService(MustangCliService mustangCliService, ILogger logger) : BackgroundService +{ + /// + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + // Best effort only: a warm-up failure must never affect the application. + try + { + bool available = await mustangCliService.CheckAvailabilityAsync(stoppingToken); + if (logger.IsEnabled(LogLevel.Information)) + { + logger.LogInformation("Mustang CLI warm-up finished (available: {Available}).", available); + } + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + // The application is shutting down. + } + catch (Exception ex) + { + logger.LogWarning(ex, "Mustang CLI warm-up failed."); + } + } +} diff --git a/src/Server/appsettings.Development.json b/src/Server/appsettings.Development.json index 81fb553..4dad7ac 100644 --- a/src/Server/appsettings.Development.json +++ b/src/Server/appsettings.Development.json @@ -1,36 +1,36 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Debug", - "System": "Information", - "Microsoft": "Information" - }, - "Console": { - "FormatterName": "simple", - "FormatterOptions": { - "SingleLine": false, - "IncludeScopes": true, - "TimestampFormat": "yyyy-MM-dd HH:mm:ss ", - "UseUtcTimestamp": false, - "ColorBehavior": "Enabled" - } - } - }, - "Kestrel": { - "Endpoints": { - "Http": { - "Url": "http://localhost:57457", - "Protocols": "Http1" - }, - "Https": { - "Url": "https://localhost:57456", - "Certificate": { - "Subject": "localhost", - "Store": "My", - "Location": "CurrentUser", - "AllowInvalid": true - } - } - } - } -} +{ + "Logging": { + "LogLevel": { + "Default": "Debug", + "System": "Information", + "Microsoft": "Information" + }, + "Console": { + "FormatterName": "simple", + "FormatterOptions": { + "SingleLine": false, + "IncludeScopes": true, + "TimestampFormat": "yyyy-MM-dd HH:mm:ss ", + "UseUtcTimestamp": false, + "ColorBehavior": "Enabled" + } + } + }, + "Kestrel": { + "Endpoints": { + "Http": { + "Url": "http://localhost:57457", + "Protocols": "Http1" + }, + "Https": { + "Url": "https://localhost:57456", + "Certificate": { + "Subject": "localhost", + "Store": "My", + "Location": "CurrentUser", + "AllowInvalid": true + } + } + } + } +} diff --git a/src/Server/appsettings.Docker.json b/src/Server/appsettings.Docker.json index 0dbb328..b87ca12 100644 --- a/src/Server/appsettings.Docker.json +++ b/src/Server/appsettings.Docker.json @@ -1,31 +1,31 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - }, - "Console": { - "FormatterName": "json", - "FormatterOptions": { - "SingleLine": true, - "IncludeScopes": true, - "TimestampFormat": "yyyy-MM-dd HH:mm:ss ", - "UseUtcTimestamp": true - } - } - }, - "AllowedHosts": "*", - "Kestrel": { - "Endpoints": { - "Http": { - "Url": "http://+:8080" - } - }, - "Limits": { - "MaxRequestBodySize": 104857600 - }, - "EndpointDefaults": { - "Protocols": "Http1AndHttp2" - } - } -} +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + }, + "Console": { + "FormatterName": "json", + "FormatterOptions": { + "SingleLine": true, + "IncludeScopes": true, + "TimestampFormat": "yyyy-MM-dd HH:mm:ss ", + "UseUtcTimestamp": true + } + } + }, + "AllowedHosts": "*", + "Kestrel": { + "Endpoints": { + "Http": { + "Url": "http://+:8080" + } + }, + "Limits": { + "MaxRequestBodySize": 104857600 + }, + "EndpointDefaults": { + "Protocols": "Http1AndHttp2" + } + } +} diff --git a/src/Server/appsettings.json b/src/Server/appsettings.json index 64680b5..15d57ec 100644 --- a/src/Server/appsettings.json +++ b/src/Server/appsettings.json @@ -1,22 +1,22 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - }, - "AllowedHosts": "*", - "Kestrel": { - "Endpoints": { - "Http": { - "Url": "http://localhost:57457" - } - }, - "Limits": { - "MaxRequestBodySize": 104857600 - }, - "EndpointDefaults": { - "Protocols": "Http1AndHttp2" - } - } -} +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "Kestrel": { + "Endpoints": { + "Http": { + "Url": "http://localhost:57457" + } + }, + "Limits": { + "MaxRequestBodySize": 104857600 + }, + "EndpointDefaults": { + "Protocols": "Http1AndHttp2" + } + } +} diff --git a/src/Server/web.config b/src/Server/web.config index 2982d4d..71372e5 100644 --- a/src/Server/web.config +++ b/src/Server/web.config @@ -1,27 +1,27 @@ - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/WebUI/.vscode/launch.json b/src/WebUI/.vscode/launch.json index ef2d2da..495b355 100644 --- a/src/WebUI/.vscode/launch.json +++ b/src/WebUI/.vscode/launch.json @@ -1,19 +1,19 @@ -{ - "version": "0.2.0", - "configurations": [ - { - "type": "edge", - "request": "launch", - "name": "localhost (Edge)", - "url": "https://localhost:53365", - "webRoot": "${workspaceFolder}" - }, - { - "type": "chrome", - "request": "launch", - "name": "localhost (Chrome)", - "url": "https://localhost:53365", - "webRoot": "${workspaceFolder}" - } - ] -} +{ + "version": "0.2.0", + "configurations": [ + { + "type": "edge", + "request": "launch", + "name": "localhost (Edge)", + "url": "https://localhost:53365", + "webRoot": "${workspaceFolder}" + }, + { + "type": "chrome", + "request": "launch", + "name": "localhost (Chrome)", + "url": "https://localhost:53365", + "webRoot": "${workspaceFolder}" + } + ] +} diff --git a/src/WebUI/.vscode/settings.json b/src/WebUI/.vscode/settings.json index 9412e27..d70e292 100644 --- a/src/WebUI/.vscode/settings.json +++ b/src/WebUI/.vscode/settings.json @@ -1,88 +1,88 @@ -{ - // ------------------------------------------------------- - // GENERAL EDITOR SETTINGS - // ------------------------------------------------------- - "editor.tabSize": 2, - "editor.insertSpaces": true, - "editor.detectIndentation": false, - "editor.wordWrap": "on", - "editor.formatOnSave": true, - "editor.formatOnPaste": true, - "editor.rulers": [ 120 ], // aligns with .editorconfig - "editor.trimAutoWhitespace": true, - - // Highlight unused vars/imports - "editor.codeActionsOnSave": { - "source.fixAll": "always", - "source.organizeImports": "always" - }, - - // ------------------------------------------------------- - // TYPESCRIPT & JAVASCRIPT - // ------------------------------------------------------- - "typescript.updateImportsOnFileMove.enabled": "always", - "typescript.preferences.importModuleSpecifier": "relative", - "typescript.validate.enable": true, - "javascript.validate.enable": true, - - // Keep quotes consistent - "javascript.preferences.quoteStyle": "double", - "typescript.preferences.quoteStyle": "double", - - // Tailwind CSS IntelliSense - "tailwindCSS.includeLanguages": { - "typescript": "javascript", - "typescriptreact": "javascript" - }, - "tailwindCSS.emmetCompletions": true, - - // ------------------------------------------------------- - // PRETTIER (Optional but recommended) - // ------------------------------------------------------- - "prettier.singleQuote": false, - "prettier.trailingComma": "all", - "prettier.printWidth": 120, - "prettier.useTabs": false, - "prettier.tabWidth": 2, - "prettier.semi": true, - "prettier.arrowParens": "always", - - // If Prettier is installed, use it as default formatter - "[javascript]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, - "[javascriptreact]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, - "[typescript]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, - "[typescriptreact]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, - "[json]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, - - // ------------------------------------------------------- - // TAILWIND - // ------------------------------------------------------- - "files.associations": { - "*.css": "tailwindcss" - }, - - // ------------------------------------------------------- - // ESLINT (optional — enable if using ESLint) - // ------------------------------------------------------- - "eslint.enable": true, - "eslint.alwaysShowStatus": true, - "eslint.run": "onType", - "eslint.validate": [ - "javascript", - "javascriptreact", - "typescript", - "typescriptreact" - ], - - // ------------------------------------------------------- - // VITE SPECIFIC - // ------------------------------------------------------- - "files.exclude": { - "dist": true, - "node_modules": true - }, - "search.exclude": { - "dist": true, - "node_modules": true - } -} +{ + // ------------------------------------------------------- + // GENERAL EDITOR SETTINGS + // ------------------------------------------------------- + "editor.tabSize": 2, + "editor.insertSpaces": true, + "editor.detectIndentation": false, + "editor.wordWrap": "on", + "editor.formatOnSave": true, + "editor.formatOnPaste": true, + "editor.rulers": [ 120 ], // aligns with .editorconfig + "editor.trimAutoWhitespace": true, + + // Highlight unused vars/imports + "editor.codeActionsOnSave": { + "source.fixAll": "always", + "source.organizeImports": "always" + }, + + // ------------------------------------------------------- + // TYPESCRIPT & JAVASCRIPT + // ------------------------------------------------------- + "typescript.updateImportsOnFileMove.enabled": "always", + "typescript.preferences.importModuleSpecifier": "relative", + "typescript.validate.enable": true, + "javascript.validate.enable": true, + + // Keep quotes consistent + "javascript.preferences.quoteStyle": "double", + "typescript.preferences.quoteStyle": "double", + + // Tailwind CSS IntelliSense + "tailwindCSS.includeLanguages": { + "typescript": "javascript", + "typescriptreact": "javascript" + }, + "tailwindCSS.emmetCompletions": true, + + // ------------------------------------------------------- + // PRETTIER (Optional but recommended) + // ------------------------------------------------------- + "prettier.singleQuote": false, + "prettier.trailingComma": "all", + "prettier.printWidth": 120, + "prettier.useTabs": false, + "prettier.tabWidth": 2, + "prettier.semi": true, + "prettier.arrowParens": "always", + + // If Prettier is installed, use it as default formatter + "[javascript]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, + "[javascriptreact]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, + "[typescript]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, + "[typescriptreact]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, + "[json]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, + + // ------------------------------------------------------- + // TAILWIND + // ------------------------------------------------------- + "files.associations": { + "*.css": "tailwindcss" + }, + + // ------------------------------------------------------- + // ESLINT (optional — enable if using ESLint) + // ------------------------------------------------------- + "eslint.enable": true, + "eslint.alwaysShowStatus": true, + "eslint.run": "onType", + "eslint.validate": [ + "javascript", + "javascriptreact", + "typescript", + "typescriptreact" + ], + + // ------------------------------------------------------- + // VITE SPECIFIC + // ------------------------------------------------------- + "files.exclude": { + "dist": true, + "node_modules": true + }, + "search.exclude": { + "dist": true, + "node_modules": true + } +} diff --git a/src/WebUI/README.md b/src/WebUI/README.md deleted file mode 100644 index 4985515..0000000 --- a/src/WebUI/README.md +++ /dev/null @@ -1,66 +0,0 @@ -# ZUGFeRD Mustang UI build automation - -Download mustang-cli from Maven Central: -https://mvnrepository.com/artifact/org.mustangproject/Mustang-CLI -or directly from their site: -https://www.mustangproject.org/commandline/ ==> https://www.mustangproject.org/deploy/Mustang-CLI-2.22.0.jar - -## What this solution does -This repository contains the front-end for the ZUGFeRD Mustang UI, a Vite + React + TypeScript application styled with Tailwind CSS and shadcn/ui components. The new automation scripts streamline getting from source code to the generated production HTML that ships in `dist/`. Each script installs the required Node.js dependencies (if needed) and triggers the framework's production build so you always end up with the final static assets. - -## Why it was created -Delivering a reliable "final HTML" build previously required running ad-hoc commands. The added scripts remove guesswork by providing: - -- **Cross-platform build coverage** — dedicated launchers for Linux/macOS (Bash), Windows PowerShell, and traditional Windows Command Prompt. -- **Repeatable environment setup** — a single initialisation script validates the Node.js toolchain and installs dependencies before the first build. -- **Documented workflow** — this README now explains the motivation, tooling, and usage for quick onboarding. - -## Technology stack -- [Vite](https://vitejs.dev/) with the React SWC plugin for lightning-fast builds -- React 18 and TypeScript for type-safe UI development -- Tailwind CSS and shadcn/ui for design system components - -## Prerequisites -- Node.js 18 or later (npm is bundled with Node.js) -- Access to a shell that matches your operating system (Bash, PowerShell, or Command Prompt) - -## Initialise the tooling -Run the initialisation script once after cloning to verify Node.js and install all dependencies: - -```bash -./scripts/init-tools.sh -``` - -The script checks that `node` and `npm` are on your `PATH`, reports their versions, and installs the dependencies defined in `package.json`/`package-lock.json`. - -## Build the final HTML bundle -Use the script that matches your environment. Each command must be executed from the repository root (or with the appropriate relative path). - -### Linux and macOS (Bash) -```bash -./scripts/build-linux.sh -``` - -### Windows PowerShell -```powershell -pwsh ./scripts/build.ps1 -``` - -### Windows Command Prompt -```cmd -scripts\build.cmd -``` - -Every script ensures dependencies are present, invokes `npm run build`, and reports where the generated HTML and assets live (`dist/`). - -## How the build works -Vite bundles the React application starting from `src/main.tsx`, resolves imports through the TypeScript compiler, and outputs an optimised static site. Tailwind CSS is processed through PostCSS using the Tailwind configuration in `tailwind.config.ts`, ensuring styles are tree-shaken. The resulting `dist/` directory contains the production-ready HTML, CSS, and JavaScript artefacts that can be deployed to any static host or integrated into larger systems. - -## Local development (optional) -While the scripts focus on production output, you can still run the development server for iterative work: - -```bash -npm run dev -``` - -This serves the application with hot module replacement at `http://localhost:5173` by default. diff --git a/src/WebUI/eslint.config.js b/src/WebUI/eslint.config.js index 40f72cc..9df7403 100644 --- a/src/WebUI/eslint.config.js +++ b/src/WebUI/eslint.config.js @@ -23,4 +23,10 @@ export default tseslint.config( "@typescript-eslint/no-unused-vars": "off", }, }, + { + files: ["src/components/ui/**/*.{ts,tsx}"], + rules: { + "react-refresh/only-export-components": "off", + }, + }, ); diff --git a/src/WebUI/package-lock.json b/src/WebUI/package-lock.json index 08994eb..0d96f59 100644 --- a/src/WebUI/package-lock.json +++ b/src/WebUI/package-lock.json @@ -3129,7 +3129,6 @@ "integrity": "sha512-bJFoMATwIGaxxx8VJPeM8TonI8t579oRvgAuT8zFugJsJZgzqv0Fu8Mhp68iecjzG7cnN3mO2dJQ5uUM2EFrgQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -3147,7 +3146,6 @@ "integrity": "sha512-/LDXMQh55EzZQ0uVAZmKKhfENivEvWz6E+EYzh+/MCjMhNsotd+ZHhBGIjFDTi6+fz0OhQQQLbTgdQIxxCsC0w==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" @@ -3159,7 +3157,6 @@ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -3209,7 +3206,6 @@ "integrity": "sha512-oM/5sAqz/l1v/zYK1qTOPrn2+JIrBGC1V6uNYucRO5gh6eT9vWJ/RhPbGE59byvZEUQ4VP6XkLBaS5Jti5jpvw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.71.1", "@typescript-eslint/types": "8.71.1", @@ -3467,7 +3463,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3804,7 +3799,6 @@ "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz", "integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==", "license": "MIT", - "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/kossnocorp" @@ -3871,8 +3865,7 @@ "version": "8.6.0", "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz", "integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/embla-carousel-react": { "version": "8.6.0", @@ -4005,7 +3998,6 @@ "integrity": "sha512-LSehfdpgMeWcTZkWZVIJl+tkZ2nuSkyyB9C27MZqFWXuph7DvaowgcTvKqxvpLW1JZIk8PN7hFY3Rj9LQ7m7lg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", @@ -5048,7 +5040,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -5075,7 +5066,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -5452,8 +5442,7 @@ "version": "4.3.3", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/tailwindcss-animate": { "version": "1.0.7", @@ -5525,7 +5514,6 @@ "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -5571,7 +5559,6 @@ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -5714,7 +5701,6 @@ "integrity": "sha512-AHEzTORMbi7/7aXp7w7KZ5+DCUdSeLsJz0grY8xHpc9DHdYb0vQu6DqJfm2tOw3gDExNgPgnJTXxM6syYFHaCA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", @@ -5808,7 +5794,6 @@ "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, diff --git a/src/WebUI/scripts/dev-run.cmd b/src/WebUI/scripts/dev-run.cmd index 49cb3de..48110e5 100644 --- a/src/WebUI/scripts/dev-run.cmd +++ b/src/WebUI/scripts/dev-run.cmd @@ -1,13 +1,13 @@ -set SCRIPT_DIR=%~dp0 -for %%I in ("%SCRIPT_DIR%..") do set PROJECT_ROOT=%%~fI -cd /d "%PROJECT_ROOT%" - -echo [serve] Ensuring dependencies are installed... -if not exist node_modules ( - call npm install -) else ( - echo [serve] node_modules directory exists; skipping npm install. -) - -echo [serve] Starting the local server for development... +set SCRIPT_DIR=%~dp0 +for %%I in ("%SCRIPT_DIR%..") do set PROJECT_ROOT=%%~fI +cd /d "%PROJECT_ROOT%" + +echo [serve] Ensuring dependencies are installed... +if not exist node_modules ( + call npm install +) else ( + echo [serve] node_modules directory exists; skipping npm install. +) + +echo [serve] Starting the local server for development... call npm run dev \ No newline at end of file diff --git a/src/WebUI/scripts/dev-run.ps1 b/src/WebUI/scripts/dev-run.ps1 index 41f0d9e..d4914de 100644 --- a/src/WebUI/scripts/dev-run.ps1 +++ b/src/WebUI/scripts/dev-run.ps1 @@ -1,16 +1,16 @@ -#!/usr/bin/env pwsh -$ErrorActionPreference = "Stop" - -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$projectRoot = Resolve-Path (Join-Path $scriptDir "..") -Set-Location $projectRoot - -Write-Host "[serve] Ensuring dependencies are installed..." -if (-not (Test-Path "node_modules")) { - npm install -} else { - Write-Host "[serve] node_modules directory exists; skipping npm install." -} - -Write-Host "[serve] Starting the local server for development..." -npm run dev +#!/usr/bin/env pwsh +$ErrorActionPreference = "Stop" + +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition +$projectRoot = Resolve-Path (Join-Path $scriptDir "..") +Set-Location $projectRoot + +Write-Host "[serve] Ensuring dependencies are installed..." +if (-not (Test-Path "node_modules")) { + npm install +} else { + Write-Host "[serve] node_modules directory exists; skipping npm install." +} + +Write-Host "[serve] Starting the local server for development..." +npm run dev diff --git a/src/WebUI/scripts/init-tools.cmd b/src/WebUI/scripts/init-tools.cmd index ee9f72d..97d3e9f 100644 --- a/src/WebUI/scripts/init-tools.cmd +++ b/src/WebUI/scripts/init-tools.cmd @@ -1,41 +1,41 @@ -@echo off -setlocal enabledelayedexpansion - -:: Get script directory and project root -set "SCRIPT_DIR=%~dp0" -set "PROJECT_ROOT=%SCRIPT_DIR%.." - -:: Change to project root -cd /d "%PROJECT_ROOT%" - -:: Function to check if a command exists -:check_command -where "%~1" >nul 2>&1 -if !errorlevel! neq 0 ( - echo [init] ERROR: %~2 ('%~1') is not available on PATH. >&2 - echo [init] Please install Node.js 18+ (which bundles npm) before continuing. >&2 - exit /b 1 -) -goto :eof - -:: Check for required commands -call :check_command node "Node.js" -if !errorlevel! neq 0 exit /b !errorlevel! - -call :check_command npm "npm" -if !errorlevel! neq 0 exit /b !errorlevel! - -:: Get versions -for /f "delims=" %%i in ('node --version') do set "node_version=%%i" -for /f "delims=" %%i in ('npm --version') do set "npm_version=%%i" - -echo [init] Using Node.js !node_version! and npm !npm_version!. - -echo [init] Installing project dependencies... -npm install -if !errorlevel! neq 0 ( - echo [init] ERROR: Failed to install dependencies. >&2 - exit /b 1 -) - +@echo off +setlocal enabledelayedexpansion + +:: Get script directory and project root +set "SCRIPT_DIR=%~dp0" +set "PROJECT_ROOT=%SCRIPT_DIR%.." + +:: Change to project root +cd /d "%PROJECT_ROOT%" + +:: Function to check if a command exists +:check_command +where "%~1" >nul 2>&1 +if !errorlevel! neq 0 ( + echo [init] ERROR: %~2 ('%~1') is not available on PATH. >&2 + echo [init] Please install Node.js 18+ (which bundles npm) before continuing. >&2 + exit /b 1 +) +goto :eof + +:: Check for required commands +call :check_command node "Node.js" +if !errorlevel! neq 0 exit /b !errorlevel! + +call :check_command npm "npm" +if !errorlevel! neq 0 exit /b !errorlevel! + +:: Get versions +for /f "delims=" %%i in ('node --version') do set "node_version=%%i" +for /f "delims=" %%i in ('npm --version') do set "npm_version=%%i" + +echo [init] Using Node.js !node_version! and npm !npm_version!. + +echo [init] Installing project dependencies... +npm install +if !errorlevel! neq 0 ( + echo [init] ERROR: Failed to install dependencies. >&2 + exit /b 1 +) + echo [init] Tooling successfully initialised. You can now run one of the build scripts in scripts/. \ No newline at end of file diff --git a/src/WebUI/scripts/init-tools.ps1 b/src/WebUI/scripts/init-tools.ps1 index ed35f7b..80b36f9 100644 --- a/src/WebUI/scripts/init-tools.ps1 +++ b/src/WebUI/scripts/init-tools.ps1 @@ -1,42 +1,42 @@ -# Set strict mode equivalent to bash's set -euo pipefail -$ErrorActionPreference = "Stop" - -# Get script directory and project root -$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path -$ProjectRoot = Split-Path -Parent $ScriptDir - -# Change to project root -Set-Location $ProjectRoot - -function Test-Command { - param( - [string]$Command, - [string]$FriendlyName - ) - - if (-not (Get-Command $Command -ErrorAction SilentlyContinue)) { - Write-Error "[init] ERROR: $FriendlyName ('$Command') is not available on PATH." - Write-Error "[init] Please install Node.js 18+ (which bundles npm) before continuing." - exit 1 - } -} - -# Check for required commands -Test-Command "node" "Node.js" -Test-Command "npm" "npm" - -# Get versions -$nodeVersion = & node --version -$npmVersion = & npm --version - -Write-Host "[init] Using Node.js $nodeVersion and npm $npmVersion." - -Write-Host "[init] Installing project dependencies..." -& npm install - -if ($LASTEXITCODE -ne 0) { - Write-Error "[init] ERROR: Failed to install dependencies." - exit 1 -} - +# Set strict mode equivalent to bash's set -euo pipefail +$ErrorActionPreference = "Stop" + +# Get script directory and project root +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$ProjectRoot = Split-Path -Parent $ScriptDir + +# Change to project root +Set-Location $ProjectRoot + +function Test-Command { + param( + [string]$Command, + [string]$FriendlyName + ) + + if (-not (Get-Command $Command -ErrorAction SilentlyContinue)) { + Write-Error "[init] ERROR: $FriendlyName ('$Command') is not available on PATH." + Write-Error "[init] Please install Node.js 18+ (which bundles npm) before continuing." + exit 1 + } +} + +# Check for required commands +Test-Command "node" "Node.js" +Test-Command "npm" "npm" + +# Get versions +$nodeVersion = & node --version +$npmVersion = & npm --version + +Write-Host "[init] Using Node.js $nodeVersion and npm $npmVersion." + +Write-Host "[init] Installing project dependencies..." +& npm install + +if ($LASTEXITCODE -ne 0) { + Write-Error "[init] ERROR: Failed to install dependencies." + exit 1 +} + Write-Host "[init] Tooling successfully initialised. You can now run one of the build scripts in scripts/." \ No newline at end of file diff --git a/src/WebUI/src/components/ui/command.tsx b/src/WebUI/src/components/ui/command.tsx index 16cf06d..206690f 100644 --- a/src/WebUI/src/components/ui/command.tsx +++ b/src/WebUI/src/components/ui/command.tsx @@ -21,7 +21,7 @@ const Command = React.forwardRef< )); Command.displayName = CommandPrimitive.displayName; -interface CommandDialogProps extends DialogProps {} +type CommandDialogProps = DialogProps; const CommandDialog = ({ children, ...props }: CommandDialogProps) => { return ( diff --git a/src/WebUI/src/components/ui/textarea.tsx b/src/WebUI/src/components/ui/textarea.tsx index 2904118..835ce55 100644 --- a/src/WebUI/src/components/ui/textarea.tsx +++ b/src/WebUI/src/components/ui/textarea.tsx @@ -2,7 +2,7 @@ import * as React from "react"; import { cn } from "@/lib/utils"; -export interface TextareaProps extends React.TextareaHTMLAttributes {} +export type TextareaProps = React.TextareaHTMLAttributes; const Textarea = React.forwardRef(({ className, ...props }, ref) => { return ( diff --git a/src/WebUI/src/components/ui/toast.tsx b/src/WebUI/src/components/ui/toast.tsx index f2b0ded..11a21a7 100644 --- a/src/WebUI/src/components/ui/toast.tsx +++ b/src/WebUI/src/components/ui/toast.tsx @@ -27,10 +27,10 @@ const toastVariants = cva( { variants: { variant: { - default: "border bg-background text-foreground", - info: "border-info bg-info text-info-foreground", - success: "border-success bg-success text-success-foreground", - warning: "border-warning bg-warning text-warning-foreground", + default: "border bg-background text-foreground", + info: "border-info bg-info text-info-foreground", + success: "border-success bg-success text-success-foreground", + warning: "border-warning bg-warning text-warning-foreground", destructive: "destructive group border-destructive bg-destructive text-destructive-foreground", }, }, @@ -55,7 +55,7 @@ const ToastAction = React.forwardRef< { - const simulationModeEnabled: boolean = false; - const [file, setFile] = useState(null); - const [isDragging, setIsDragging] = useState(false); - const [isLoading, setIsLoading] = useState(false); - const [operation, setOperation] = useState("validate-pdf"); - const [result, setResult] = useState(null); - const [simulationMode, setSimulationMode] = useState(false); - const [healthData, setHealthData] = useState(null); - const [isLoadingHealth, setIsLoadingHealth] = useState(true); - const { toast } = useToast(); - - const getUrlInfo = () => { - const url = new URL(window.location.href); - - return { - currentUrl: url.href, - siteUrl: url.origin, - virtualPath: url.pathname, - queryParams: Object.fromEntries(url.searchParams.entries()) - }; - }; - - // type OperationType = "validate-pdf" | "validate-xml" | "extract" | "convert-xml-to-pdf"; - const getApiEndPoint = (operation) => { - const apiEndpoints = { - "validate-pdf": "api/pdf/validate-zugferd", - "validate-xml": "api/xml/validate", - "extract": "api/pdf/extract-zugferd-xml", - "convert-xml-to-pdf": "api/xml/convert-to-pdf", - "health": "api/health" - }; - - return apiEndpoints[operation]; - }; - - - // Fetch health check data on component mount - useEffect(() => { - const fetchHealthData = async () => { - try { - setIsLoadingHealth(true); - const response = await fetch(`${getUrlInfo().siteUrl}/${getApiEndPoint("health")}`); - if (response.ok) { - const data = await response.json() as ServerHealthResult; - setHealthData(data); - } - } catch (error) { - console.error("Failed to fetch health data:", error); - } finally { - setIsLoadingHealth(false); - } - }; - - fetchHealthData(); - }, []); - - // Mock response data for simulation - const getMockResponse = (operation: TOperation): OperationResultMap[TOperation] => { - const mockResponses = { - "validate-pdf": { - success: true, - errorCode: "Success", - errorMessage: null, - diagnosticsErrorMessage: null, - isValid: true, - isSignatureValid: true, - isPdfValid: true, - isXmlValid: true, - validationReport: ` - - - PDF/A-3 - 1.7 - - - - - - 2 - urn:cen.eu:en16931:2017#compliant:factur-x.eu:1p0:en16931 - - - 201 - 0 - - 1250 - - - - -` - }, - "validate-xml": { - success: true, - errorCode: "Success", - errorMessage: null, - diagnosticsErrorMessage: null, - isValid: true, - validationReport: ` - - - - 2 - urn:cen.eu:en16931:2017#compliant:factur-x.eu:1p0:en16931 - - - 195 - 0 - - 950 - - - - -` - }, - "extract": { - success: true, - errorCode: "Success", - errorMessage: null, - diagnosticsErrorMessage: null, - xml: ` - - - - urn:cen.eu:en16931:2017#compliant:factur-x.eu:1p0:en16931 - - - - MOCK-INV-2024-001 - 380 - - 20240115 - - - - - - 1000.00 - 190.00 - 1190.00 - - - -` - }, - "convert-xml-to-pdf": { - success: true, - errorCode: "Success", - errorMessage: null, - diagnosticsErrorMessage: null, - pdf: "JVBERi0xLjQKJeLjz9MKNCAwIG9iago8PC9UeXBlIC9QYWdlCi9QYXJlbnQgMyAwIFIKL01lZGlhQm94IFswIDAgNTk1IDg0Ml0KL0NvbnRlbnRzIDUgMCBSCj4+CmVuZG9iago1IDAgb2JqCjw8L0xlbmd0aCA0Nj4+CnN0cmVhbQpCVAovRjEgMjQgVGYKMTAwIDcwMCBUZAooTW9jayBJbnZvaWNlIFBERikgVGoKRVQKZW5kc3RyZWFtCmVuZG9iagozIDAgb2JqCjw8L1R5cGUgL1BhZ2VzCi9LaWRzIFs0IDAgUl0KL0NvdW50IDEKL1Jlc291cmNlcyA8PC9Gb250IDw8L0YxIDYgMCBSPj4+Pgo+PgplbmRvYmoKNiAwIG9iago8PC9UeXBlIC9Gb250Ci9TdWJ0eXBlIC9UeXBlMQovQmFzZUZvbnQgL0hlbHZldGljYQo+PgplbmRvYmoKMSAwIG9iago8PC9UeXBlIC9DYXRhbG9nCi9QYWdlcyAzIDAgUgo+PgplbmRvYmoKMiAwIG9iago8PC9Qcm9kdWNlciAoTW9jayBQREYgR2VuZXJhdG9yKQo+PgplbmRvYmoKeHJlZgowIDcKMDAwMDAwMDAwMCA2NTUzNSBmDQowMDAwMDAwMzY4IDAwMDAwIG4NCjAwMDAwMDA0MTcgMDAwMDAgbg0KMDAwMDAwMDIyOCAwMDAwMCBuDQowMDAwMDAwMDE1IDAwMDAwIG4NCjAwMDAwMDAxMDkgMDAwMDAgbg0KMDAwMDAwMDMxNyAwMDAwMCBuDQp0cmFpbGVyCjw8L1NpemUgNwovUm9vdCAxIDAgUgovSW5mbyAyIDAgUgo+PgpzdGFydHhyZWYKNDY1CiUlRU9G" - } - } satisfies OperationResultMap; - - return mockResponses[operation]; - }; - - const handleDragOver = (e: React.DragEvent) => { - e.preventDefault(); - setIsDragging(true); - }; - - const handleDragLeave = () => { - setIsDragging(false); - }; - - const handleDownloadPDF = () => { - const pdfContentBase64 = (result as ConvertXmlToPdfResult)?.pdf; - - if (!pdfContentBase64) return; - - const byteCharacters = atob(pdfContentBase64); - const byteNumbers = new Array(byteCharacters.length); - for (let i = 0; i < byteCharacters.length; i++) { - byteNumbers[i] = byteCharacters.charCodeAt(i); - } - const byteArray = new Uint8Array(byteNumbers); - const blob = new Blob([byteArray], { type: 'application/pdf' }); - const url = URL.createObjectURL(blob); - const link = document.createElement('a'); - link.href = url; - link.download = `converted-e-invoice-${Date.now()}.pdf`; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - URL.revokeObjectURL(url); - toast({ - title: "Downloaded", - description: "PDF file has been downloaded successfully", - variant: "success" - }); - }; - - const handleDownloadXML = () => { - const xmlContent = (result as FileValidationResult | PdfFileValidationResult)?.validationReport || (result as ExtractXmlFromPdfResult)?.xml; - - if (!xmlContent) return; - - console.log(operation); - - let filename: string = `Validation-Report-${Date.now()}.xml`; - if ((result as ExtractXmlFromPdfResult)?.xml) { - filename = `Extracted-e-Invoice-attachment-${Date.now()}.xml`; - } - - const blob = new Blob([xmlContent], { type: 'application/xml' }); - const url = URL.createObjectURL(blob); - const link = document.createElement('a'); - link.href = url; - link.download = filename; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - URL.revokeObjectURL(url); - - toast({ - title: "Downloaded", - description: "XML file has been downloaded successfully", - variant: "success" - }); - }; - - const handleDrop = (e: React.DragEvent) => { - e.preventDefault(); - setIsDragging(false); - const droppedFile = e.dataTransfer.files[0]; - if (droppedFile) { - setFile(droppedFile); - } - }; - - const handleFileChange = (e: React.ChangeEvent) => { - if (e.target.files && e.target.files[0]) { - setFile(e.target.files[0]); - } - }; - - const handleOperation = async () => { - // Simulation mode - return mock data immediately - if (simulationMode) { - setIsLoading(true); - setResult(null); - const startTime = performance.now(); - - // Auto-create mock file if none selected - if (!file) { - const mockFileName = operation === "validate-xml" || operation === "convert-xml-to-pdf" ? "mock-invoice.xml" : "mock-invoice.pdf"; - const mockType = operation === "validate-xml" ? "application/xml" : "application/pdf"; - const mockContent = operation === "validate-xml" - ? '' - : "%PDF-1.4 mock content"; - - const mockFile = new File([mockContent], mockFileName, { type: mockType }); - setFile(mockFile); - } - - // Simulate API delay - setTimeout(() => { - const mockData = getMockResponse(operation); - setResult(mockData); - setIsLoading(false); - const duration = Math.round(performance.now() - startTime); - - toast({ - title: "🧪 Simulation Complete", - description: `Mock ${operation} result generated in ${duration}ms`, - }); - }, 800); - return; - } - - // Real mode - validate and make API call - if (!file) { - toast({ - title: "No file selected", - description: "Please select a PDF or XML file", - variant: "destructive", - }); - return; - } - - // Validate file type based on operation - const isXML = file.name.toLowerCase().endsWith('.xml'); - const isPDF = file.name.toLowerCase().endsWith('.pdf'); - - if (operation === "validate-xml" && !isXML) { - toast({ - title: "Invalid file type", - description: "Please select an XML file for XML validation", - variant: "destructive", - }); - return; - } - - if ((operation === "validate-pdf" || operation === "extract") && !isPDF) { - toast({ - title: "Invalid file type", - description: "Please select a PDF file for this operation", - variant: "destructive", - }); - return; - } - - setIsLoading(true); - setResult(null); - const startTime = performance.now(); - - try { - const formData = new FormData(); - formData.append("file", file); - - const response = await fetch(`${getUrlInfo().siteUrl}/${getApiEndPoint(operation)}`, { - method: "POST", - body: formData, - }); - - const data = await response.json() as OperationResultMap[typeof operation]; - console.log(data); - setResult(data); - const duration = Math.round(performance.now() - startTime); - - - const operationLabels: Record = { - "validate-pdf": "PDF Validation", - "validate-xml": "XML Validation", - "extract": "XML Extraction", - "convert-xml-to-pdf": "XML to PDF Conversion" - }; - - const toastTitle: string = `${operationLabels[operation]}`; - type toastVariantType = "default" | "success" | "info" | "warning" | "destructive"; - let toastVariant: toastVariantType = data.success ? "success" : "destructive"; - - if (operation === "validate-pdf" || operation === "validate-xml") { - const validationResult = data as PdfFileValidationResult | FileValidationResult; - if (validationResult?.isValid === false) { - toastVariant = "destructive"; - } else if (operation === "validate-pdf") { - const pdfResult = data as PdfFileValidationResult; - if (!pdfResult.isPdfValid) { - toastVariant = "warning"; - } - } - } - - const getDescription = () => { - const fileSize = (file.size / 1024).toFixed(1); - const timeStr = duration < 1000 ? `${duration}ms` : `${(duration / 1000).toFixed(2)}s`; - - switch (operation) { - case "extract": - return data.errorCode === "Success" - ? `XML extracted successfully from ${file.name} (${fileSize} KB) in ${timeStr}` - : `XML extraction failed after ${timeStr}. ${data.errorMessage || `Error code: ${data.errorCode}`}`; - case "convert-xml-to-pdf": - return data.errorCode === "Success" - ? `PDF generated successfully from ${file.name} (${fileSize} KB) in ${timeStr}` - : `PDF generation failed after ${timeStr}. ${data.errorMessage || `Error code: ${data.errorCode}`}`; - case "validate-pdf": { - const pdfResult = data as PdfFileValidationResult; - if (!pdfResult.isValid) - return `${file.name} (${fileSize} KB) is invalid. Processed in ${timeStr}`; - if (!pdfResult.isPdfValid) - return `${file.name} (${fileSize} KB) is valid. PDF/A-3 has warnings (BR-FX-DE-03). Processed in ${timeStr}`; - return `${file.name} (${fileSize} KB) is valid. Processed in ${timeStr}`; - } - case "validate-xml": { - const xmlResult = data as FileValidationResult; - return xmlResult.isValid - ? `${file.name} (${fileSize} KB) is valid. Processed in ${timeStr}` - : `${file.name} (${fileSize} KB) validation failed. Processed in ${timeStr}`; - } - } - }; - - toast({ - title: toastTitle, - description: getDescription(), - variant: toastVariant, - }); - } catch (error) { - const duration = Math.round(performance.now() - startTime); - const timeStr = duration < 1000 ? `${duration}ms` : `${(duration / 1000).toFixed(2)}s`; - - toast({ - title: "Operation Failed", - description: `${error instanceof Error ? error.message : "Operation failed"} (after ${timeStr})`, - variant: "destructive", - }); - } finally { - setIsLoading(false); - } - }; - - const getEscapedString = (input: string) => { - return input?.replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'") ?? ""; - } - - const getResultFile = (source: BaseFileOperationResult) => { - let result = (source as FileValidationResult | PdfFileValidationResult)?.validationReport || - (source as ExtractXmlFromPdfResult)?.xml; - - if (!result) { - result = (source as ConvertXmlToPdfResult)?.pdf; - } - - return result; - } - - return ( -
- {/* Header */} -
- -
- -
- {/* Interactive Tester Section */} -
- -
-
-
-

Test your ZuGFeRD, Factur-X or UBL - e-Invoices

-

- Upload, validate, extract or convert files -

-
- {simulationModeEnabled && ( -
- - - -
- )} -
-
- -
- {/* File Upload Area */} -
- - -
- - {/* Operation Selection */} -
- -
- - - - -
-
- - - - {/* Results */} - {result && ( - -
- {result.success === true ? ( - - ) : ( - /**/ - - )} -
-

{result.success ? "valid" : "invalid"}

- {/*

*/} - {/* Exit code: {result.errorCode}*/} - {/* {result.errorCode !== 0 && ` - ${result.errorMessage}`}*/} - {/*

*/} -
-
- - {/* PDF sub-validation breakdown */} - {operation === "validate-pdf" && (() => { - const pdfResult = result as PdfFileValidationResult; - return ( -
-
- {pdfResult.isXmlValid - ? - : } - XML invoice -
-
- {pdfResult.isPdfValid - ? - : } - PDF/A-3 - {!pdfResult.isPdfValid && pdfResult.isValid && ( - (warning only - valid under BR-FX-DE-03) - )} -
-
- {pdfResult.isSignatureValid - ? - : } - Signature -
-
- ); - })()} - - {((result as FileValidationResult | PdfFileValidationResult)?.validationReport !== undefined || (result as ExtractXmlFromPdfResult)?.xml !== undefined || (result as ConvertXmlToPdfResult)?.pdf) !== undefined && ( -
-
-

- - {operation === "extract" ? "Extracted XML" : operation === "convert-xml-to-pdf" ? "Generated PDF document in base64 encoding" : "Validation report"} -

- -
-
-                        {getResultFile(result)}
-                      
-
- )} - - {result.diagnosticsErrorMessage && ( -
-

Diagnostics

-
-                        {getEscapedString(result.diagnosticsErrorMessage)}
-                      
-
- )} -
- )} -
-
-
- - {/* Sample Files Section */} -
-

Sample Files

- -
- -
- - {/* API Documentation */} -
-

API Documentation

- - -
-

Interactive API Documentation

-

- Explore and test all API endpoints with our interactive documentation -

-
- -
-
-
- - {/* Licensing Section */} -
-
-

Licensing Information

- - -
-

Open Source Licenses

-

- This project and its dependencies are licensed under open source licenses -

-
- -
- {/* Project License */} -
-

Project License (MIT)

-
-

MIT License

-

Copyright (c) 2026 Docentric

-

- Permission is hereby granted, free of charge, to any person obtaining a copy - of this software and associated documentation files (the "Software"), to deal - in the Software without restriction, including without limitation the rights - to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - copies of the Software, and to permit persons to whom the Software is - furnished to do so, subject to the following conditions: -

-

- The above copyright notice and this permission notice shall be included in - all copies or substantial portions of the Software. -

-

- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN - THE SOFTWARE. -

-
-
- - {/* Third-Party License */} -
-

Third-Party Components

- -
-
-
- -
-
-
Mustang project / Mustang-CLI
-

- This project integrates with the Mustang project CLI for ZUGFeRD/Factur-X/UBL - validation and PDF/A processing. -

-
-

- Project:{" "} - - https://mustangproject.org/ - -

-

- Mustang-CLI:{" "} - - https://www.mustangproject.org/commandline/ - -

-

- Repository:{" "} - - https://github.com/ZUGFeRD/mustangproject - -

-

- License: Apache License 2.0 -

-

- Copyright © 2013-2025 ZUGFeRD Association e.V. -

-
-
-
-
-
-
- - {/* Acknowledgements */} -
-

Acknowledgements

-
-

- We gratefully acknowledge the Mustang project team and the ZUGFeRD Association - for their excellent work in creating and maintaining the Mustang-CLI tool, - which enables validation, extraction, and processing of ZUGFeRD/UBL/Factur-X documents. -

-

- If you bundle the Mustang-CLI JAR or Docker image with this project, the full - Apache License 2.0 text should be included in your distribution. -

-
-
- - {/* Kudos to Lovable */} -
-

Kudos to Lovable

-
-

- Special thanks to the Lovable platform for - accelerating development, improving code quality, and making the overall project experience smoother - and more enjoyable. -

-
-
-
-
-
-
- - {/* Footer */} -
-
-

Docentric ZuGFeRD, Factur-X and UBL Document Validator

-
-
-
- ); -}; - -export default Index; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { useToast } from "@/hooks/use-toast"; +import { AlertCircle, Beaker, CheckCircle, Download, FileText, MinusCircle, Upload, XCircle } from "lucide-react"; +import { useEffect, useState } from "react"; + +interface ValidationResult { + exitCode: number; + status: "valid" | "invalid" | "unknown"; + xml: string; + stderr: string; +} + +/** Health check result interface */ +interface ServerHealthResult { + /** Indicates if the server is healthy */ + healthy: boolean; + /** Is Java present on the server */ + javaPresent: boolean; + /** The version of Java installed on the server */ + javaVersion: string | null; + /** Is Mustang CLI present on the server */ + mustangCliPresent: boolean; + /** The version of Mustang CLI installed on the server */ + mustangCliVersion: string | null; +} + +/** Base interface for operation results */ +interface BaseFileOperationResult { + /** Indicates if the operation was successful */ + success: boolean; + /** Error code */ + errorCode: string; + /** Error message */ + errorMessage: string | null; + /** Diagnostics error message containing details about the error */ + diagnosticsErrorMessage: string | null; +} + +/* General file validation result interface */ +interface FileValidationResult extends BaseFileOperationResult { + /* Indicates if the file is valid */ + isValid: boolean; + /* XML validation report as string */ + validationReport: string; +} + +/* PDF file validation result interface */ +interface PdfFileValidationResult extends BaseFileOperationResult { + /* Indicates if the file is valid */ + isValid: boolean; + /* Indicates if the PDF signature is valid */ + isSignatureValid: boolean; + /* Indicates if the PDF/A part is valid */ + isPdfValid: boolean; + /* Indicates if the embedded XML invoice data is valid */ + isXmlValid: boolean; + /* XML validation report as string */ + validationReport: string; +} + +/* XML file validation result interface */ +interface ExtractXmlFromPdfResult extends BaseFileOperationResult { + /* Extracted XML content as string */ + xml: string | null; +} + +/* Convert XML to PDF result interface */ +interface ConvertXmlToPdfResult extends BaseFileOperationResult { + /* Generated PDF content as base64 string */ + pdf: string | null; +} + +type OperationType = "validate-pdf" | "validate-xml" | "extract" | "convert-xml-to-pdf"; + +// Mapping: operation → result type +type OperationResultMap = { + "validate-pdf": PdfFileValidationResult; + "validate-xml": FileValidationResult; + "extract": ExtractXmlFromPdfResult; + "convert-xml-to-pdf": ConvertXmlToPdfResult; +}; + +const Index = () => { + const simulationModeEnabled: boolean = false; + const [file, setFile] = useState(null); + const [isDragging, setIsDragging] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [operation, setOperation] = useState("validate-pdf"); + const [result, setResult] = useState(null); + const [simulationMode, setSimulationMode] = useState(false); + const [healthData, setHealthData] = useState(null); + const [isLoadingHealth, setIsLoadingHealth] = useState(true); + const { toast } = useToast(); + + const getUrlInfo = () => { + const url = new URL(window.location.href); + + return { + currentUrl: url.href, + siteUrl: url.origin, + virtualPath: url.pathname, + queryParams: Object.fromEntries(url.searchParams.entries()) + }; + }; + + // type OperationType = "validate-pdf" | "validate-xml" | "extract" | "convert-xml-to-pdf"; + const getApiEndPoint = (operation) => { + const apiEndpoints = { + "validate-pdf": "api/pdf/validate-zugferd", + "validate-xml": "api/xml/validate", + "extract": "api/pdf/extract-zugferd-xml", + "convert-xml-to-pdf": "api/xml/convert-to-pdf", + "health": "api/health" + }; + + return apiEndpoints[operation]; + }; + + + // Fetch health check data on component mount + useEffect(() => { + const fetchHealthData = async () => { + try { + setIsLoadingHealth(true); + const response = await fetch(`${getUrlInfo().siteUrl}/${getApiEndPoint("health")}`); + if (response.ok) { + const data = await response.json() as ServerHealthResult; + setHealthData(data); + } + } catch (error) { + console.error("Failed to fetch health data:", error); + } finally { + setIsLoadingHealth(false); + } + }; + + fetchHealthData(); + }, []); + + // Mock response data for simulation + const getMockResponse = (operation: TOperation): OperationResultMap[TOperation] => { + const mockResponses = { + "validate-pdf": { + success: true, + errorCode: "Success", + errorMessage: null, + diagnosticsErrorMessage: null, + isValid: true, + isSignatureValid: true, + isPdfValid: true, + isXmlValid: true, + validationReport: ` + + + PDF/A-3 + 1.7 + + + + + + 2 + urn:cen.eu:en16931:2017#compliant:factur-x.eu:1p0:en16931 + + + 201 + 0 + + 1250 + + + + +` + }, + "validate-xml": { + success: true, + errorCode: "Success", + errorMessage: null, + diagnosticsErrorMessage: null, + isValid: true, + validationReport: ` + + + + 2 + urn:cen.eu:en16931:2017#compliant:factur-x.eu:1p0:en16931 + + + 195 + 0 + + 950 + + + + +` + }, + "extract": { + success: true, + errorCode: "Success", + errorMessage: null, + diagnosticsErrorMessage: null, + xml: ` + + + + urn:cen.eu:en16931:2017#compliant:factur-x.eu:1p0:en16931 + + + + MOCK-INV-2024-001 + 380 + + 20240115 + + + + + + 1000.00 + 190.00 + 1190.00 + + + +` + }, + "convert-xml-to-pdf": { + success: true, + errorCode: "Success", + errorMessage: null, + diagnosticsErrorMessage: null, + pdf: "JVBERi0xLjQKJeLjz9MKNCAwIG9iago8PC9UeXBlIC9QYWdlCi9QYXJlbnQgMyAwIFIKL01lZGlhQm94IFswIDAgNTk1IDg0Ml0KL0NvbnRlbnRzIDUgMCBSCj4+CmVuZG9iago1IDAgb2JqCjw8L0xlbmd0aCA0Nj4+CnN0cmVhbQpCVAovRjEgMjQgVGYKMTAwIDcwMCBUZAooTW9jayBJbnZvaWNlIFBERikgVGoKRVQKZW5kc3RyZWFtCmVuZG9iagozIDAgb2JqCjw8L1R5cGUgL1BhZ2VzCi9LaWRzIFs0IDAgUl0KL0NvdW50IDEKL1Jlc291cmNlcyA8PC9Gb250IDw8L0YxIDYgMCBSPj4+Pgo+PgplbmRvYmoKNiAwIG9iago8PC9UeXBlIC9Gb250Ci9TdWJ0eXBlIC9UeXBlMQovQmFzZUZvbnQgL0hlbHZldGljYQo+PgplbmRvYmoKMSAwIG9iago8PC9UeXBlIC9DYXRhbG9nCi9QYWdlcyAzIDAgUgo+PgplbmRvYmoKMiAwIG9iago8PC9Qcm9kdWNlciAoTW9jayBQREYgR2VuZXJhdG9yKQo+PgplbmRvYmoKeHJlZgowIDcKMDAwMDAwMDAwMCA2NTUzNSBmDQowMDAwMDAwMzY4IDAwMDAwIG4NCjAwMDAwMDA0MTcgMDAwMDAgbg0KMDAwMDAwMDIyOCAwMDAwMCBuDQowMDAwMDAwMDE1IDAwMDAwIG4NCjAwMDAwMDAxMDkgMDAwMDAgbg0KMDAwMDAwMDMxNyAwMDAwMCBuDQp0cmFpbGVyCjw8L1NpemUgNwovUm9vdCAxIDAgUgovSW5mbyAyIDAgUgo+PgpzdGFydHhyZWYKNDY1CiUlRU9G" + } + } satisfies OperationResultMap; + + return mockResponses[operation]; + }; + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(true); + }; + + const handleDragLeave = () => { + setIsDragging(false); + }; + + const handleDownloadPDF = () => { + const pdfContentBase64 = (result as ConvertXmlToPdfResult)?.pdf; + + if (!pdfContentBase64) return; + + const byteCharacters = atob(pdfContentBase64); + const byteNumbers = new Array(byteCharacters.length); + for (let i = 0; i < byteCharacters.length; i++) { + byteNumbers[i] = byteCharacters.charCodeAt(i); + } + const byteArray = new Uint8Array(byteNumbers); + const blob = new Blob([byteArray], { type: 'application/pdf' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `converted-e-invoice-${Date.now()}.pdf`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + toast({ + title: "Downloaded", + description: "PDF file has been downloaded successfully", + variant: "success" + }); + }; + + const handleDownloadXML = () => { + const xmlContent = (result as FileValidationResult | PdfFileValidationResult)?.validationReport || (result as ExtractXmlFromPdfResult)?.xml; + + if (!xmlContent) return; + + console.log(operation); + + let filename: string = `Validation-Report-${Date.now()}.xml`; + if ((result as ExtractXmlFromPdfResult)?.xml) { + filename = `Extracted-e-Invoice-attachment-${Date.now()}.xml`; + } + + const blob = new Blob([xmlContent], { type: 'application/xml' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + + toast({ + title: "Downloaded", + description: "XML file has been downloaded successfully", + variant: "success" + }); + }; + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(false); + const droppedFile = e.dataTransfer.files[0]; + if (droppedFile) { + setFile(droppedFile); + } + }; + + const handleFileChange = (e: React.ChangeEvent) => { + if (e.target.files && e.target.files[0]) { + setFile(e.target.files[0]); + } + }; + + const handleOperation = async () => { + // Simulation mode - return mock data immediately + if (simulationMode) { + setIsLoading(true); + setResult(null); + const startTime = performance.now(); + + // Auto-create mock file if none selected + if (!file) { + const mockFileName = operation === "validate-xml" || operation === "convert-xml-to-pdf" ? "mock-invoice.xml" : "mock-invoice.pdf"; + const mockType = operation === "validate-xml" ? "application/xml" : "application/pdf"; + const mockContent = operation === "validate-xml" + ? '' + : "%PDF-1.4 mock content"; + + const mockFile = new File([mockContent], mockFileName, { type: mockType }); + setFile(mockFile); + } + + // Simulate API delay + setTimeout(() => { + const mockData = getMockResponse(operation); + setResult(mockData); + setIsLoading(false); + const duration = Math.round(performance.now() - startTime); + + toast({ + title: "🧪 Simulation Complete", + description: `Mock ${operation} result generated in ${duration}ms`, + }); + }, 800); + return; + } + + // Real mode - validate and make API call + if (!file) { + toast({ + title: "No file selected", + description: "Please select a PDF or XML file", + variant: "destructive", + }); + return; + } + + // Validate file type based on operation + const isXML = file.name.toLowerCase().endsWith('.xml'); + const isPDF = file.name.toLowerCase().endsWith('.pdf'); + + if (operation === "validate-xml" && !isXML) { + toast({ + title: "Invalid file type", + description: "Please select an XML file for XML validation", + variant: "destructive", + }); + return; + } + + if ((operation === "validate-pdf" || operation === "extract") && !isPDF) { + toast({ + title: "Invalid file type", + description: "Please select a PDF file for this operation", + variant: "destructive", + }); + return; + } + + setIsLoading(true); + setResult(null); + const startTime = performance.now(); + + try { + const formData = new FormData(); + formData.append("file", file); + + const response = await fetch(`${getUrlInfo().siteUrl}/${getApiEndPoint(operation)}`, { + method: "POST", + body: formData, + }); + + const data = await response.json() as OperationResultMap[typeof operation]; + console.log(data); + setResult(data); + const duration = Math.round(performance.now() - startTime); + + + const operationLabels: Record = { + "validate-pdf": "PDF Validation", + "validate-xml": "XML Validation", + "extract": "XML Extraction", + "convert-xml-to-pdf": "XML to PDF Conversion" + }; + + const toastTitle: string = `${operationLabels[operation]}`; + type toastVariantType = "default" | "success" | "info" | "warning" | "destructive"; + let toastVariant: toastVariantType = data.success ? "success" : "destructive"; + + if (operation === "validate-pdf" || operation === "validate-xml") { + const validationResult = data as PdfFileValidationResult | FileValidationResult; + if (validationResult?.isValid === false) { + toastVariant = "destructive"; + } else if (operation === "validate-pdf") { + const pdfResult = data as PdfFileValidationResult; + if (!pdfResult.isPdfValid) { + toastVariant = "warning"; + } + } + } + + const getDescription = () => { + const fileSize = (file.size / 1024).toFixed(1); + const timeStr = duration < 1000 ? `${duration}ms` : `${(duration / 1000).toFixed(2)}s`; + + switch (operation) { + case "extract": + return data.errorCode === "Success" + ? `XML extracted successfully from ${file.name} (${fileSize} KB) in ${timeStr}` + : `XML extraction failed after ${timeStr}. ${data.errorMessage || `Error code: ${data.errorCode}`}`; + case "convert-xml-to-pdf": + return data.errorCode === "Success" + ? `PDF generated successfully from ${file.name} (${fileSize} KB) in ${timeStr}` + : `PDF generation failed after ${timeStr}. ${data.errorMessage || `Error code: ${data.errorCode}`}`; + case "validate-pdf": { + const pdfResult = data as PdfFileValidationResult; + if (!pdfResult.isValid) + return `${file.name} (${fileSize} KB) is invalid. Processed in ${timeStr}`; + if (!pdfResult.isPdfValid) + return `${file.name} (${fileSize} KB) is valid. PDF/A-3 has warnings (BR-FX-DE-03). Processed in ${timeStr}`; + return `${file.name} (${fileSize} KB) is valid. Processed in ${timeStr}`; + } + case "validate-xml": { + const xmlResult = data as FileValidationResult; + return xmlResult.isValid + ? `${file.name} (${fileSize} KB) is valid. Processed in ${timeStr}` + : `${file.name} (${fileSize} KB) validation failed. Processed in ${timeStr}`; + } + } + }; + + toast({ + title: toastTitle, + description: getDescription(), + variant: toastVariant, + }); + } catch (error) { + const duration = Math.round(performance.now() - startTime); + const timeStr = duration < 1000 ? `${duration}ms` : `${(duration / 1000).toFixed(2)}s`; + + toast({ + title: "Operation Failed", + description: `${error instanceof Error ? error.message : "Operation failed"} (after ${timeStr})`, + variant: "destructive", + }); + } finally { + setIsLoading(false); + } + }; + + const getEscapedString = (input: string) => { + return input?.replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'") ?? ""; + } + + const getResultFile = (source: BaseFileOperationResult) => { + let result = (source as FileValidationResult | PdfFileValidationResult)?.validationReport || + (source as ExtractXmlFromPdfResult)?.xml; + + if (!result) { + result = (source as ConvertXmlToPdfResult)?.pdf; + } + + return result; + } + + return ( +
+ {/* Header */} +
+
+

+ Docentric e-Invoice Document Validator +

+

+ A lightweight REST API wrapper around the Mustang-Project CLI for + validating ZuGFeRD, Factur-X and UBL documents. Fast, reliable, and easy to integrate. +

+
+
+ {isLoadingHealth ? ( + <> + + Java: + Checking... + + ) : healthData?.javaPresent ? ( + <> + + Java: + Available ({healthData.javaVersion}) + + ) : ( + <> + + Java: + Not available + + )} +
+
+ {isLoadingHealth ? ( + <> + + Mustang CLI: + Checking... + + ) : healthData?.mustangCliPresent ? ( + <> + + Mustang CLI: + Available (v{healthData.mustangCliVersion}) + + ) : ( + <> + + Mustang CLI: + Not available + + )} +
+
+
+
+ +
+ {/* Interactive Tester Section */} +
+ +
+
+
+

Test your ZuGFeRD, Factur-X or UBL + e-Invoices

+

+ Upload, validate, extract or convert files +

+
+ {simulationModeEnabled && ( +
+ + + +
+ )} +
+
+ +
+ {/* File Upload Area */} +
+ + +
+ + {/* Operation Selection */} +
+ +
+ + + + +
+
+ + + + {/* Results */} + {result && ( + +
+ {result.success === true ? ( + + ) : ( + /**/ + + )} +
+

{result.success ? "valid" : "invalid"}

+ {/*

*/} + {/* Exit code: {result.errorCode}*/} + {/* {result.errorCode !== 0 && ` - ${result.errorMessage}`}*/} + {/*

*/} +
+
+ + {/* PDF sub-validation breakdown */} + {operation === "validate-pdf" && (() => { + const pdfResult = result as PdfFileValidationResult; + return ( +
+
+ {pdfResult.isXmlValid + ? + : } + XML invoice +
+
+ {pdfResult.isPdfValid + ? + : } + PDF/A-3 + {!pdfResult.isPdfValid && pdfResult.isValid && ( + (warning only - valid under BR-FX-DE-03) + )} +
+
+ {pdfResult.isSignatureValid + ? + : } + Signature +
+
+ ); + })()} + + {((result as FileValidationResult | PdfFileValidationResult)?.validationReport !== undefined || (result as ExtractXmlFromPdfResult)?.xml !== undefined || (result as ConvertXmlToPdfResult)?.pdf) !== undefined && ( +
+
+

+ + {operation === "extract" ? "Extracted XML" : operation === "convert-xml-to-pdf" ? "Generated PDF document in base64 encoding" : "Validation report"} +

+ +
+
+                        {getResultFile(result)}
+                      
+
+ )} + + {result.diagnosticsErrorMessage && ( +
+

Diagnostics

+
+                        {getEscapedString(result.diagnosticsErrorMessage)}
+                      
+
+ )} +
+ )} +
+
+
+ + {/* Sample Files Section */} +
+

Sample Files

+ +
+
+ +
+
+

ZUGFeRD Sample Corpus

+

+ Access a comprehensive collection of sample files including ZUGFeRD, Factur-X, and UBL formats to test + the validator with real-world examples. +

+ +
+
+
+
+ + {/* API Documentation */} +
+

API Documentation

+ + +
+

Interactive API Documentation

+

+ Explore and test all API endpoints with our interactive documentation +

+
+ +
+
+
+ + {/* Licensing Section */} +
+
+

Licensing Information

+ + +
+

Open Source Licenses

+

+ This project and its dependencies are licensed under open source licenses +

+
+ +
+ {/* Project License */} +
+

Project License (MIT)

+
+

MIT License

+

Copyright (c) 2026 Docentric

+

+ Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: +

+

+ The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. +

+

+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. +

+
+
+ + {/* Third-Party License */} +
+

Third-Party Components

+ +
+
+
+ +
+
+
Mustang project / Mustang-CLI
+

+ This project integrates with the Mustang project CLI for ZUGFeRD/Factur-X/UBL + validation and PDF/A processing. +

+
+

+ Project:{" "} + + https://mustangproject.org/ + +

+

+ Mustang-CLI:{" "} + + https://www.mustangproject.org/commandline/ + +

+

+ Repository:{" "} + + https://github.com/ZUGFeRD/mustangproject + +

+

+ License: Apache License 2.0 +

+

+ Copyright © 2013-2025 ZUGFeRD Association e.V. +

+
+
+
+
+
+
+ + {/* Acknowledgements */} +
+

Acknowledgements

+
+

+ We gratefully acknowledge the Mustang project team and the ZUGFeRD Association + for their excellent work in creating and maintaining the Mustang-CLI tool, + which enables validation, extraction, and processing of ZUGFeRD/UBL/Factur-X documents. +

+

+ If you bundle the Mustang-CLI JAR or Docker image with this project, the full + Apache License 2.0 text should be included in your distribution. +

+
+
+ + {/* Kudos to Lovable */} +
+

Kudos to Lovable

+
+

+ Special thanks to the Lovable platform for + accelerating development, improving code quality, and making the overall project experience smoother + and more enjoyable. +

+
+
+
+
+
+
+ + {/* Footer */} + +
+ ); +}; + +export default Index; diff --git a/src/WebUI/vite.config.ts b/src/WebUI/vite.config.ts index b8b3881..3c3e509 100644 --- a/src/WebUI/vite.config.ts +++ b/src/WebUI/vite.config.ts @@ -1,30 +1,30 @@ -import { defineConfig } from "vite"; -import react from "@vitejs/plugin-react-swc"; -import path from "path"; - -// https://vitejs.dev/config/ -export default defineConfig(() => ({ - server: { - host: "::", - port: 53365, - strictPort: true, - proxy: { - "/api": { - target: 'https://localhost:57456', - changeOrigin: true, - secure: false, // if you use a self-signed dev cert - }, - "/openapi/v1.json": { - target: 'https://localhost:57456', - changeOrigin: true, - secure: false, // if you use a self-signed dev cert - } - }, - }, - plugins: [react()], - resolve: { - alias: { - "@": path.resolve(__dirname, "./src"), - }, - }, -})); +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react-swc"; +import path from "path"; + +// https://vitejs.dev/config/ +export default defineConfig(() => ({ + server: { + host: "::", + port: 53365, + strictPort: true, + proxy: { + "/api": { + target: 'https://localhost:57456', + changeOrigin: true, + secure: false, // if you use a self-signed dev cert + }, + "/openapi/v1.json": { + target: 'https://localhost:57456', + changeOrigin: true, + secure: false, // if you use a self-signed dev cert + } + }, + }, + plugins: [react()], + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + }, + }, +})); diff --git a/src/docker-compose.yml b/src/docker-compose.yml index 941f436..8e5fa38 100644 --- a/src/docker-compose.yml +++ b/src/docker-compose.yml @@ -1,75 +1,75 @@ -# This controls the naming of everything: images, networks, containers -name: docentric-e-invoice-validator - -services: - e-invoice-validator: - # Explicit image name + tag (built locally) - image: docentric/e-invoice-validator:latest - - # Build settings - build: - context: . - dockerfile: Dockerfile - # Build arguments for versioning - args: - VERSION: ${VERSION:-2026.3.0} - BUILD_DATE: ${BUILD_DATE:-$(date -u +"%Y-%m-%dT%H:%M:%SZ")} - VCS_REF: ${VCS_REF:-$(git rev-parse --short HEAD)} - # Multi-platform builds (optional, requires buildx) - # platforms: - # - linux/amd64 - # - linux/arm64 - - # Container name (what appears in `docker ps`) - container_name: docentric-e-invoice-validator - - # Ports (HTTP only — no HTTPS certs inside container) - ports: - - "8080:8080" - - # Clean environment config for ASP.NET Core - environment: - ASPNETCORE_ENVIRONMENT: "Docker" - ASPNETCORE_URLS: "http://+:8080" - ASPNETCORE_HTTP_PORTS: "8080" - # Optional: Application insights, logging, etc. - TZ: "UTC" - - # Optional: logs directory - volumes: - - ./logs:/app/logs - - # Restart policy - restart: unless-stopped - - # Resource limits for production - deploy: - resources: - limits: - cpus: '2.0' - memory: 2G - reservations: - cpus: '0.5' - memory: 512M - - # Healthcheck - healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:8080/api/health"] - interval: 30s - timeout: 3s - retries: 3 - start_period: 10s - - # Security options - security_opt: - - no-new-privileges:true - - # Read-only root filesystem (if your app supports it) - # read_only: true - # tmpfs: - # - /tmp - # - /app/logs - -volumes: - app-logs: - driver: local +# This controls the naming of everything: images, networks, containers +name: docentric-e-invoice-validator + +services: + e-invoice-validator: + # Explicit image name + tag (built locally) + image: docentric/e-invoice-validator:latest + + # Build settings + build: + context: . + dockerfile: Dockerfile + # Build arguments for versioning + args: + VERSION: ${VERSION:-2026.3.0} + BUILD_DATE: ${BUILD_DATE:-$(date -u +"%Y-%m-%dT%H:%M:%SZ")} + VCS_REF: ${VCS_REF:-$(git rev-parse --short HEAD)} + # Multi-platform builds (optional, requires buildx) + # platforms: + # - linux/amd64 + # - linux/arm64 + + # Container name (what appears in `docker ps`) + container_name: docentric-e-invoice-validator + + # Ports (HTTP only — no HTTPS certs inside container) + ports: + - "8080:8080" + + # Clean environment config for ASP.NET Core + environment: + ASPNETCORE_ENVIRONMENT: "Docker" + ASPNETCORE_URLS: "http://+:8080" + ASPNETCORE_HTTP_PORTS: "8080" + # Optional: Application insights, logging, etc. + TZ: "UTC" + + # Optional: logs directory + volumes: + - ./logs:/app/logs + + # Restart policy + restart: unless-stopped + + # Resource limits for production + deploy: + resources: + limits: + cpus: '2.0' + memory: 2G + reservations: + cpus: '0.5' + memory: 512M + + # Healthcheck + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/api/health"] + interval: 30s + timeout: 3s + retries: 3 + start_period: 10s + + # Security options + security_opt: + - no-new-privileges:true + + # Read-only root filesystem (if your app supports it) + # read_only: true + # tmpfs: + # - /tmp + # - /app/logs + +volumes: + app-logs: + driver: local