Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d2b902baf |
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(dir:*)",
|
||||
"Bash(do)",
|
||||
"Bash(done)",
|
||||
"Bash(echo:*)",
|
||||
"Bash(find:*)",
|
||||
"Bash(for:*)",
|
||||
"Bash(gh pr diff:*)",
|
||||
"Bash(gh pr view:*)",
|
||||
"Bash(git log:*)",
|
||||
"Bash(grep:*)",
|
||||
"Bash(npm run build:*)",
|
||||
"Bash(npm run check:*)",
|
||||
"Bash(npm run compile:*)",
|
||||
"Bash(npm run lint:*)",
|
||||
"Bash(npm run:*)",
|
||||
"Bash(npx eslint:*)",
|
||||
"Bash(npx tsc:*)",
|
||||
"Bash(tree:*)",
|
||||
"Bash(gh issue view:*)",
|
||||
"Bash(npm test:*)",
|
||||
"mcp__umbraco__create*",
|
||||
"mcp__umbraco__get*",
|
||||
"mcp__playwright__browser_click",
|
||||
"mcp__playwright__browser_type",
|
||||
"mcp__playwright__browser_wait_for"
|
||||
]
|
||||
},
|
||||
"enableAllProjectMcpServers": true,
|
||||
"enabledMcpjsonServers": [
|
||||
"umbraco-cms"
|
||||
]
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
---
|
||||
name: umb-bump-version
|
||||
description: Bump the Umbraco CMS version across all required files. Use when the user asks to bump, update, or set the version number — e.g., "bump version to 17.3.4", "set version to 18.0.0-rc", "update version". Accepts the target version as an argument.
|
||||
argument-hint: <version> (e.g., 17.3.4, 18.0.0-rc)
|
||||
---
|
||||
|
||||
# Bump Version - Umbraco CMS
|
||||
|
||||
Updates the Umbraco CMS version string across all files that track it.
|
||||
|
||||
**Do NOT use AskUserQuestion if a version argument is provided. Only ask if `$ARGUMENTS` is empty or cannot be parsed as a version.**
|
||||
|
||||
## Arguments
|
||||
|
||||
- `$ARGUMENTS` - Required: the target version string (e.g., `17.3.4`, `18.0.0-rc`)
|
||||
|
||||
## Files to Update
|
||||
|
||||
The following 5 files must be updated with the new version:
|
||||
|
||||
| # | File | Field |
|
||||
|---|------|-------|
|
||||
| 1 | `version.json` | `"version"` |
|
||||
| 2 | `src/Umbraco.Web.UI.Client/package.json` | `"version"` |
|
||||
| 3 | `src/Umbraco.Web.UI.Client/package-lock.json` | top-level `"version"` AND `packages[""].version` |
|
||||
| 4 | `tests/Umbraco.Tests.AcceptanceTest/package.json` | `"version"` |
|
||||
| 5 | `tests/Umbraco.Tests.AcceptanceTest/package-lock.json` | top-level `"version"` AND `packages[""].version` |
|
||||
|
||||
**Note**: For major version bumps (e.g., 17.x to 18.x), `src/Umbraco.Web.UI.Login/package.json` has a caret-ranged dependency on `@umbraco-cms/backoffice` (e.g., `^17.2.0`) that will need manual updating. This skill does not handle that — major bumps involve many other changes beyond version strings.
|
||||
|
||||
## Instructions
|
||||
|
||||
### 1. Parse and Validate the Version
|
||||
|
||||
Extract the version from `$ARGUMENTS`. It must be a valid semver-like string (e.g., `17.3.4`, `18.0.0-rc`, `17.4.0-preview.1`). If no version is provided or it cannot be parsed, ask the user for the target version.
|
||||
|
||||
### 2. Read the Current Version
|
||||
|
||||
Read `version.json` and extract the current `"version"` value. If the current version already equals the target version, report that the version is already set and stop — do not edit, stage, or commit anything.
|
||||
|
||||
Otherwise, display both versions:
|
||||
|
||||
```
|
||||
Bumping version: {current} -> {target}
|
||||
```
|
||||
|
||||
### 3. Update All Files
|
||||
|
||||
Update each of the 5 files listed above, replacing the old version with the new version. For each file:
|
||||
|
||||
- **`version.json`**: Replace the `"version"` value.
|
||||
- **`package.json` files**: Replace the `"version"` value (near the top of the file).
|
||||
- **`package-lock.json` files**: Replace BOTH the top-level `"version"` value AND the `"version"` inside the `"packages": { "": { ... } }` block. These are always in the first ~10 lines of the file.
|
||||
|
||||
Use targeted edits — do NOT rewrite entire files. Be precise to avoid changing version strings in dependency entries.
|
||||
|
||||
### 4. Verify
|
||||
|
||||
After all edits, grep for the target version value across the 5 files to confirm all updates landed correctly:
|
||||
|
||||
```bash
|
||||
grep -n "\"version\": \"{version}\"" version.json src/Umbraco.Web.UI.Client/package.json src/Umbraco.Web.UI.Client/package-lock.json tests/Umbraco.Tests.AcceptanceTest/package.json tests/Umbraco.Tests.AcceptanceTest/package-lock.json
|
||||
```
|
||||
|
||||
Expect exactly 7 matches (one per `package.json` and `version.json`, two per `package-lock.json`).
|
||||
|
||||
### 5. Stage and Commit
|
||||
|
||||
Stage only the 5 changed files:
|
||||
|
||||
```bash
|
||||
git add version.json src/Umbraco.Web.UI.Client/package.json src/Umbraco.Web.UI.Client/package-lock.json tests/Umbraco.Tests.AcceptanceTest/package.json tests/Umbraco.Tests.AcceptanceTest/package-lock.json
|
||||
```
|
||||
|
||||
Then commit with the message `Bump version to {version}.` — replacing `{version}` with the target version:
|
||||
|
||||
```bash
|
||||
git commit -m "Bump version to {version}."
|
||||
```
|
||||
|
||||
### 6. Report
|
||||
|
||||
Output a summary:
|
||||
|
||||
```
|
||||
Version bumped to {version} in:
|
||||
- version.json
|
||||
- src/Umbraco.Web.UI.Client/package.json
|
||||
- src/Umbraco.Web.UI.Client/package-lock.json
|
||||
- tests/Umbraco.Tests.AcceptanceTest/package.json
|
||||
- tests/Umbraco.Tests.AcceptanceTest/package-lock.json
|
||||
|
||||
Changes staged and committed.
|
||||
```
|
||||
@@ -1,251 +0,0 @@
|
||||
---
|
||||
name: umb-review
|
||||
description: Automated PR code review for Umbraco CMS. Analyzes changed files for intent, impact on consumers, breaking changes, architecture compliance, and code quality. Non-interactive — outputs a full structured review. Use this skill whenever the user asks to review a branch, review a PR, check their changes for issues, analyze a diff, or validate breaking change patterns — even if they don't say "review" explicitly. Does NOT apply to writing new code, fixing bugs, refactoring, explaining architecture, writing tests, or reviewing documentation content.
|
||||
argument-hint: <target-branch>
|
||||
---
|
||||
|
||||
# PR Review - Umbraco CMS
|
||||
|
||||
Automated, non-interactive PR code review. Analyzes changed files for intent, impact on consumers, breaking changes, architecture compliance, and code quality.
|
||||
|
||||
**Do NOT use AskUserQuestion at any point. This skill runs fully autonomously.**
|
||||
|
||||
## Arguments
|
||||
|
||||
- `$ARGUMENTS` - Optional: target branch to diff against (auto-detected from PR, falls back to `origin/main`)
|
||||
|
||||
## Instructions
|
||||
|
||||
### 0. Verify GH CLI is Available
|
||||
|
||||
Run `gh auth status`. If it fails, read `references/gh-cli-setup.md` and present the setup instructions to the user. Do not proceed with the review.
|
||||
|
||||
### 1. Resolve Target Branch
|
||||
|
||||
Determine the target branch for comparison using this priority order:
|
||||
|
||||
1. **Explicit argument**: If `$ARGUMENTS` is provided and non-empty, use it as the target branch
|
||||
2. **PR target branch**: If no argument, run `gh pr view --json baseRefName --jq '.baseRefName'` to detect the target branch of the current branch's open PR. If a PR exists, use `origin/{baseRefName}` as the target branch.
|
||||
3. **Fallback**: If no argument and no PR found (command fails or returns empty), default to `origin/main`
|
||||
|
||||
Store the resolved target branch for use in subsequent steps. Log which resolution method was used (e.g., "Target branch: `origin/v18/dev` (from PR #1234)").
|
||||
|
||||
### 2. Load Review Standards
|
||||
|
||||
#### 2a. Load coding preferences
|
||||
|
||||
Read the coding preferences and code review scoring criteria from:
|
||||
|
||||
- `references/coding-preferences.md` (relative to this skill file)
|
||||
|
||||
Parse and internalize all rules, conventions, scoring categories, and severity definitions. These are your review criteria.
|
||||
|
||||
#### 2b. Load area-specific documentation
|
||||
|
||||
Once the changed file list is known (after step 3a), determine which areas of the codebase are touched and load the relevant documentation. Execute this sub-step between 3a and 3b. This documentation takes precedence over sibling comparison for architectural and pattern validation.
|
||||
|
||||
**Resolution order for each changed file:**
|
||||
|
||||
1. **Find the nearest `CLAUDE.md`** — walk up from the changed file's directory toward the repository root. The first `CLAUDE.md` found is the area guide for that file. Read it.
|
||||
2. **Read referenced docs** — if the `CLAUDE.md` references documentation files (e.g., a `docs/` directory), use the descriptions in the `CLAUDE.md` to determine which docs are relevant to the type of code being changed, and read those. If unsure, read all referenced docs — the cost of reading is low, the cost of missing a convention is high.
|
||||
3. **Follow cross-references in loaded docs** — if a loaded doc references another doc as covering a complementary or related concern, and the changed files touch that concern, read the referenced doc too. Repeat until no new relevant cross-references remain.
|
||||
4. **Check for applicable skills** — review the available skills list. If a skill exists for the type of code being changed, read the skill file to understand the expected patterns, structure, and conventions it enforces. Do NOT invoke the skill — just use it as a reference for what the correct implementation should look like.
|
||||
|
||||
**Store all loaded documentation** for use in step 4. These docs define the authoritative patterns and conventions that the review evaluates against.
|
||||
|
||||
### 3. Gather Changed Files
|
||||
|
||||
#### 3a. Collect file list, stats, and diff
|
||||
|
||||
Run these git commands (where `{target}` is the resolved target branch):
|
||||
|
||||
```bash
|
||||
git diff {target}...HEAD --name-only --diff-filter=d # changed files (excluding deleted)
|
||||
git diff {target}...HEAD --stat # line counts per file
|
||||
git log {target}...HEAD --oneline # commit history
|
||||
git diff {target}...HEAD # full diff (primary review source)
|
||||
```
|
||||
|
||||
**If no changes found**: Output "No changes found between current branch and `{target}`. Nothing to review." and stop.
|
||||
|
||||
#### 3b. Filter out noise files
|
||||
|
||||
From the changed file list, classify each file as **noise** or **reviewable**.
|
||||
|
||||
**Noise files** (skip entirely — do not read, do not review):
|
||||
|
||||
| Pattern | Reason |
|
||||
| ---------------------------------------------------- | ------------------------------- |
|
||||
| `*.gen.ts`, `*.gen.cs` | Auto-generated API client code |
|
||||
| `*.generated.cs`, `*.Designer.cs` (in `Migrations/`) | Auto-generated models/snapshots |
|
||||
| `*/assets/lang/*.ts` (except `en.ts`) | Non-English translation files |
|
||||
| `*/mocks/data/*.ts` | Test fixture data |
|
||||
| `*/dist-cms/*`, `*/storybook-static/*` | Build output |
|
||||
| `*/TEMP/InMemoryAuto/*` | Runtime-generated models |
|
||||
| `package-lock.json` | Dependency lock file |
|
||||
| `appsettings-schema.*.json` | Generated JSON schema |
|
||||
|
||||
Log the skip list: "Skipped {N} noise files: {comma-separated list of filenames}"
|
||||
|
||||
#### 3c. Read reviewable changed files
|
||||
|
||||
Read the full file for every reviewable changed file.
|
||||
|
||||
#### 3d. Track file counts
|
||||
|
||||
Keep track of these numbers for the review output in step 7: total changed files, noise files skipped, and reviewable files read. Also record: distinct production layers touched, distinct project directories, and total lines changed — these feed step 3e.
|
||||
|
||||
#### 3e. Assess PR complexity
|
||||
|
||||
Follow the procedure in `references/complexity-assessment.md`. Store the triggered dimensions and suggestions for step 7.
|
||||
|
||||
#### 3f. Classify PR scope
|
||||
|
||||
Classify the PR to determine which review steps are relevant:
|
||||
|
||||
| Classification | Condition | Effect |
|
||||
| --------------- | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- |
|
||||
| **Gen-only** | All reviewable files are `gen.ts` | Skip steps 5 and 6; step 4 reviews impact on other code only |
|
||||
| **Docs-only** | All reviewable files are `.md` | Skip steps 5 and 6; step 4 reviews intent and readability only |
|
||||
| **Test-only** | All reviewable files are in `tests/` | Skip steps 5 and 6; step 4 reviews intent, code quality, and test coverage only |
|
||||
| **Config-only** | All reviewable files are `.csproj`, `.props`, `.json` config, or CI/build files | Skip step 5; step 6 checks dependency version changes only |
|
||||
| **Standard** | Anything else | No skips — run all steps |
|
||||
|
||||
### 4. Raw Code Review
|
||||
|
||||
Review each changed file holistically. Think like a senior developer reading a colleague's PR. Note all findings without worrying about format or severity yet.
|
||||
|
||||
#### 4a. Read and reason about each file
|
||||
|
||||
For each changed file, reason about: What does this code do? Is it correct? What's missing — validation, error handling, notifications, cleanup, edge cases? Could this break anything for consumers?
|
||||
|
||||
#### 4b. Validate against documentation and patterns
|
||||
|
||||
Use a **docs-first** approach: classify the code by what it does, check it against documented conventions, and only fall back to sibling comparison when docs don't cover the pattern.
|
||||
|
||||
**Step 1 — Determine the correct approach from documentation, then check whether the PR matches**
|
||||
|
||||
A PR is a proposed solution, not the source of truth. This step has two parts that must happen in order — do not start part B until part A is complete.
|
||||
|
||||
**Part A — Before validating/judging the implementation**, determine what the correct approach is for each new class or file based on what it does. Use the documentation loaded in step 2b to identify the expected base classes, patterns, and conventions. Write down the expected approach. Classify based on what the code does, not based on what neighboring files look like.
|
||||
|
||||
**Part B — Now compare the PR's implementation** against the expected approach from Part A. If it deviates from the documented approach, flag it. If the documentation specifies reference examples, read those examples to verify the implementation matches.
|
||||
|
||||
**Pattern match is the leading finding.** If the documentation defines a pattern that fits what the code does, the first and most important finding is whether the code follows that pattern.
|
||||
|
||||
**Step 2 — Fall back to sibling comparison**
|
||||
|
||||
If the documentation does not cover the specific pattern, or for cross-cutting concerns not addressed in docs, fall back to sibling comparison:
|
||||
|
||||
1. **New method on existing class/interface**: Grep for the most similar existing method on the same class using `-A 80` to capture the full method body (e.g., `UpdateCurrentUserAsync` → grep for `UpdateAsync` in the same file with `-A 80`). Compare line by line for missing cross-cutting concerns: notifications/events, validation, scoping, authorization, error handling, audit logging.
|
||||
2. **New TS class**: Grep for siblings by base class (`extends {BaseClass}`) or by interface (`implements {Interface}`) or by name suffix (e.g., `CurrentUserController` → grep for `UserController`). Compare for missing concerns.
|
||||
3. **New CS class**: Grep for siblings by base class (`class {ClassName} : {BaseClass}`) or by interface (`class {ClassName} : {Interface}`) or by name suffix (e.g., `ManagementApiComposer` → grep for `ApiComposer`). Compare for missing concerns.
|
||||
|
||||
**Important:** Sibling comparison validates cross-cutting concerns, but it must not override documented conventions. If a sibling deviates from documented patterns, that sibling is wrong — do not copy its deviation.
|
||||
|
||||
Store your raw findings — they feed into step 7.
|
||||
|
||||
### 5. Impact Analysis
|
||||
|
||||
**Skip this step if PR scope is docs-only, test-only, or config-only.**
|
||||
|
||||
Follow the procedure in `references/impact-analysis.md`.
|
||||
|
||||
### 6. Breaking Changes Check
|
||||
|
||||
**Skip this step if PR scope is docs-only or test-only. If config-only, only check for dependency version changes that could break consumers.**
|
||||
|
||||
Follow the procedure in `references/breaking-changes.md`.
|
||||
|
||||
### 7. Consolidate and Output Review
|
||||
|
||||
Merge findings from step 4 (raw review), step 5 (impact analysis), and step 6 (breaking changes). For each finding, assign severity (Critical/Important/Suggestion) and verify it relates to changed code — not pre-existing issues. Before outputting, drop any finding about whitespace, blank lines, formatting, or comment wording. Then present the review in this exact format:
|
||||
|
||||
```markdown
|
||||
## PR Review
|
||||
|
||||
**Target:** `{target_branch}` · **Based on commit:** `{head_sha}`
|
||||
[If any skipped files, append: · **Skipped:** {skipped} files out of {total} total]
|
||||
[If step 3f classification is not "Standard", append: · **Classified as:** {classification}]
|
||||
|
||||
[1–2 sentences: what this PR accomplishes , keep it as short as possible, only highlight the primary essence.]
|
||||
|
||||
- **Modified public API:** {changed existing interfaces/types/classes/methods}
|
||||
[Omit bullet if none]
|
||||
- **Affected implementations (outside this PR):** {interfaces/types/classes/methods using modified public API}
|
||||
[Omit bullet if none]
|
||||
- **Breaking changes:** {violations with specifics}
|
||||
[Omit bullet if none]
|
||||
- **Other changes:** {changes not listed above that an Umbraco user, plugin developer, or API consumer would notice — e.g., behavior changes, default value changes, error message changes, new configuration options, removed functionality. Exclude internal renames, formatting, and private implementation details.}
|
||||
[Omit bullet if none]
|
||||
|
||||
[If step 3e triggered any dimensions, insert this block. Omit entirely if nothing triggered:]
|
||||
|
||||
> [!NOTE]
|
||||
> **Complexity advisory** — This PR may benefit from splitting.
|
||||
>
|
||||
> - **{Dimension}:** {Explanation and concrete split suggestion from step 3e}
|
||||
> [one bullet per triggered dimension]
|
||||
>
|
||||
> _This is an observation, not a blocker. The full review follows below._
|
||||
|
||||
---
|
||||
|
||||
### Critical
|
||||
|
||||
[Must fix before merge — security vulnerabilities, data loss, broken functionality, breaking changes without proper patterns]
|
||||
|
||||
- **`{file}:{line}`**: {problem} → {fix}
|
||||
|
||||
[Omit section if none]
|
||||
|
||||
### Important
|
||||
|
||||
[Should fix — performance issues, missing tests, architectural violations, pattern misuse]
|
||||
|
||||
- **`{file}:{line}`**: {observation} → {suggestion}
|
||||
|
||||
[Omit section if none]
|
||||
|
||||
### Suggestions
|
||||
|
||||
[Nice to have — readability, minor refactoring, alternative approaches]
|
||||
|
||||
- **`{file}:{line}`**: {detail}
|
||||
|
||||
[Omit section if none]
|
||||
|
||||
---
|
||||
|
||||
[One of:]
|
||||
|
||||
## Approved
|
||||
|
||||
This looks good to be merged as-is, but please do a manual sanity check and testing before merging.
|
||||
|
||||
## Approved with Suggestions for improvement
|
||||
|
||||
Good to go, but please carefully consider the importance of the suggestions.
|
||||
|
||||
## Request Changes
|
||||
|
||||
Critical and important issues must be addressed first.
|
||||
|
||||
## Needs re-work
|
||||
|
||||
This is in such a bad state that the feedback of this review is not sufficient to guide improvements, the PR cannot be approved.
|
||||
```
|
||||
|
||||
**Guidelines for the review output:**
|
||||
|
||||
— When reporting information, be extremely concise and sacrifice grammar for sake of concision.
|
||||
|
||||
- Only review code that was changed in the diff — pre-existing issues are out of scope. Focus on what compilers and linters cannot catch: behavioral side-effects (e.g., a changed default alters runtime behavior for consumers), architectural violations (e.g., a new dependency breaks layering), breaking changes for external consumers of the public API, and security implications. Leave type errors, missing imports, and broken references to CI.
|
||||
- Be specific — always reference file and line number
|
||||
- Explain WHY something is an issue, not just WHAT, but avoid stating the obvious.
|
||||
- For complex matters, provide concrete fix suggestions, including code snippets when helpful
|
||||
- Keep it constructive — the goal is to help, not gatekeep
|
||||
- Don't repeat the same finding for every occurrence — mention it once and note "same pattern in {other files}"
|
||||
- Focus on substantive issues only. Do NOT flag purely cosmetic or stylistic concerns. Specifically, never flag: code formatting or whitespace, comment grammar or wording, redundant-but-harmless syntax (e.g., optional chaining after a truthiness check), code duplication that doesn't cause bugs, or HTML template cosmetics. The only exception is when a stylistic issue has a concrete impact on performance or rendering. Note: missing JSDoc/documentation on public or exported APIs is a substantive finding (per coding preferences), not a cosmetic one — flag it as a Suggestion.
|
||||
- For breaking changes, reference the specific pattern from the CLAUDE.md that should be applied
|
||||
- Do not suggest changes that would themselves introduce breaking changes. If a suggestion would alter public API surface (e.g., changing return types, renaming public members), it is not appropriate for a PR targeting `main` within a major version. Only suggest non-breaking alternatives.
|
||||
@@ -1,97 +0,0 @@
|
||||
{
|
||||
"skill_name": "umb-review",
|
||||
"evals": [
|
||||
{
|
||||
"id": 0,
|
||||
"name": "pr-22214-large-frontend-refactor",
|
||||
"prompt": "Review the changes in PR #22214 (branch origin/pr/22214 targeting main). This is a large frontend refactor migrating create entity actions to use entityCreateOptionAction extensions, with deprecations.",
|
||||
"expected_output": "A structured review that identifies frontend deprecation patterns, flags the large PR complexity, handles 75+ files correctly, checks for breaking changes in exported components, and produces the correct output format.",
|
||||
"pr_number": 22214,
|
||||
"pr_branch": "origin/pr/22214",
|
||||
"base_branch": "origin/main",
|
||||
"files": [],
|
||||
"assertions": [
|
||||
{"id": "deprecation-patterns-noted", "text": "Review identifies deprecation patterns (@deprecated, UmbDeprecation)"},
|
||||
{"id": "frontend-breaking-change-awareness", "text": "Checks frontend-specific breaking changes (exports, custom elements) not just backend"},
|
||||
{"id": "file-references-present", "text": "Findings reference specific files with line numbers"},
|
||||
{"id": "no-false-critical-on-deprecations", "text": "Properly deprecated code is NOT flagged as Critical breaking change"},
|
||||
{"id": "no-stylistic-nitpicks", "text": "Review does not flag purely cosmetic/stylistic issues (formatting, whitespace, naming conventions, comment grammar, code style preferences) unless they affect performance or rendering. Missing JSDoc on new public APIs is NOT a stylistic issue — it is a legitimate finding."},
|
||||
{"id": "manifest-alias-rename-detected", "text": "Alias renames (CreateOptions → Create) flagged as Critical breaking change"},
|
||||
{"id": "non-exported-deletions-dismissed", "text": "Deleted action classes NOT flagged as breaking (verified against package.json exports)"},
|
||||
{"id": "noise-files-filtered", "text": "Does not review noise files (generated files, lock files, etc.)"},
|
||||
{"id": "complexity-advisory-triggers", "text": "Review includes a complexity/split advisory for the large 75+ file scope"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 1,
|
||||
"name": "pr-21672-small-frontend-bugfix",
|
||||
"prompt": "Review the changes in PR #21672 (branch origin/pr/21672 targeting main). This is a small 4-file frontend bugfix implementing tab validation badges in the block editor.",
|
||||
"expected_output": "A clean review that correctly identifies this as a small focused bugfix, avoids false positives, and either approves or approves with minor suggestions.",
|
||||
"pr_number": 21672,
|
||||
"pr_branch": "origin/pr/21672",
|
||||
"base_branch": "origin/main",
|
||||
"files": [],
|
||||
"assertions": [
|
||||
{"id": "complexity-advisory-absent", "text": "Review does NOT include a complexity/split advisory"},
|
||||
{"id": "no-false-breaking-changes", "text": "Review does not flag breaking changes"},
|
||||
{"id": "proportionate-verdict", "text": "Verdict is 'Request Changes'"},
|
||||
{"id": "concise-review", "text": "Review output is under 200 lines"},
|
||||
{"id": "no-stylistic-nitpicks", "text": "Review does not flag purely cosmetic/stylistic issues (formatting, whitespace, naming conventions, comment grammar, code style preferences) unless they affect performance or rendering. Missing JSDoc on new public APIs is NOT a stylistic issue — it is a legitimate finding."}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": "pr-22217-small-backend-webhook",
|
||||
"prompt": "Review the changes in PR #22217 (branch origin/pr/22217 targeting v18/dev). This is a tiny 3-file backend change to the default webhook payload type.",
|
||||
"expected_output": "A concise review that correctly resolves v18/dev as target branch, handles the small change proportionately, and considers the behavioral impact of changing a default value.",
|
||||
"pr_number": 22217,
|
||||
"pr_branch": "origin/pr/22217",
|
||||
"base_branch": "origin/v18/dev",
|
||||
"files": [],
|
||||
"assertions": [
|
||||
{"id": "correct-target-branch", "text": "Review references 'v18/dev' as the target branch (not 'main')"},
|
||||
{"id": "default-value-change-noted", "text": "Review discusses the behavioral impact of changing the default payload type"},
|
||||
{"id": "proportionate-review", "text": "Review output is under 150 lines"},
|
||||
{"id": "no-stylistic-nitpicks", "text": "Review does not flag purely cosmetic/stylistic issues (formatting, whitespace, naming conventions, comment grammar, code style preferences) unless they affect performance or rendering. Missing JSDoc on new public APIs is NOT a stylistic issue — it is a legitimate finding."},
|
||||
{"id": "ignores-preexisting-issues", "text": "Does NOT flag the ~30 builder extension methods with Legacy defaults (pre-existing, not changed in the PR)"},
|
||||
{"id": "side-effect-detection", "text": "Flags stale WebhookSettings.cs docs as a side-effect of the constant value change"},
|
||||
{"id": "consumer-identification", "text": "Identifies affected consumers outside the PR (WebhookSettings, UmbracoBuilder, or WebhookEventCollectionBuilderExtensions)"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"name": "pr-22268-frontend-feature-workspace-modal",
|
||||
"prompt": "Review the changes in PR #22268 (branch origin/pr/22268 targeting main). This is a 29-file frontend feature adding a current user workspace modal.",
|
||||
"expected_output": "A review of a medium-sized new feature PR. Should assess the new code for architectural compliance, check for breaking changes (new exports, custom elements), and evaluate code quality without flagging pre-existing issues.",
|
||||
"pr_number": 22268,
|
||||
"pr_branch": "origin/pr/22268",
|
||||
"base_branch": "origin/main",
|
||||
"files": [],
|
||||
"assertions": [
|
||||
{"id": "complexity-advisory-triggers", "text": "Review includes a complexity/split advisory (3 layers: Core, API, Frontend across 27+ files)"},
|
||||
{"id": "breaking-changes-on-interface-additions", "text": "Flags new interface methods without default implementations as breaking changes (Pattern 3)"},
|
||||
{"id": "no-stylistic-nitpicks", "text": "Review does not flag purely cosmetic/stylistic issues unless they affect performance or rendering. Missing JSDoc on new public APIs is NOT a stylistic issue — it is a legitimate finding."},
|
||||
{"id": "diff-scoped", "text": "All findings reference code that was changed in the diff, not pre-existing issues"},
|
||||
{"id": "new-feature-assessed", "text": "Review assesses the new feature's architecture, patterns, or integration approach — not just absence of bugs"},
|
||||
{"id": "no-false-notification-finding", "text": "Review does NOT flag UpdateCurrentUserAsync as missing UserSavingNotification/UserSavedNotification — the sibling UpdateAsync also does not publish these notifications, so flagging their absence would be a false positive"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"name": "pr-22215-frontend-architecture-violation",
|
||||
"prompt": "Review the changes in PR #22215 (branch origin/pr/22215 targeting main). This is a 2-file frontend feature adding user management to the user group workspace.",
|
||||
"expected_output": "A review that catches the architecture violation: the workspace context directly imports and calls UserService and UserGroupService (generated API clients) instead of going through a repository. In the Umbraco backoffice, workspace contexts access data via repositories, not by calling API services directly. The review should flag this as a significant architecture issue and request changes.",
|
||||
"pr_number": 22215,
|
||||
"pr_branch": "origin/pr/22215",
|
||||
"base_branch": "origin/main",
|
||||
"files": [],
|
||||
"assertions": [
|
||||
{"id": "service-bypass-detected", "text": "Review flags that the workspace context directly imports/calls UserService or UserGroupService instead of using a repository"},
|
||||
{"id": "repository-pattern-recommended", "text": "Review recommends using the repository pattern (going through a repository/data-source layer) rather than calling API services directly from the workspace context"},
|
||||
{"id": "verdict-request-changes", "text": "Verdict is 'Request Changes' (the architecture violation warrants requesting changes, not just approving with suggestions)"},
|
||||
{"id": "no-stylistic-nitpicks", "text": "Review does not flag purely cosmetic/stylistic issues (formatting, whitespace, naming conventions, comment grammar, code style preferences) unless they affect performance or rendering. Missing JSDoc on new public APIs is NOT a stylistic issue — it is a legitimate finding."},
|
||||
{"id": "no-false-breaking-changes", "text": "Review does not flag breaking changes (this PR only adds new code, no public API is removed or modified)"}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,249 +0,0 @@
|
||||
# Breaking Changes Reference
|
||||
|
||||
This document describes how to detect and validate breaking changes during PR review. It covers both backend (.NET) and frontend (TypeScript/Lit) patterns.
|
||||
|
||||
---
|
||||
|
||||
## Version Detection
|
||||
|
||||
**Always read `version.json`** at the repository root to determine the current major version. This drives the obsolete removal target calculation:
|
||||
|
||||
- Current major version: read from `version.json` → `version` field (e.g., `"17.4.0-rc"` → major version `17`)
|
||||
- Obsolete removal target: `current + 2` (e.g., if current is 17, removal is scheduled for Umbraco 19)
|
||||
- Format: `[Obsolete("... Scheduled for removal in Umbraco {current+2}.")]`
|
||||
|
||||
---
|
||||
|
||||
## Backend (.NET) Breaking Changes
|
||||
|
||||
### What Constitutes a Breaking Change
|
||||
|
||||
Any of these on a `public` or `protected` member:
|
||||
|
||||
- Removing or renaming a class, interface, struct, record, or enum
|
||||
- Removing or renaming a method, property, or field
|
||||
- Changing a method signature (parameters, return type)
|
||||
- Adding required parameters to an existing method
|
||||
- Adding methods to a public interface (without default implementation)
|
||||
- Changing a constructor signature on a public class
|
||||
- Removing or changing enum values
|
||||
- Changing type hierarchy (base class, implemented interfaces)
|
||||
|
||||
### Pattern 1: Obsolete Constructor + StaticServiceProvider
|
||||
|
||||
When a public class needs new dependencies, the existing constructor must be preserved.
|
||||
|
||||
**Correct pattern:**
|
||||
|
||||
```csharp
|
||||
[Obsolete("Please use the constructor with all parameters. Scheduled for removal in Umbraco 19.")]
|
||||
public MyService(IDependencyA depA)
|
||||
: this(
|
||||
depA,
|
||||
StaticServiceProvider.Instance.GetRequiredService<IDependencyB>())
|
||||
{
|
||||
}
|
||||
|
||||
public MyService(IDependencyA depA, IDependencyB depB)
|
||||
{
|
||||
_depA = depA;
|
||||
_depB = depB;
|
||||
}
|
||||
```
|
||||
|
||||
**Validation checklist:**
|
||||
|
||||
- [ ] Old constructor has `[Obsolete]` attribute with correct removal version
|
||||
- [ ] Old constructor calls new constructor via `: this(...)`
|
||||
- [ ] `StaticServiceProvider.Instance.GetRequiredService<T>()` used for new params only
|
||||
- [ ] DI registration uses the NEW constructor (old is for external consumers only)
|
||||
- [ ] Removal version is `{current_major + 2}`
|
||||
|
||||
**Common mistakes to flag:**
|
||||
|
||||
- Removing the old constructor entirely (breaking change!)
|
||||
- Old constructor NOT calling new constructor (code duplication)
|
||||
- Wrong removal version in `[Obsolete]`
|
||||
- Missing `StaticServiceProvider` resolution for new dependencies
|
||||
- DI registration still using the old constructor
|
||||
|
||||
### Pattern 2: Obsolete Method + New Overload
|
||||
|
||||
When a method signature needs to change, add the new overload and obsolete the old.
|
||||
|
||||
**Correct pattern:**
|
||||
|
||||
```csharp
|
||||
[Obsolete("Use the overload taking all parameters. Scheduled for removal in Umbraco 19.")]
|
||||
public void DoThing(string name)
|
||||
=> DoThing(name, extraParam: null);
|
||||
|
||||
public void DoThing(string name, string? extraParam)
|
||||
{
|
||||
// Real implementation here
|
||||
}
|
||||
```
|
||||
|
||||
**Validation checklist:**
|
||||
|
||||
- [ ] Old method has `[Obsolete]` attribute with correct removal version
|
||||
- [ ] Old method calls new method, providing defaults for new parameters
|
||||
- [ ] All internal callers updated to use the new method
|
||||
- [ ] No internal code references the obsolete method (except the delegation)
|
||||
|
||||
### Pattern 3: Default Interface Implementation
|
||||
|
||||
When adding methods to a public interface, provide a default implementation.
|
||||
|
||||
**Correct pattern:**
|
||||
|
||||
```csharp
|
||||
public interface IMyService
|
||||
{
|
||||
void ExistingMethod();
|
||||
|
||||
// New method with default implementation
|
||||
void NewMethod(string param)
|
||||
=> ExistingMethod(); // delegate to existing if possible
|
||||
}
|
||||
```
|
||||
|
||||
**Strategies for defaults (in order of preference):**
|
||||
|
||||
1. Use existing interface methods to satisfy the contract
|
||||
2. Return a sensible default (empty collection, null, etc.)
|
||||
3. Throw `NotImplementedException` if no reasonable default exists
|
||||
|
||||
**Validation checklist:**
|
||||
|
||||
- [ ] New interface method has a default implementation
|
||||
- [ ] TODO comment present: `// TODO (V{next-major}): Remove the default implementation when {obsolete method} is removed.`
|
||||
- [ ] Default implementation is functionally correct (even if not optimal)
|
||||
- [ ] If `StaticServiceProvider` is used in default impl, noted as temporary
|
||||
|
||||
### Obsolete Attribute Validation
|
||||
|
||||
For any `[Obsolete]` attribute found in changed code:
|
||||
|
||||
1. **Format**: Must contain `"Scheduled for removal in Umbraco {version}."`
|
||||
2. **Version**: Must be `current_major + 2` (read from `version.json`)
|
||||
3. **Pragma**: Where obsolete members must call each other, `#pragma warning disable CS0618` / `#pragma warning restore CS0618` must be present
|
||||
|
||||
### Internal Caller Check
|
||||
|
||||
After finding obsolete patterns, verify:
|
||||
|
||||
- Search the codebase for usages of the obsolete member
|
||||
- **No internal code** (inside `src/`) should reference obsolete members
|
||||
- Only the obsolete member's own delegation (calling the new version) is acceptable
|
||||
- External consumers (outside the repo) get the deprecation period to migrate
|
||||
|
||||
---
|
||||
|
||||
## Frontend (TypeScript/Lit) Breaking Changes
|
||||
|
||||
The backoffice is published as `@umbraco-cms/backoffice` with 140+ named exports. Plugin developers depend on this public API surface.
|
||||
|
||||
**Critical frontend rule (does not apply to backend .NET where `public`/`protected` visibility determines the API surface): only symbols reachable through the `package.json` `exports` field are public API.** Anything not exported — whether classes, functions, constants, types, or entire files — is an internal implementation detail, even if other internal code imports it. Removing or changing unexported frontend symbols is not a breaking change. Before flagging a frontend deletion or rename as breaking, verify the symbol is reachable via `package.json` exports. If it is not, do not flag it.
|
||||
|
||||
### Custom Elements (Web Components)
|
||||
|
||||
**Breaking changes:**
|
||||
|
||||
- Renaming or removing a registered custom element tag (`umb-*`)
|
||||
- Removing elements from `HTMLElementTagNameMap`
|
||||
- Removing or changing `@property()` decorated fields on exported components
|
||||
- Removing event emissions (checked via `this.dispatchEvent`)
|
||||
- Removing CSS custom properties (`@cssprop` in JSDoc)
|
||||
- Removing CSS parts (`@csspart` in JSDoc)
|
||||
|
||||
**How to detect:**
|
||||
|
||||
- Check diff for removed `@customElement('umb-...')` decorators
|
||||
- Check diff for removed `@property()` fields on exported components
|
||||
- Check diff for removed entries in `HTMLElementTagNameMap` declarations
|
||||
|
||||
### Exported Types/Interfaces
|
||||
|
||||
**Breaking changes:**
|
||||
|
||||
- Removing exports from `package.json` `exports` field
|
||||
- Changing the shape of exported interfaces (removing properties, changing types)
|
||||
- Renaming exported types (consumers import by name)
|
||||
- Removing union type members
|
||||
- Changing generic type parameter constraints
|
||||
|
||||
**How to detect:**
|
||||
|
||||
- Check if `package.json` `exports` field is modified
|
||||
- Check diff for removed `export` statements
|
||||
- Check diff for changed interface/type shapes
|
||||
|
||||
### Manifest/Extension System
|
||||
|
||||
**Breaking changes:**
|
||||
|
||||
- Renaming a manifest `alias` value — plugin developers reference aliases by string in conditions, overwrites, and extension registry lookups. Alias renames are not caught by the compiler since they are string-based. A renamed alias silently breaks any plugin that references the old string.
|
||||
- Removing support for a manifest `type` that plugins use
|
||||
- Changing manifest `alias` resolution or validation
|
||||
- Removing or renaming manifest `kind` types
|
||||
- Changing extension bundle structure
|
||||
|
||||
**How to detect:**
|
||||
|
||||
- **Alias renames**: Compare `alias:` values in manifest files before and after. Changed alias strings are Critical — the old alias should be preserved as a deprecated entry.
|
||||
- Search for changes to manifest type definitions
|
||||
- Check for removed or renamed manifest kinds
|
||||
|
||||
### Context API
|
||||
|
||||
**Breaking changes:**
|
||||
|
||||
- Removing context tokens from exports
|
||||
- Changing the shape of data provided by a context
|
||||
- Removing context provider/consumer mechanisms
|
||||
|
||||
**How to detect:**
|
||||
|
||||
- Check for removed context token exports
|
||||
- Check for changes to context provider classes
|
||||
|
||||
### Controllers/Lifecycle
|
||||
|
||||
**Breaking changes:**
|
||||
|
||||
- Changing controller base class inheritance requirements
|
||||
- Removing controller lifecycle hooks
|
||||
- Breaking cleanup mechanisms in `disconnectedCallback()`
|
||||
|
||||
### Observable/State
|
||||
|
||||
**Breaking changes:**
|
||||
|
||||
- Removing observable properties from the public API
|
||||
- Changing observable emission patterns
|
||||
|
||||
### npm Publishing
|
||||
|
||||
**Breaking changes:**
|
||||
|
||||
- Changing version constraints that exclude previously-supported versions
|
||||
- Adding incompatible peer dependency constraints
|
||||
|
||||
**How to detect:**
|
||||
|
||||
- Check if `package.json` `peerDependencies` or `dependencies` changed
|
||||
- Verify version ranges are not narrowed
|
||||
|
||||
---
|
||||
|
||||
## Reporting Breaking Changes
|
||||
|
||||
When a breaking change is detected, report:
|
||||
|
||||
1. **What**: The specific change and which public symbol is affected
|
||||
2. **Pattern**: Which mitigation pattern should be applied (Pattern 1, 2, or 3 for backend)
|
||||
3. **Severity**: Critical (no mitigation present) or Important (mitigation present but incorrect)
|
||||
4. **Fix**: Concrete code suggestion showing the correct pattern
|
||||
|
||||
If no breaking changes are detected, state: "No breaking changes detected."
|
||||
@@ -1,168 +0,0 @@
|
||||
# Coding Preferences & Review Criteria
|
||||
|
||||
These are the coding preferences and code review standards used by the review skill. They define what the review evaluates against.
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
- **Always create blackbox tests** for new/changed code
|
||||
- Choose the appropriate test level:
|
||||
- **Unit tests** for isolated logic
|
||||
- **Integration tests** for application services/use cases
|
||||
- **E2E tests** for API endpoints
|
||||
|
||||
### Test Class Naming
|
||||
|
||||
- Test classes must be postfixed with `Tests` (e.g., `OrderServiceTests`)
|
||||
- One test class per class under test
|
||||
|
||||
### Test Method Naming
|
||||
|
||||
**C# tests**: Use the `Can_`/`Cannot_` pattern with PascalCase underscore-separated words:
|
||||
|
||||
- `Can_Schedule_Publish_Invariant`
|
||||
- `Cannot_Delete_Non_Existing`
|
||||
- `Can_Schedule_Publish_Single_Culture`
|
||||
|
||||
Large test classes are split into partial files by method: `ContentServiceTests.Delete.cs`, `ContentServiceTests.Publish.cs`.
|
||||
|
||||
**TypeScript tests**: Use BDD-style `it()` with natural language descriptions:
|
||||
|
||||
- `it('should not allow the returned value to be lower than min')`
|
||||
- `it('converts string to camelCase')`
|
||||
|
||||
### Unit Tests
|
||||
|
||||
- Optional, but must be blackbox tests so refactoring does not break tests
|
||||
|
||||
### Integration Tests
|
||||
|
||||
- Every use case / application service must have integration tests
|
||||
- Tests run against real database (containerized or similar)
|
||||
- Test the full flow from application layer through infrastructure
|
||||
|
||||
### E2E Tests
|
||||
|
||||
- Every API endpoint must have E2E tests
|
||||
- Test realistic scenarios including error cases
|
||||
|
||||
---
|
||||
|
||||
## Trade-offs
|
||||
|
||||
When making decisions, prioritize:
|
||||
|
||||
- **Readability** over cleverness
|
||||
- **Flexibility** over rigidity
|
||||
- Explain trade-offs when deviating from these defaults
|
||||
|
||||
---
|
||||
|
||||
## Breaking Changes
|
||||
|
||||
- Communicate breaking changes at the **OpenAPI/openapi.json level**
|
||||
- Clearly document what changed and the migration path
|
||||
|
||||
---
|
||||
|
||||
## Documentation
|
||||
|
||||
- **Document all public or exported types** (classes, interfaces, types, methods, properties)
|
||||
- Keep documentation in sync with code changes
|
||||
- Add **JS Docs** on all public frontend APIs (classes, methods, properties)
|
||||
- Focus on "why" and usage, not restating the obvious
|
||||
|
||||
---
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Use what's available in the codebase, unless there is no good choice
|
||||
- **Flag new dependencies** for review — new packages should be justified
|
||||
- Prefer well-maintained, widely-used packages
|
||||
|
||||
---
|
||||
|
||||
## Error Messages & Logging
|
||||
|
||||
- **User-facing errors**: Clear, friendly, actionable
|
||||
- **Log messages**: Technical, detailed, with context
|
||||
- Include correlation IDs and relevant data in logs
|
||||
|
||||
---
|
||||
|
||||
## Security
|
||||
|
||||
- **Always check for security issues** using OWASP Top 10 as baseline
|
||||
- Flag potential vulnerabilities immediately
|
||||
- Suggest secure alternatives when spotting risky patterns
|
||||
- Apply principle of least privilege
|
||||
|
||||
---
|
||||
|
||||
## Immutability
|
||||
|
||||
- Prefer **immutability** by default
|
||||
- Allow internal properties to be mutated, as long as they are not direct references coming from the outside
|
||||
|
||||
---
|
||||
|
||||
## Nullability
|
||||
|
||||
- **TypeScript / JavaScript**
|
||||
- Prefer `undefined` for optional/omitted values (e.g., optional parameters, props, and fields)
|
||||
- Use `null` only when the domain model explicitly encodes "no value" or "not set" (e.g., `string | null` from APIs/DB), and be consistent with existing types
|
||||
- Avoid mixing `null` and `undefined` for the same concept within the same model or API surface
|
||||
|
||||
- **C#**
|
||||
- use nullable types (e.g., `string?`, `int?`) where absence is valid
|
||||
- Prefer domain modeling (value objects, options/results, empty collections) over `null` where appropriate, but respect existing conventions in the codebase
|
||||
|
||||
---
|
||||
|
||||
## C# Specific
|
||||
|
||||
- use Notification pattern (not C# events), Composer pattern (DI registration), Scoping with `Complete()`, Attempt pattern for operation results.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
- Follow **Clean Architecture** principles
|
||||
- **Fail-fast** principle: detect and report errors as early as possible
|
||||
- Within the established layered architecture (Core/Infrastructure/Web/API), organize code by feature inside each layer where practical, while preserving dependency direction
|
||||
- One class per file
|
||||
- Avoid N+1 queries
|
||||
- Profile before optimizing non-critical paths
|
||||
|
||||
### Type Hierarchy Consistency
|
||||
|
||||
When parallel model types have inconsistent relationships to a shared base type:
|
||||
|
||||
**TypeScript**: manipulations via `Omit`, `Pick`, intersection overrides, or workarounds like `as unknown as` / double-casts to bridge type mismatches.
|
||||
|
||||
**C#**: hiding base members with `new` to change types, explicit interface implementations to mask mismatches, or downcasting base return types in derived classes.
|
||||
|
||||
- **Do NOT suggest** the PR code should deviate from its base type to match a sibling that already deviates. Copying the deviation spreads the problem.
|
||||
- **Do flag** the architectural inconsistency: parallel models should share a compatible base contract. The model that manipulates or deviates from the base type is the one that needs attention — not the one that extends it correctly.
|
||||
- **Frame the suggestion** as: "These related models have inconsistent type hierarchies. `{deviating type}` manipulates the base contract of `{base type}`, which forces shared consumers like `{shared utility}` to require a shape that conforming subtypes can't satisfy."
|
||||
|
||||
---
|
||||
|
||||
## Code Style
|
||||
|
||||
- Follow standard naming conventions for the language (C# or JS/TS)
|
||||
- Keep components small and focused on a single responsibility
|
||||
- Prefer early returns
|
||||
- Small functions
|
||||
- No nested ternaries
|
||||
|
||||
---
|
||||
|
||||
## Severity Levels
|
||||
|
||||
| Severity | Meaning |
|
||||
|----------|---------|
|
||||
| **Critical** | Must fix before merge — security vulnerabilities, data loss risks, broken functionality |
|
||||
| **Important** | Should fix — performance issues, missing tests, architectural violations |
|
||||
| **Suggestion** | Nice to have — readability, minor refactoring, alternative approaches |
|
||||
@@ -1,33 +0,0 @@
|
||||
# PR Complexity Assessment
|
||||
|
||||
Evaluate whether the PR's scope suggests it should be split. This assessment is **informational only** — it never blocks or shortens the review.
|
||||
|
||||
## Always check: Formatting mixed with logic
|
||||
|
||||
This check applies to every PR regardless of size or scope.
|
||||
|
||||
Run both commands and compare per-file line counts:
|
||||
```bash
|
||||
git diff {target}...HEAD --stat
|
||||
git diff {target}...HEAD --stat --ignore-all-space
|
||||
```
|
||||
|
||||
For any file where the whitespace-ignored diff is less than **half** the full diff size (and the full diff is over 50 lines), that file has significant formatting changes mixed with logic. Flag it with a split suggestion: "File(s) {list} contain significant formatting changes mixed with logic. Consider a separate formatting-only commit or PR to keep the functional diff reviewable."
|
||||
|
||||
## Multi-project scope check
|
||||
|
||||
Skip this section entirely if ALL production files reside in a single project directory or if the PR is docs-only, test-only, dependency-bump-only, or rename-only.
|
||||
|
||||
Otherwise, flag any dimension that applies:
|
||||
|
||||
| Dimension | Condition | Suggestion |
|
||||
|---|---|---|
|
||||
| **Size** | 30+ files OR 1500+ lines, spanning 2+ projects | "If changes in {projectA} and {projectB} are independently functional, they could be separate PRs." |
|
||||
| **Layer spread** | 3+ layers touched (Core/Infrastructure/Web/API/Frontend), 10+ files | "Consider splitting by layer — e.g., Core+Infrastructure first, then API/Frontend consumers." |
|
||||
| **Mixed intent** | 2+ intent categories (new feature, bugfix, refactor, dependency update) with 15+ files or 3+ projects | "Consider extracting the {secondary intent} into a separate PR." |
|
||||
|
||||
Intent categories — detect from diff characteristics, not commit messages:
|
||||
- **New feature**: new files or new `public`/`export` declarations
|
||||
- **Bug fix**: small targeted edits, no new files (don't co-flag with new feature)
|
||||
- **Refactor**: file renames, symbols moved but logic unchanged
|
||||
- **Dependency update**: changes to `.csproj`, `Directory.Packages.props`, `package.json`
|
||||
@@ -1,23 +0,0 @@
|
||||
# GH CLI Setup Instructions
|
||||
|
||||
The GitHub CLI (`gh`) is required for this review skill to detect PR target branches.
|
||||
|
||||
## Installation
|
||||
|
||||
Install via Homebrew:
|
||||
|
||||
```
|
||||
brew install gh
|
||||
```
|
||||
|
||||
Or see https://cli.github.com/ for other installation methods.
|
||||
|
||||
## Authentication
|
||||
|
||||
After installing, authorize by running this in the terminal (use the `!` prefix in Claude Code):
|
||||
|
||||
```
|
||||
! gh auth login
|
||||
```
|
||||
|
||||
Follow the prompts to authenticate with your GitHub account.
|
||||
@@ -1,153 +0,0 @@
|
||||
# Impact Analysis Reference
|
||||
|
||||
This document describes how to perform impact analysis during PR review. The goal is to look beyond the diff to understand how changes affect consumers in other parts of the codebase.
|
||||
|
||||
---
|
||||
|
||||
## 1. Extract Changed Public Symbols
|
||||
|
||||
Scan the diff output for changes to public API surface:
|
||||
|
||||
### Backend (.NET)
|
||||
|
||||
Look for added, modified, or removed lines containing:
|
||||
|
||||
- `public class`, `public abstract class`, `public sealed class`
|
||||
- `public interface`
|
||||
- `public record`, `public struct`, `public enum`
|
||||
- `public` or `protected` methods, properties, fields
|
||||
- `public static` members
|
||||
- Constructor signatures on public types
|
||||
|
||||
### Frontend (TypeScript/Lit)
|
||||
|
||||
Look for changes to:
|
||||
|
||||
- `export class`, `export interface`, `export type`, `export enum`
|
||||
- `export function`, `export const`
|
||||
- `@property()` decorated fields on exported components
|
||||
- `@customElement()` registrations
|
||||
- Entries in `package.json` `exports` field
|
||||
|
||||
Collect a list of all changed public symbol names (type names, method names, property names).
|
||||
|
||||
---
|
||||
|
||||
## 2. Search for Consumers
|
||||
|
||||
For each changed public symbol, search the `src/` directory for usages **outside the changed file itself**.
|
||||
|
||||
### Grep Strategy
|
||||
|
||||
Use the Grep tool with these settings:
|
||||
|
||||
```
|
||||
pattern: {symbol name}
|
||||
path: src/
|
||||
output_mode: files_with_matches
|
||||
head_limit: 20
|
||||
```
|
||||
|
||||
Use `head_limit: 20` to avoid overwhelming results — if there are more than 20 consumers, note "20+ consumers found" and list the first 20.
|
||||
|
||||
### What to Search For
|
||||
|
||||
For each changed type/method, search for:
|
||||
|
||||
- **Type references**: class name, interface name (e.g., `IContentService`)
|
||||
- **Method calls**: method name in context (e.g., `\.GetById\(` for a method rename)
|
||||
- **Constructor usage**: `new TypeName(`
|
||||
- **DI registrations**: `.AddSingleton<IType, Type>`, `.AddScoped<`, `.AddTransient<`
|
||||
- **Notification handlers**: if a notification type changed, search for `INotificationHandler<NotificationTypeName>` and `INotificationAsyncHandler<NotificationTypeName>`
|
||||
- **Interface implementations**: if an interface changed, search for `: IInterfaceName` or `IInterfaceName,`
|
||||
|
||||
### Excluding the Changed File
|
||||
|
||||
When reporting consumers, exclude files that are part of the PR's changes (they're already being reviewed). The interesting consumers are those **outside** the PR that may be affected.
|
||||
|
||||
---
|
||||
|
||||
## 3. Check Dependency Flow Direction
|
||||
|
||||
The Umbraco architecture enforces strict unidirectional dependencies:
|
||||
|
||||
```
|
||||
Api.Management / Api.Delivery (depend on Api.Common)
|
||||
↓
|
||||
Api.Common (depends on Web.Common)
|
||||
↓
|
||||
Web.Common (depends on Infrastructure)
|
||||
↓
|
||||
Infrastructure (depends on Core)
|
||||
↓
|
||||
Core (no dependencies)
|
||||
```
|
||||
|
||||
### Layer Mapping
|
||||
|
||||
Map each changed file to its architectural layer:
|
||||
|
||||
| Path prefix | Layer |
|
||||
|---|---|
|
||||
| `src/Umbraco.Core/` | Core |
|
||||
| `src/Umbraco.Infrastructure/` | Infrastructure |
|
||||
| `src/Umbraco.PublishedCache.*` | Infrastructure |
|
||||
| `src/Umbraco.Examine.Lucene/` | Infrastructure |
|
||||
| `src/Umbraco.Cms.Persistence.*` | Infrastructure |
|
||||
| `src/Umbraco.Web.Common/` | Web |
|
||||
| `src/Umbraco.Web.UI/` | Web (Application) |
|
||||
| `src/Umbraco.Web.Website/` | Web |
|
||||
| `src/Umbraco.Cms.Api.Common/` | API |
|
||||
| `src/Umbraco.Cms.Api.Management/` | API |
|
||||
| `src/Umbraco.Cms.Api.Delivery/` | API |
|
||||
| `src/Umbraco.Web.UI.Client/` | Frontend |
|
||||
| `tests/` | Test |
|
||||
|
||||
### Violation Detection
|
||||
|
||||
Flag if a change introduces:
|
||||
|
||||
- **Core depending on Infrastructure**: Core file importing/referencing Infrastructure types
|
||||
- **Core depending on Web/API**: Core file importing/referencing Web or API types
|
||||
- **Infrastructure depending on Web/API**: Infrastructure file importing Web or API types
|
||||
- **Cross-API dependencies**: Management API depending on Delivery API or vice versa
|
||||
|
||||
### How to Check
|
||||
|
||||
1. For each changed file, identify its layer
|
||||
2. Read the file's `using` statements (C#) or `import` statements (TS)
|
||||
3. Check if any imports reference a higher layer
|
||||
4. Also check if new parameters or return types come from higher layers
|
||||
|
||||
---
|
||||
|
||||
## 4. Flag Cross-Project Risks
|
||||
|
||||
### High-Risk Patterns
|
||||
|
||||
These changes have high ripple potential:
|
||||
|
||||
- **Interface changes in Core** — all implementations in Infrastructure must be updated
|
||||
- **Notification type changes** — all handlers across the codebase are affected
|
||||
- **Base class changes** — all derived classes are affected
|
||||
- **Composer changes** — can affect DI container and runtime behavior globally
|
||||
- **Shared model/DTO changes** — can affect serialization, API contracts, and consumers
|
||||
|
||||
### What to Report
|
||||
|
||||
For each cross-project risk found, report:
|
||||
|
||||
1. **What changed**: The specific symbol and how it changed
|
||||
2. **Who is affected**: List of consuming files/projects found via Grep
|
||||
3. **Risk level**: Whether the consumers will break (compile error), behave differently (runtime), or are unaffected
|
||||
4. **Recommendation**: Whether the PR should include updates to affected consumers
|
||||
|
||||
---
|
||||
|
||||
## 5. Performance Notes
|
||||
|
||||
- Use `head_limit: 20` on all Grep searches to cap results
|
||||
- Only search for symbols that actually changed (not every symbol in the file)
|
||||
- For very common type names (e.g., `IScope`, `ILogger`), consider adding more context to the search pattern to reduce false positives
|
||||
- Skip impact analysis for test files — they don't have external consumers
|
||||
- Skip impact analysis for private/internal members — they can't have external consumers
|
||||
+12
-4
@@ -70,6 +70,18 @@ trim_trailing_whitespace = true
|
||||
[*.less]
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
##########################################
|
||||
# File Header (Uncomment to support file headers)
|
||||
# https://docs.microsoft.com/visualstudio/ide/reference/add-file-header
|
||||
##########################################
|
||||
|
||||
# [*.{cs,csx,cake,vb,vbx}]
|
||||
file_header_template = Copyright (c) Umbraco.\nSee LICENSE for more details.
|
||||
|
||||
# SA1636: File header copyright text should match
|
||||
# Justification: .editorconfig supports file headers. If this is changed to a value other than "none", a stylecop.json file will need to added to the project.
|
||||
# dotnet_diagnostic.SA1636.severity = none
|
||||
|
||||
##########################################
|
||||
# .NET Language Conventions
|
||||
# https://docs.microsoft.com/visualstudio/ide/editorconfig-language-conventions
|
||||
@@ -124,10 +136,6 @@ dotnet_code_quality_unused_parameters = all:warning
|
||||
dotnet_style_operator_placement_when_wrapping = end_of_line
|
||||
# https://github.com/dotnet/roslyn/pull/40070
|
||||
dotnet_style_prefer_simplified_interpolation = true:warning
|
||||
# File header preferences
|
||||
file_header_template = Copyright (c) Umbraco.\nSee LICENSE for more details.
|
||||
dotnet_diagnostic.SA1633.severity = none # Suppressed until we decide to enforce it
|
||||
dotnet_diagnostic.SA1636.severity = none # Suppressed since we are using StyleCop
|
||||
|
||||
# C# Code Style Settings
|
||||
# https://docs.microsoft.com/visualstudio/ide/editorconfig-language-conventions#c-code-style-settings
|
||||
|
||||
@@ -59,5 +59,4 @@
|
||||
# Generated files - hidden by default in GitHub diffs
|
||||
src/Umbraco.Web.UI.Client/src/packages/core/backend-api/** linguist-generated
|
||||
src/Umbraco.Web.UI.Login/src/api/** linguist-generated
|
||||
templates/UmbracoExtension/Client/src/api/** linguist-generated
|
||||
src/Umbraco.Cms.Api.Management/OpenApi.json linguist-generated
|
||||
|
||||
@@ -7,7 +7,7 @@ body:
|
||||
id: "version"
|
||||
attributes:
|
||||
label: "Which Umbraco version are you using?"
|
||||
description: "Please write the *exact* version, example: `10.1.0`. Click the Umbraco logo in the top left corner of the backoffice to find the version you're using."
|
||||
description: "Please write the *exact* version, example: `10.1.0`. Use the help icon in the Umbraco backoffice to find the version you're using"
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
|
||||
@@ -1 +1,218 @@
|
||||
The full development guide for this repository lives in [CLAUDE.md](../CLAUDE.md). Please read that file for complete instructions on architecture, build steps, testing, branching conventions, and coding patterns.
|
||||
# Umbraco CMS Development Guide
|
||||
|
||||
Always reference these instructions first and fallback to search or bash commands only when you encounter unexpected information that does not match the info here.
|
||||
|
||||
## Working Effectively
|
||||
|
||||
Bootstrap, build, and test the repository:
|
||||
|
||||
- Install .NET SDK (version specified in global.json):
|
||||
- `curl -sSL https://dot.net/v1/dotnet-install.sh | bash /dev/stdin --version $(jq -r '.sdk.version' global.json)`
|
||||
- `export PATH="/home/runner/.dotnet:$PATH"`
|
||||
- Install Node.js (version specified in src/Umbraco.Web.UI.Client/.nvmrc):
|
||||
- `curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.0/install.sh | bash`
|
||||
- `export NVM_DIR="$HOME/.nvm" && [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"`
|
||||
- `nvm install $(cat src/Umbraco.Web.UI.Client/.nvmrc) && nvm use $(cat src/Umbraco.Web.UI.Client/.nvmrc)`
|
||||
- Fix shallow clone issue (required for GitVersioning):
|
||||
- `git fetch --unshallow`
|
||||
- Restore packages:
|
||||
- `dotnet restore` -- takes 50 seconds. NEVER CANCEL. Set timeout to 90+ seconds.
|
||||
- Build the solution:
|
||||
- `dotnet build` -- takes 4.5 minutes. NEVER CANCEL. Set timeout to 10+ minutes.
|
||||
- Install and build frontend:
|
||||
- `cd src/Umbraco.Web.UI.Client`
|
||||
- `npm ci --no-fund --no-audit --prefer-offline` -- takes 11 seconds.
|
||||
- `npm run build:for:cms` -- takes 1.25 minutes. NEVER CANCEL. Set timeout to 5+ minutes.
|
||||
- Install and build Login
|
||||
- `cd src/Umbraco.Web.UI.Login`
|
||||
- `npm ci --no-fund --no-audit --prefer-offline`
|
||||
- `npm run build`
|
||||
- Run the application:
|
||||
- `cd src/Umbraco.Web.UI`
|
||||
- `dotnet run --no-build` -- Application runs on https://localhost:44339 and http://localhost:11000
|
||||
|
||||
Check out [BUILD.md](./BUILD.md) for more detailed instructions.
|
||||
|
||||
## Validation
|
||||
|
||||
- ALWAYS run through at least one complete end-to-end scenario after making changes.
|
||||
- Build and unit tests must pass before committing changes.
|
||||
- Frontend build produces output in src/Umbraco.Web.UI.Client/dist-cms/ which gets copied to src/Umbraco.Web.UI/wwwroot/umbraco/backoffice/
|
||||
- Always run `dotnet build` and `npm run build:for:cms` before running the application to see your changes.
|
||||
- For login-only changes, you can run `npm run build` from src/Umbraco.Web.UI.Login and then `dotnet run --no-build` from src/Umbraco.Web.UI.
|
||||
- For frontend-only changes, you can run `npm run dev:server` from src/Umbraco.Web.UI.Client for hot reloading.
|
||||
- Frontend changes should be linted using `npm run lint:fix` which uses Eslint.
|
||||
|
||||
## Testing
|
||||
|
||||
### Unit Tests (.NET)
|
||||
|
||||
- Location: tests/Umbraco.Tests.UnitTests/
|
||||
- Run: `dotnet test tests/Umbraco.Tests.UnitTests/Umbraco.Tests.UnitTests.csproj --configuration Release --verbosity minimal`
|
||||
- Duration: ~1 minute with 3,343 tests
|
||||
- NEVER CANCEL: Set timeout to 5+ minutes
|
||||
|
||||
### Integration Tests (.NET)
|
||||
|
||||
- Location: tests/Umbraco.Tests.Integration/
|
||||
- Run: `dotnet test tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj --configuration Release --verbosity minimal`
|
||||
- NEVER CANCEL: Set timeout to 10+ minutes
|
||||
|
||||
### Frontend Tests
|
||||
|
||||
- Location: src/Umbraco.Web.UI.Client/
|
||||
- Run: `npm test` (requires `npx playwright install` first)
|
||||
- Frontend tests use Web Test Runner with Playwright
|
||||
|
||||
### Acceptance Tests (E2E)
|
||||
|
||||
- Location: tests/Umbraco.Tests.AcceptanceTest/
|
||||
- Requires running Umbraco application and configuration
|
||||
- See tests/Umbraco.Tests.AcceptanceTest/README.md for detailed setup (requires `npx playwright install` first)
|
||||
|
||||
## Project Structure
|
||||
|
||||
The solution contains 30 C# projects organized as follows:
|
||||
|
||||
### Main Application Projects
|
||||
|
||||
- **Umbraco.Web.UI**: Main web application project (startup project)
|
||||
- **Umbraco.Web.UI.Client**: TypeScript frontend (backoffice)
|
||||
- **Umbraco.Web.UI.Login**: Separate login screen frontend
|
||||
- **Umbraco.Core**: Core domain models and interfaces
|
||||
- **Umbraco.Infrastructure**: Data access and infrastructure
|
||||
- **Umbraco.Cms**: Main CMS package
|
||||
|
||||
### API Projects
|
||||
|
||||
- **Umbraco.Cms.Api.Management**: Management API
|
||||
- **Umbraco.Cms.Api.Delivery**: Content Delivery API
|
||||
- **Umbraco.Cms.Api.Common**: Shared API components
|
||||
|
||||
### Persistence Projects
|
||||
|
||||
- **Umbraco.Cms.Persistence.SqlServer**: SQL Server support
|
||||
- **Umbraco.Cms.Persistence.Sqlite**: SQLite support
|
||||
- **Umbraco.Cms.Persistence.EFCore**: Entity Framework Core abstractions
|
||||
|
||||
### Test Projects
|
||||
|
||||
- **Umbraco.Tests.UnitTests**: Unit tests
|
||||
- **Umbraco.Tests.Integration**: Integration tests
|
||||
- **Umbraco.Tests.AcceptanceTest**: End-to-end tests with Playwright
|
||||
- **Umbraco.Tests.Common**: Shared test utilities
|
||||
|
||||
## Common Tasks
|
||||
|
||||
### Running Umbraco in Different Modes
|
||||
|
||||
**Production Mode (Standard Development)**
|
||||
Use this for backend development, testing full builds, or when you don't need hot reloading:
|
||||
|
||||
1. Build frontend assets: `cd src/Umbraco.Web.UI.Client && npm run build:for:cms`
|
||||
2. Run backend: `cd src/Umbraco.Web.UI && dotnet run --no-build`
|
||||
3. Access backoffice: `https://localhost:44339/umbraco`
|
||||
4. Application uses compiled frontend from `wwwroot/umbraco/backoffice/`
|
||||
|
||||
**Vite Dev Server Mode (Frontend Development with Hot Reload)**
|
||||
Use this for frontend-only development with hot module reloading:
|
||||
|
||||
1. Configure backend for frontend development - Add to `src/Umbraco.Web.UI/appsettings.json` under `Umbraco:CMS:Security`:
|
||||
```json
|
||||
"BackOfficeHost": "http://localhost:5173",
|
||||
"AuthorizeCallbackPathName": "/oauth_complete",
|
||||
"AuthorizeCallbackLogoutPathName": "/logout",
|
||||
"AuthorizeCallbackErrorPathName": "/error",
|
||||
"BackOfficeTokenCookie": {
|
||||
"SameSite": "None"
|
||||
}
|
||||
```
|
||||
2. Run backend: `cd src/Umbraco.Web.UI && dotnet run --no-build`
|
||||
3. Run frontend dev server: `cd src/Umbraco.Web.UI.Client && npm run dev:server`
|
||||
4. Access backoffice: `http://localhost:5173/` (no `/umbraco` prefix)
|
||||
5. Changes to TypeScript/Lit files hot reload automatically
|
||||
|
||||
**Important:** Remove the `BackOfficeHost` configuration before committing or switching back to production mode.
|
||||
|
||||
### Backend-Only Development
|
||||
|
||||
For backend-only changes, disable frontend builds:
|
||||
|
||||
- Comment out the target named "BuildStaticAssetsPreconditions" in src/Umbraco.Cms.StaticAssets.csproj:
|
||||
```
|
||||
<!--<Target Name="BuildStaticAssetsPreconditions" BeforeTargets="AssignTargetPaths">
|
||||
[...]
|
||||
</Target>-->
|
||||
```
|
||||
- Remember to uncomment before committing
|
||||
|
||||
### Building NuGet Packages
|
||||
|
||||
To build custom NuGet packages for testing:
|
||||
|
||||
```bash
|
||||
dotnet pack -c Release -o Build.Out
|
||||
dotnet nuget add source [Path to Build.Out folder] -n MyLocalFeed
|
||||
```
|
||||
|
||||
### Regenerating Frontend API Types
|
||||
|
||||
When changing Management API:
|
||||
|
||||
```bash
|
||||
cd src/Umbraco.Web.UI.Client
|
||||
npm run generate:server-api-dev
|
||||
```
|
||||
|
||||
Also update OpenApi.json from /umbraco/swagger/management/swagger.json
|
||||
|
||||
## Database Setup
|
||||
|
||||
Default configuration supports SQLite for development. For production-like testing:
|
||||
|
||||
- Use SQL Server/LocalDb for better performance
|
||||
- Configure connection string in src/Umbraco.Web.UI/appsettings.json
|
||||
|
||||
## Clean Up / Reset
|
||||
|
||||
To reset development environment:
|
||||
|
||||
```bash
|
||||
# Remove configuration and database
|
||||
rm src/Umbraco.Web.UI/appsettings.json
|
||||
rm -rf src/Umbraco.Web.UI/umbraco/Data
|
||||
|
||||
# Full clean (removes all untracked files)
|
||||
git clean -xdf .
|
||||
```
|
||||
|
||||
## Version Information
|
||||
|
||||
- Target Framework: .NET (version specified in global.json)
|
||||
- Current Version: (specified in version.json)
|
||||
- Node.js Requirement: (specified in src/Umbraco.Web.UI.Client/.nvmrc)
|
||||
- npm Requirement: Latest compatible version
|
||||
|
||||
## Known Issues
|
||||
|
||||
- Build requires full git history (not shallow clone) due to GitVersioning
|
||||
- Some NuGet package security warnings are expected (SixLabors.ImageSharp vulnerabilities)
|
||||
- Frontend tests require Playwright browser installation: `npx playwright install`
|
||||
- Older Node.js versions may show engine compatibility warnings (check .nvmrc for current requirement)
|
||||
|
||||
## Timing Expectations
|
||||
|
||||
**NEVER CANCEL** these operations - they are expected to take time:
|
||||
|
||||
| Operation | Expected Time | Timeout Setting |
|
||||
| ----------------------- | ------------- | --------------- |
|
||||
| `dotnet restore` | 50 seconds | 90+ seconds |
|
||||
| `dotnet build` | 4.5 minutes | 10+ minutes |
|
||||
| `npm ci` | 11 seconds | 30+ seconds |
|
||||
| `npm run build:for:cms` | 1.25 minutes | 5+ minutes |
|
||||
| `npm test` | 2 minutes | 5+ minutes |
|
||||
| `npm run lint` | 1 minute | 5+ minutes |
|
||||
| Unit tests | 1 minute | 5+ minutes |
|
||||
| Integration tests | Variable | 10+ minutes |
|
||||
|
||||
Always wait for commands to complete rather than canceling and retrying.
|
||||
|
||||
@@ -4,7 +4,9 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- release/*
|
||||
- v*/dev
|
||||
- v*/main
|
||||
paths:
|
||||
- src/Umbraco.Web.UI.Client/package.json
|
||||
- src/Umbraco.Web.UI.Client/package-lock.json
|
||||
@@ -14,7 +16,9 @@ on:
|
||||
types: [opened, synchronize, reopened, closed]
|
||||
branches:
|
||||
- main
|
||||
- release/*
|
||||
- v*/dev
|
||||
- v*/main
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
@@ -23,7 +27,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
name: Build and Deploy Job
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: true
|
||||
- name: Build And Deploy
|
||||
|
||||
@@ -5,6 +5,7 @@ on:
|
||||
branches:
|
||||
- main
|
||||
- v*/dev
|
||||
- v*/main
|
||||
paths:
|
||||
- src/Umbraco.Web.UI.Client/package.json
|
||||
- src/Umbraco.Web.UI.Client/package-lock.json
|
||||
@@ -15,6 +16,7 @@ on:
|
||||
branches:
|
||||
- main
|
||||
- v*/dev
|
||||
- v*/main
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
@@ -26,7 +28,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
name: Build and Deploy Job
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v4
|
||||
- name: Build And Deploy
|
||||
id: builddeploy
|
||||
uses: Azure/static-web-apps-deploy@v1
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
name: Claude PR Review
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, ready_for_review, reopened]
|
||||
# NOTE: `pull_request_target` would let this workflow review fork PRs
|
||||
# (with access to secrets), but the action currently fails during OIDC
|
||||
# token exchange with "401 Unauthorized - Invalid OIDC token" on that
|
||||
# event. PR #579 added `pull_request_target` routing to the action, but
|
||||
# Anthropic's `/github-app-token-exchange` endpoint appears not to
|
||||
# accept the token claims produced by that event. Re-enable once the
|
||||
# upstream issue is resolved.
|
||||
# See: https://github.com/anthropics/claude-code-action/issues/347
|
||||
# https://github.com/anthropics/claude-code-action/issues/621
|
||||
# pull_request_target:
|
||||
# types: [opened, ready_for_review]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
id-token: write
|
||||
actions: read
|
||||
|
||||
jobs:
|
||||
review:
|
||||
# Skip fork PRs: secrets are not exposed on `pull_request` events from
|
||||
# forks, so the action would fail with a red check. Remove this clause
|
||||
# once upstream fork support lands (tracked in
|
||||
# https://github.com/anthropics/claude-code-action/issues/939) and we
|
||||
# can re-enable the `pull_request_target` trigger above.
|
||||
if: >-
|
||||
github.event.pull_request.draft == false
|
||||
&& github.event.pull_request.head.repo.full_name == github.repository
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY_03 }}
|
||||
|
||||
# Enable progress tracking
|
||||
track_progress: true
|
||||
|
||||
# Debug (set to true to show full output in logs, false to hide it and only post comments on the PR)
|
||||
show_full_output: false
|
||||
|
||||
additional_permissions: "actions: read"
|
||||
claude_args: "--model claude-sonnet-4-6 --allowedTools 'Bash(gh:*),Bash(git:*)'"
|
||||
prompt: |
|
||||
You are reviewing pull request #${{ github.event.pull_request.number }}
|
||||
in the Umbraco CMS repository.
|
||||
|
||||
Read and execute the review procedure defined in `.claude/skills/umb-review/SKILL.md`.
|
||||
|
||||
For each finding that references a specific file and line:
|
||||
- Post an individual inline PR comment on that line.
|
||||
- Format: **[Severity]** explanation, then suggestion.
|
||||
|
||||
For the overall summary (header, impact, verdict):
|
||||
- Post ONE top-level PR comment.
|
||||
|
||||
Do NOT use sticky/updating comments — post new individual comments.
|
||||
|
||||
After reviewing, apply labels to the PR based on changed files:
|
||||
- `area/frontend` — if files under `src/Umbraco.Web.UI.Client/` are changed
|
||||
- `area/backend` — if .cs files outside the frontend client are changed
|
||||
- `area/test` — if only test files are changed
|
||||
- `category/api` — if Management API or Delivery API files are changed
|
||||
- `category/breaking` — if breaking changes were detected in the review
|
||||
- `category/localization` — if localization/language files are changed
|
||||
- `category/test-automation` — if only test files are changed
|
||||
- `category/refactor` — if the PR is pure refactoring with no new features
|
||||
- `category/performance` — if performance-related changes are detected
|
||||
- `category/ux` — if user-facing changes are detected
|
||||
- `category/ui` — if changes to the UI layer are detected
|
||||
|
||||
Only apply labels you are confident about. Never remove existing labels.
|
||||
|
||||
Be friendly and constructive. This project values community contributions.
|
||||
Frame feedback as suggestions where possible.
|
||||
Reserve firm language for genuine Critical issues only.
|
||||
|
||||
Run fully autonomously. Do NOT ask questions.
|
||||
Only review changed files. Do not flag pre-existing issues.
|
||||
Do not suggest changes that would themselves introduce breaking changes.
|
||||
@@ -1,91 +0,0 @@
|
||||
name: Claude
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_review_comment:
|
||||
types: [created]
|
||||
issues:
|
||||
types: [opened, assigned, labeled]
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
issues: write
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
claude:
|
||||
if: |
|
||||
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
|
||||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY_03 }}
|
||||
assignee_trigger: "claude"
|
||||
label_trigger: "claude"
|
||||
base_branch: "main"
|
||||
additional_permissions: "actions: read"
|
||||
claude_args: "--model claude-sonnet-4-6 --max-turns 50 --allowedTools 'Bash(gh:*),Bash(git:*),Bash(npm:*),Bash(dotnet:*)'"
|
||||
prompt: |
|
||||
You are an AI assistant for the Umbraco CMS repository, an open-source
|
||||
.NET CMS that welcomes community contributions.
|
||||
|
||||
You were triggered on issue/PR #${{ github.event.issue.number || github.event.pull_request.number }}.
|
||||
|
||||
Read the user's message and do what they ask. The trigger phrase
|
||||
`@claude` is stripped before you see the message, so common requests
|
||||
will look like:
|
||||
|
||||
- `review` — Review PR #${{ github.event.issue.number || github.event.pull_request.number }}.
|
||||
Use `gh pr diff ${{ github.event.issue.number || github.event.pull_request.number }}`
|
||||
and `gh pr view ${{ github.event.issue.number || github.event.pull_request.number }}`
|
||||
to read the changes. Do NOT use git diff or the umb-review skill.
|
||||
Focus on bugs, breaking changes, and architectural concerns.
|
||||
Post inline comments for specific issues and a brief summary.
|
||||
- `help` or a general question — Answer based on the codebase.
|
||||
Read CLAUDE.md files for project structure and conventions.
|
||||
- `fix ...` — Implement the requested fix on a new branch.
|
||||
- `label` — Apply appropriate labels to the PR or issue.
|
||||
|
||||
If the message is empty or just whitespace, treat it as `review`
|
||||
when on a PR, or `help` when on an issue.
|
||||
|
||||
If none of these match, read the user's message carefully and respond
|
||||
to what they actually asked for.
|
||||
|
||||
## Labeling
|
||||
|
||||
When labeling PRs (based on changed files):
|
||||
- `area/frontend`, `area/backend`, `area/test`
|
||||
- `category/api`, `category/breaking`, `category/localization`
|
||||
- `category/refactor`, `category/performance`, `category/ux`, `category/ui`
|
||||
- `category/test-automation`
|
||||
|
||||
When labeling issues (based on content):
|
||||
- `area/frontend`, `area/backend`, `area/test`
|
||||
- `affected/v14` through `affected/v17`, `affected/backoffice`
|
||||
- `category/api`, `category/localization`, `category/performance`
|
||||
- `category/ux`, `category/ui`
|
||||
|
||||
Only apply labels you are confident about. Never remove existing labels.
|
||||
|
||||
## Tone
|
||||
|
||||
Be friendly and constructive. Frame feedback as suggestions.
|
||||
Reserve firm language for genuine critical issues only.
|
||||
|
||||
## Constraints
|
||||
|
||||
- Run fully autonomously. Do NOT ask questions.
|
||||
- Do not suggest changes that would introduce breaking changes.
|
||||
@@ -51,7 +51,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# We use the setup-dotnet action to set up .NET Core, otherwise the CodeQL CLI will not work with preview versions.
|
||||
- name: Setup .NET from global.json
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
name: Issue Deduplication
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [ opened ]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
issue_number:
|
||||
description: 'Issue number to analyze for duplicates'
|
||||
required: true
|
||||
type: number
|
||||
|
||||
jobs:
|
||||
deduplicate:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Check for duplicate issues
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
prompt: |
|
||||
Analyze this new issue and check if it's a duplicate of existing issues in the repository.
|
||||
|
||||
Issue: #${{ github.event.issue.number || inputs.issue_number }}
|
||||
Repository: ${{ github.repository }}
|
||||
|
||||
Your task:
|
||||
1. Use mcp__github__get_issue to get details of the current issue (#${{ github.event.issue.number || inputs.issue_number }})
|
||||
2. Search for similar existing issues using mcp__github__search_issues with relevant keywords from the issue title and body
|
||||
3. Compare the new issue with existing ones to identify potential duplicates
|
||||
|
||||
Criteria for duplicates:
|
||||
- Same bug or error being reported
|
||||
- Same feature request (even if worded differently)
|
||||
- Same question being asked
|
||||
- Issues describing the same root problem
|
||||
|
||||
If you find duplicates:
|
||||
- Add a comment on the new issue linking to the original issue(s)
|
||||
- Apply the "duplicate" and "state/needs-investigation" labels to the new issue
|
||||
- Be polite and explain why it's a duplicate
|
||||
- Suggest the user follow the original issue for updates
|
||||
|
||||
If it's NOT a duplicate:
|
||||
- Don't add any comments
|
||||
- You may apply appropriate topic labels based on the issue content
|
||||
|
||||
Use these tools:
|
||||
- mcp__github__get_issue: Get issue details
|
||||
- mcp__github__search_issues: Search for similar issues
|
||||
- mcp__github__list_issues: List recent issues if needed
|
||||
- mcp__github__add_issue_comment: Add a comment if duplicate found
|
||||
- mcp__github__update_issue: Add labels
|
||||
|
||||
Be thorough but efficient. Focus on finding true duplicates, not just similar issues.
|
||||
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY_03 }}
|
||||
|
||||
# Issues are opened by community members without write access, so the
|
||||
# default OIDC token exchange fails with "User does not have write
|
||||
# access on this repository". Pass `github_token` explicitly and set
|
||||
# `allowed_non_write_users` to bypass that check. Safe here because
|
||||
# `permissions:` and `--allowedTools` below are tightly scoped to
|
||||
# issue operations only.
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
allowed_non_write_users: "*"
|
||||
|
||||
# Surface full SDK output (including tool calls and permission denials)
|
||||
# to diagnose why Claude sometimes only partially completes (e.g. labels
|
||||
# an issue but skips the comment). Safe to leave on — no secrets in output.
|
||||
show_full_output: true
|
||||
|
||||
claude_args: |
|
||||
--model claude-haiku-4-5 --allowedTools "mcp__github__get_issue,mcp__github__search_issues,mcp__github__list_issues,mcp__github__add_issue_comment,mcp__github__update_issue,mcp__github__get_issue_comments"
|
||||
@@ -1,81 +0,0 @@
|
||||
name: "SonarQube Cloud - Analysis"
|
||||
|
||||
# This workflow runs the full SonarCloud analysis with the SONAR_TOKEN secret.
|
||||
# It is skipped for fork PRs since secrets are not available in that context.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- "v*/dev"
|
||||
- "v*/main"
|
||||
- "release/*"
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
SONAR_PROJECT_KEY: umbraco_Umbraco-CMS
|
||||
SONAR_ORGANIZATION: umbraco
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Build and analyze
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork != true
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup .NET from global.json
|
||||
uses: actions/setup-dotnet@v5
|
||||
|
||||
- name: Cache SonarQube packages
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.sonar/cache
|
||||
key: ${{ runner.os }}-sonar
|
||||
restore-keys: ${{ runner.os }}-sonar
|
||||
|
||||
- name: Install tools
|
||||
run: |
|
||||
dotnet tool install --global dotnet-sonarscanner
|
||||
dotnet tool install --global dotnet-coverage
|
||||
|
||||
- name: Load sonar params
|
||||
run: echo "SONARQUBE_SCANNER_PARAMS=$(jq -c . .github/workflows/sonarcloud/sonar-params.json)" >> $GITHUB_ENV
|
||||
|
||||
- name: Begin analysis
|
||||
env:
|
||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||
run: |
|
||||
dotnet-sonarscanner begin \
|
||||
/k:"$SONAR_PROJECT_KEY" \
|
||||
/o:"$SONAR_ORGANIZATION" \
|
||||
/d:sonar.token="$SONAR_TOKEN"
|
||||
|
||||
- name: Restore
|
||||
run: dotnet restore umbraco.sln
|
||||
|
||||
- name: Build solution
|
||||
run: GITHUB_ENV=/dev/null dotnet build umbraco.sln --no-restore -clp:ErrorsOnly # prevent sonar MSBuild integration from writing malformed values to $GITHUB_ENV
|
||||
|
||||
- name: Run unit tests with coverage
|
||||
run: |
|
||||
dotnet-coverage collect \
|
||||
"dotnet test tests/Umbraco.Tests.UnitTests/Umbraco.Tests.UnitTests.csproj --no-build" \
|
||||
--output TestResults/coverage.xml \
|
||||
--output-format xml
|
||||
|
||||
- name: End analysis
|
||||
env:
|
||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||
run: dotnet-sonarscanner end /d:sonar.token="$SONAR_TOKEN"
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"sonar.cs.vscoveragexml.reportsPaths": "TestResults/coverage.xml",
|
||||
"sonar.inclusions": "src/**,templates/**,tools/**,tests/**,.github/**,build/**",
|
||||
"sonar.exclusions": "**/bin/**,**/obj/**,**/node_modules/**,**/lang/*.ts,**/mocks/**,**/wwwroot/**,**/dist-cms/**,**/*.generated.cs,src/Umbraco.Web.UI/umbraco/**,src/Umbraco.Cms.Persistence.EFCore.*/Migrations/**,src/Umbraco.Web.UI.Client/src/packages/core/backend-api/**,**/.nuget/**",
|
||||
"sonar.test.inclusions": "tests/**,**/*.test.ts,**/*.spec.ts",
|
||||
"sonar.typescript.tsconfigPaths": "src/Umbraco.Web.UI.Client/tsconfig.json,src/Umbraco.Web.UI.Client/tsconfig.node.json,src/Umbraco.Web.UI.Login/tsconfig.json"
|
||||
}
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
run:
|
||||
working-directory: src/Umbraco.Web.UI.Client
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v4
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
@@ -57,7 +57,7 @@ jobs:
|
||||
run:
|
||||
working-directory: src/Umbraco.Web.UI.Client
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v4
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
|
||||
+2
-11
@@ -52,9 +52,7 @@ tools/docfx/
|
||||
/build/csharp-docs/_site/
|
||||
|
||||
# Local config
|
||||
.claude/*
|
||||
!.claude/skills/
|
||||
!.claude/settings.json
|
||||
.claude/settings.local.json
|
||||
.env.local
|
||||
|
||||
# Build
|
||||
@@ -72,8 +70,7 @@ tools/docfx/
|
||||
/src/Umbraco.Cms.StaticAssets/wwwroot/umbraco/assets
|
||||
/src/Umbraco.Cms.StaticAssets/wwwroot/umbraco/js
|
||||
/src/Umbraco.Cms.StaticAssets/wwwroot/umbraco/lib
|
||||
/src/Umbraco.Cms.StaticAssets/wwwroot/umbraco/views/*
|
||||
!/src/Umbraco.Cms.StaticAssets/wwwroot/umbraco/views/errors
|
||||
/src/Umbraco.Cms.StaticAssets/wwwroot/umbraco/views
|
||||
/src/Umbraco.Cms.StaticAssets/wwwroot/umbraco/login
|
||||
|
||||
# Environment specific data
|
||||
@@ -106,7 +103,6 @@ tools/docfx/
|
||||
playwright-report
|
||||
trace.zip
|
||||
/tests/Umbraco.Tests.AcceptanceTest/results
|
||||
/tests/Umbraco.Tests.AcceptanceTest/dist
|
||||
|
||||
# Ignore auto-generated schema
|
||||
/src/Umbraco.Cms.Targets/tasks/
|
||||
@@ -120,8 +116,3 @@ trace.zip
|
||||
/tests/Umbraco.Tests.Integration/appsettings-schema.*.json
|
||||
/tests/Umbraco.Tests.Integration/umbraco-package-schema.json
|
||||
/src/Umbraco.Cms/appsettings-schema.json
|
||||
.worktrees
|
||||
.playwright-mcp/
|
||||
|
||||
# SonarQube local analysis cache
|
||||
.sonarqube/
|
||||
|
||||
@@ -48,6 +48,7 @@ dotnet_analyzer_diagnostic.category-StyleCop.CSharp.OrderingRules.severity = sug
|
||||
dotnet_analyzer_diagnostic.category-StyleCop.CSharp.MaintainabilityRules.severity = suggestion
|
||||
dotnet_analyzer_diagnostic.category-StyleCop.CSharp.LayoutRules.severity = suggestion
|
||||
|
||||
dotnet_diagnostic.SA1636.severity = none # SA1636: File header copyright text should match
|
||||
dotnet_diagnostic.SA1101.severity = none # PrefixLocalCallsWithThis - stylecop appears to be ignoring dotnet_style_qualification_for_*
|
||||
dotnet_diagnostic.SA1309.severity = none # FieldNamesMustNotBeginWithUnderscore
|
||||
|
||||
|
||||
@@ -46,8 +46,7 @@ Enterprise-grade CMS built on .NET 10.0. This repository contains 21 production
|
||||
- **ASP.NET Core** - Web framework
|
||||
- **Entity Framework Core** - Modern ORM
|
||||
- **OpenIddict** - OAuth 2.0/OpenID Connect authentication
|
||||
- **Microsoft.AspNetCore.OpenApi** - OpenAPI document generation
|
||||
- **Swashbuckle.AspNetCore.SwaggerUI** - Swagger UI for API documentation
|
||||
- **Swashbuckle** - OpenAPI/Swagger documentation
|
||||
- **Lucene.NET** - Full-text search via Examine
|
||||
- **ImageSharp** - Image processing
|
||||
|
||||
@@ -199,7 +198,7 @@ Use the format: `Area: Description (closes #IssueID)`
|
||||
- Describe the change and its impact
|
||||
- Be specific, not vague (describe "a golden retriever" not just "a dog")
|
||||
|
||||
**Issue Linking**: Add `(closes #IssueID)` to the title for readability, AND include a closing keyword on its own line in the PR body (e.g., `Fixes #IssueID`) so GitHub actually auto-links and auto-closes the issue on merge. GitHub only parses closing keywords (`closes`, `fixes`, `resolves`) from the PR body or commit messages — the title suffix is cosmetic and does **not** trigger auto-close on its own.
|
||||
**Issue Linking**: Add `(closes #IssueID)` to auto-close linked issues on merge.
|
||||
|
||||
### Commit Messages
|
||||
|
||||
@@ -228,11 +227,9 @@ Project ownership is distributed across teams. Check individual project director
|
||||
|
||||
1. **Layered Architecture with Dependency Inversion**
|
||||
- Core defines contracts (interfaces)
|
||||
- Infrastructure implements contracts that need Infrastructure-owned machinery
|
||||
- Infrastructure implements contracts
|
||||
- Web/APIs consume implementations via DI
|
||||
|
||||
**Where service implementations live**: Services whose dependencies are satisfiable from Core interfaces alone (repositories, scope, config, other Core services) live in `Umbraco.Core/Services/` — this covers the majority of domain services (`MemberService`, `ContentService`, `MediaService`, `ContentTypeService`, `EntityService`, `AuditService`, `ExternalMemberService`, etc.). Service implementations only live in `Umbraco.Infrastructure/Services/Implement/` when they genuinely need Infrastructure concerns — Examine indexes (`ContentSearchService`, `MediaSearchService`, `IndexedEntitySearchService`), log files (`LogViewerRepository`), packaging internals (`PackagingService`), webhook firing (`WebhookFiringService`), distributed-job coordination (`DistributedJobService`). When adding a new service, default to Core and only move to Infrastructure if a concrete dependency forces it.
|
||||
|
||||
2. **Interface-First Design**
|
||||
- All services defined as interfaces in Core
|
||||
- Enables testing, polymorphism, extensibility
|
||||
@@ -367,27 +364,16 @@ public interface IMyService
|
||||
|
||||
### Centralized Package Management
|
||||
|
||||
**NuGet package versions** are centralized in `Directory.Packages.props`. There are two `Directory.Packages.props` files in the source tree, with multi-level merging enabled so the test file inherits from the root:
|
||||
|
||||
| File | Scope |
|
||||
|------|-------|
|
||||
| `Directory.Packages.props` (root) | Production source code packages — referenced by all `src/**` projects |
|
||||
| `tests/Directory.Packages.props` | Test-only packages (NUnit, Moq, Bogus, BenchmarkDotNet, etc.) — adds entries on top of the inherited root file |
|
||||
|
||||
When updating dependencies, decide which file the package belongs in:
|
||||
- A package used only by test projects → `tests/Directory.Packages.props`
|
||||
- A package used by any production project (or by both production and tests) → root `Directory.Packages.props`
|
||||
**All NuGet package versions** are centralized in `Directory.Packages.props`. Individual projects do NOT specify versions.
|
||||
|
||||
```xml
|
||||
<!-- Individual projects reference WITHOUT version -->
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" />
|
||||
|
||||
<!-- Versions defined in Directory.Packages.props -->
|
||||
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0" />
|
||||
<PackageVersion Include="Swashbuckle.AspNetCore" Version="6.5.0" />
|
||||
```
|
||||
|
||||
**Opt-out**: `src/Umbraco.Web.UI/Umbraco.Web.UI.csproj` sets `<ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally>` and specifies versions inline (for `Microsoft.EntityFrameworkCore.Design`, `Microsoft.Build.Tasks.Core`, `Microsoft.ICU.ICU4C.Runtime`, etc.). Update those versions directly in that csproj when bumping. Two further `Directory.Packages.props` files exist under `templates/` for the project/extension templates and have their own version sets — keep `Microsoft.AspNetCore.OpenApi` aligned between the root file and `templates/UmbracoExtension/`.
|
||||
|
||||
### Build Configuration
|
||||
|
||||
- `Directory.Build.props` - Shared properties (target framework, company, copyright)
|
||||
@@ -407,18 +393,13 @@ The repository contains BOTH (actively supported):
|
||||
All APIs use **OpenIddict** (OAuth 2.0/OpenID Connect):
|
||||
- Reference tokens (not JWT) for better security
|
||||
- **Secure cookie-based token storage** (v17+) - tokens stored in HTTP-only cookies with `__Host-` prefix
|
||||
- Tokens are redacted from client-side responses and passed via secure cookies only (`[redacted]` placeholder)
|
||||
- Tokens are redacted from client-side responses and passed via secure cookies only
|
||||
- ASP.NET Core Data Protection for token encryption
|
||||
- Configured in `Umbraco.Cms.Api.Common`
|
||||
- API requests must include credentials (`credentials: include` for fetch)
|
||||
|
||||
**Load Balancing Requirement**: All servers must share the same Data Protection key ring.
|
||||
|
||||
**Frontend auth pitfalls** — see `src/Umbraco.Web.UI.Client/docs/edge-cases.md` (Auth & Cross-tab section) and `docs/security.md`. Key points:
|
||||
- Never call `validateToken()` per API request — it revokes the previous reference token (ID2019 errors)
|
||||
- `window.opener` is set for ANY `window.open()` target, not only OAuth popups — scope guards to the pathname too
|
||||
- BroadcastChannel does not deliver messages to the sender's own tab
|
||||
|
||||
### Content Caching Strategy
|
||||
|
||||
**HybridCache** (`Umbraco.PublishedCache.HybridCache`):
|
||||
@@ -431,30 +412,66 @@ All APIs use **OpenIddict** (OAuth 2.0/OpenID Connect):
|
||||
APIs use `Asp.Versioning.Mvc`:
|
||||
- Management API: `/umbraco/management/api/v{version}/*`
|
||||
- Delivery API: `/umbraco/delivery/api/v{version}/*`
|
||||
- OpenAPI docs: `/umbraco/openapi/management.json`, `/umbraco/openapi/delivery.json`
|
||||
- Swagger UI: `/umbraco/openapi/`
|
||||
- OpenAPI/Swagger docs per version
|
||||
|
||||
### Updating `OpenApi.json` (Management API)
|
||||
### Backoffice npm Package Structure
|
||||
|
||||
When a PR changes Management API controllers or models, the `OpenApi.json` file in the Management API project must be updated:
|
||||
The backoffice (`Umbraco.Web.UI.Client`) is published to npm as **`@umbraco-cms/backoffice`** with a plugin architecture:
|
||||
|
||||
1. Run the Umbraco instance locally
|
||||
2. Open Swagger UI and navigate to the swagger.json link (e.g. `https://localhost:44339/umbraco/swagger/management/swagger.json`)
|
||||
3. Copy the full JSON content and paste it into `src/Umbraco.Cms.Api.Management/OpenApi.json`
|
||||
#### Architecture Overview
|
||||
|
||||
**Important**: Commit only the substantive changes — not IDE-applied formatting (whitespace, reordering, etc.). Extraneous formatting diffs make PRs harder to review and merge-ups more error-prone.
|
||||
- **Multi-workspace structure**: Subprojects in `src/libs/*`, `src/packages/*`, `src/external/*`
|
||||
- **Export model**: All exports defined in root `package.json` → `./exports` field
|
||||
- **Importmap-driven runtime**: Dependencies provided at runtime via importmap (single source of truth)
|
||||
- **Build-time types**: TypeScript types come from npm peerDependencies
|
||||
- **Plugin model**: Developers create plugins that import from `@umbraco-cms/backoffice/*` exports
|
||||
|
||||
### Backoffice npm Package
|
||||
#### Dependency Hoisting Strategy
|
||||
|
||||
The backoffice is published to npm as `@umbraco-cms/backoffice`. Runtime dependencies are provided via importmap; npm peerDependencies provide types only. For full details on dependency hoisting, version range logic, and plugin development, see `/src/Umbraco.Web.UI.Client/CLAUDE.md` → "npm Package Publishing".
|
||||
When building for npm (`npm pack`), the `cleanse-pkg.js` script hoists subproject dependencies to root `peerDependencies` with intelligent version range conversion:
|
||||
|
||||
### SQL Server 2100-parameter limit
|
||||
**Version Range Logic** (uses `semver` package):
|
||||
|
||||
Any `WHERE IN (@0, @1, ...)` built from a runtime-sized collection risks hitting SQL Server's 2100-parameter ceiling and throwing `SqlException` 8003 in production.
|
||||
1. **Pre-release (0.x.y)**: Convert to explicit range
|
||||
- Input: `^0.85.0` or `0.85.0`
|
||||
- Output: `>=0.85.0 <1.0.0`
|
||||
- Rationale: Pre-release caret only allows patch updates, explicit range allows minor upgrades within 0.x.x
|
||||
- Example: Plugin can use `@hey-api/openapi-ts@0.91.1` while backoffice uses `0.85.0`
|
||||
|
||||
Batch with `IEnumerable<T>.InGroupsOf(Constants.Sql.MaxParameterCount)` or `Database.FetchByGroups(...)` whenever the collection size is driven by user data — not just when it currently fits. Watch for products of two scaling dimensions (documents × languages, properties × versions) and config-tunable batch sizes whose defaults are safe but ceilings aren't.
|
||||
2. **Stable with caret (^X.Y.Z where X ≥ 1)**: Keep as-is
|
||||
- Input: `^3.3.1`
|
||||
- Output: `^3.3.1` (unchanged)
|
||||
- Rationale: Caret already implements correct semantics for stable versions
|
||||
|
||||
Full guidance, safe patterns and decision rule: see `/src/Umbraco.Infrastructure/CLAUDE.md` → "Avoiding the SQL Server 2100-parameter limit".
|
||||
3. **Stable exact versions (X.Y.Z where X ≥ 1)**: Add caret
|
||||
- Input: `3.16.0`
|
||||
- Output: `^3.16.0`
|
||||
- Rationale: Normalizes to conventional semver format
|
||||
|
||||
#### Key Dependencies
|
||||
|
||||
**Runtime via importmap** (types available from peerDependencies):
|
||||
- `lit`, `rxjs`, `@umbraco-ui/uui` - Core framework
|
||||
- `monaco-editor`, `@tiptap/*` - Feature-specific editors
|
||||
- `@hey-api/openapi-ts` - HTTP client type generation
|
||||
|
||||
**Build-time only** (not hoisted):
|
||||
- `vite`, `typescript`, `eslint` - Dev tooling
|
||||
|
||||
#### Plugin Development Implications
|
||||
|
||||
Plugin developers should:
|
||||
- **Declare explicit dependencies** in their own `package.json` (avoid relying on transitive deps)
|
||||
- **Understand the version ranges**: `>=0.85.0 <1.0.0` means they can use newer pre-release versions
|
||||
- **Know that types match npm ranges**, but runtime comes from importmap (managed by backoffice)
|
||||
- **When `@hey-api` hits 1.0.0**: Published constraint will automatically become `^1.0.0`
|
||||
|
||||
#### Implementation Details
|
||||
|
||||
- Script location: `src/Umbraco.Web.UI.Client/devops/publish/cleanse-pkg.js`
|
||||
- Runs as `prepack` hook before npm pack
|
||||
- Uses `semver.minVersion()` for robust version range parsing
|
||||
- Generates single source of truth for importmap versions
|
||||
|
||||
### Known Limitations
|
||||
|
||||
@@ -464,102 +481,6 @@ Full guidance, safe patterns and decision rule: see `/src/Umbraco.Infrastructure
|
||||
|
||||
---
|
||||
|
||||
## 7. CI/CD — Claude AI Assistant
|
||||
|
||||
Two GitHub Actions workflows powered by `anthropics/claude-code-action@v1`. Advisory only — does not block merging.
|
||||
|
||||
### Workflows
|
||||
|
||||
| File | Trigger | Purpose |
|
||||
|------|---------|---------|
|
||||
| `claude-review.yml` | `pull_request: [opened, ready_for_review]` | Auto-review every non-draft PR using the `umb-review` skill |
|
||||
| `claude.yml` | `@claude` comments, issue assign/label | Interactive assistant for PRs and issues |
|
||||
|
||||
### Auto-Review (`claude-review.yml`)
|
||||
|
||||
Runs the full `.claude/skills/umb-review/SKILL.md` procedure on every newly opened or un-drafted PR. Produces inline comments per finding and one summary comment with a verdict. Skips draft PRs. No turn limit.
|
||||
|
||||
### Interactive (`claude.yml`)
|
||||
|
||||
Responds to `@claude` mentions on PRs and issues. The trigger phrase is stripped before Claude sees the message, so:
|
||||
|
||||
- `@claude review` → light review using `gh pr diff` (not the umb-review skill)
|
||||
- `@claude fix ...` → implements a fix on a new branch
|
||||
- `@claude help` → answers questions about the codebase
|
||||
- `@claude label` → applies labels
|
||||
- `@claude` (empty) → defaults to `review` on PRs, `help` on issues
|
||||
|
||||
Also triggers on issue assignment to `claude` or adding the `claude` label. Gated: only runs when `@claude` appears in the comment/issue body. Max 25 turns.
|
||||
|
||||
**Allowed Bash tools**: `gh`, `git`, `npm`, `dotnet` (interactive only; auto-review allows `gh` and `git`).
|
||||
|
||||
### Labels
|
||||
|
||||
Both workflows apply labels based on content:
|
||||
|
||||
**On PRs** (based on changed files):
|
||||
|
||||
| Label | Condition |
|
||||
|-------|-----------|
|
||||
| `area/frontend` | Files under `src/Umbraco.Web.UI.Client/` |
|
||||
| `area/backend` | `.cs` files outside the frontend client |
|
||||
| `area/test` | Only test files changed |
|
||||
| `category/api` | Management or Delivery API files |
|
||||
| `category/breaking` | Breaking changes detected |
|
||||
| `category/localization` | Localization/language files |
|
||||
| `category/test-automation` | Only test files changed |
|
||||
| `category/refactor` | Pure refactoring, no new features |
|
||||
| `category/performance` | Performance-related changes |
|
||||
| `category/ux` | User-facing changes |
|
||||
| `category/ui` | UI layer changes |
|
||||
|
||||
**On Issues** (based on content): same `area/*` and `category/*` labels, plus `affected/v14` through `affected/v17` and `affected/backoffice`.
|
||||
|
||||
Labels are only added, never removed. Claude applies only labels it is confident about.
|
||||
|
||||
### Key Implementation Notes
|
||||
|
||||
- **Checkout required** — the action internally runs `git fetch origin main` for trusted file restoration. Without `actions/checkout`, it fails with `fatal: not a git repository`.
|
||||
- **`id-token: write` permission** — required for OIDC token exchange with the Claude GitHub App.
|
||||
- **Trigger phrase stripping** — the action strips `@claude` from comments before passing to Claude. Prompts must reference commands without the prefix (e.g., `review` not `@claude review`).
|
||||
- **PR number injection** — the interactive workflow injects the PR/issue number into the prompt via `${{ github.event.issue.number }}` since Claude can't discover it from `gh pr view` when checked out on `main`.
|
||||
|
||||
---
|
||||
|
||||
## 8. Code Comment Policy
|
||||
|
||||
**Default to no comment.** Applies to all code in this repository — C#, TypeScript, Razor, build scripts. Well-named identifiers and small functions are the primary form of self-documentation; comments are a fallback for the rare cases where the code itself cannot carry the meaning.
|
||||
|
||||
### When NOT to comment
|
||||
|
||||
- **Don't restate what the code does.** A line calling `resetState()` does not need `// Reset state`. A method named `validateInput` does not need `// Validate input`.
|
||||
- **Don't narrate a sequence of calls.** If three lines run in order, the order is in the code — don't paraphrase it above.
|
||||
- **Don't reference the current task, fix, callers, or PR.** No `// Fix for X`, `// Used by Y`, `// Added for the Z flow`, `// See PR #1234`. That belongs in commit messages and PR descriptions; in source it rots as the codebase evolves.
|
||||
|
||||
### When a comment IS justified
|
||||
|
||||
Write a comment only when **removing it would leave a future reader confused**. Concretely:
|
||||
|
||||
- **A non-obvious WHY.** A hidden constraint, business rule, or ordering requirement that is not visible from the code.
|
||||
- **A workaround for a specific bug or platform quirk.** Link the issue (`(#21996)`, `https://...`) so the comment can be deleted once the upstream fix lands.
|
||||
- **A subtle invariant** that the type system or method names do not enforce.
|
||||
- **An edge case the code intentionally handles** that would surprise a reader (e.g. "must run before X because Y").
|
||||
- **API documentation** — XML doc comments on C# members, JSDoc on exported TypeScript symbols. Required for the public contract; still keep them concise.
|
||||
|
||||
### TODOs
|
||||
|
||||
Allowed, but cheap to write and cheaper to leave behind. Keep them short and trackable: `// TODO (V19): remove once obsolete overload is gone` or `// TODO: pagination [NL]`. A TODO should have an author or a version trigger.
|
||||
|
||||
---
|
||||
|
||||
## 9. Testing Practices
|
||||
|
||||
### Tests for a bug fix must fail before the fix
|
||||
|
||||
Verify any test you add for a bug fix actually catches the bug: either write the failing test first (TDD), or temporarily revert the production change and confirm the test fails before re-applying. A test that passes both ways proves nothing. Watch for coincidental passes — default seed/sort orders can make a buggy path produce the right answer for the test's specific inputs; construct inputs so the broken and fixed behaviours give visibly different results.
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### Essential Commands
|
||||
@@ -581,16 +502,6 @@ dotnet format
|
||||
dotnet pack -c Release
|
||||
```
|
||||
|
||||
### Integration Test Database Configuration
|
||||
|
||||
Integration tests are configured in `tests/Umbraco.Tests.Integration/appsettings.Tests.json`.
|
||||
|
||||
The `Tests:Database:DatabaseType` setting controls which database is used:
|
||||
- `"SQLite"` (default) - No external dependencies
|
||||
- `"LocalDb"` - Uses SQL Server LocalDB, required for SQL Server-specific tests (e.g., page-level locking, `sys.dm_tran_locks`)
|
||||
|
||||
SQL Server-specific tests use `BaseTestDatabase.IsSqlite()` to skip when running on SQLite.
|
||||
|
||||
### Key Projects
|
||||
|
||||
| Project | Type | Description |
|
||||
@@ -616,9 +527,6 @@ SQL Server-specific tests use `BaseTestDatabase.IsSqlite()` to skip when running
|
||||
For detailed information about individual projects, see their CLAUDE.md files:
|
||||
- **Core Architecture**: `/src/Umbraco.Core/CLAUDE.md` - Service contracts, notification patterns
|
||||
- **API Infrastructure**: `/src/Umbraco.Cms.Api.Common/CLAUDE.md` - OpenAPI, authentication, serialization
|
||||
- **Backoffice Frontend**: `/src/Umbraco.Web.UI.Client/CLAUDE.md` - Lit web components, extension system, auth client
|
||||
|
||||
**Important**: When working on backoffice client code (anything under `src/Umbraco.Web.UI.Client/`), read `/src/Umbraco.Web.UI.Client/CLAUDE.md` first. It contains action-specific checklists (deprecation, testing, security, etc.) that are not duplicated here.
|
||||
|
||||
### Getting Help
|
||||
|
||||
|
||||
+2
-12
@@ -40,8 +40,8 @@
|
||||
<!-- Package Validation -->
|
||||
<PropertyGroup>
|
||||
<GenerateCompatibilitySuppressionFile>false</GenerateCompatibilitySuppressionFile>
|
||||
<EnablePackageValidation>false</EnablePackageValidation> <!-- TODO (V18): Set to true once this version is released. -->
|
||||
<PackageValidationBaselineVersion>18.0.0</PackageValidationBaselineVersion>
|
||||
<EnablePackageValidation>true</EnablePackageValidation>
|
||||
<PackageValidationBaselineVersion>17.0.0</PackageValidationBaselineVersion>
|
||||
<EnableStrictModeForCompatibleFrameworksInPackage>true</EnableStrictModeForCompatibleFrameworksInPackage>
|
||||
<EnableStrictModeForCompatibleTfms>true</EnableStrictModeForCompatibleTfms>
|
||||
</PropertyGroup>
|
||||
@@ -64,14 +64,4 @@
|
||||
</_ProjectReferencesWithVersions>
|
||||
</ItemGroup>
|
||||
</Target>
|
||||
|
||||
<!-- Workaround for https://github.com/umbraco/Umbraco-CMS/issues/23018
|
||||
Due to the amount of XML documentation in this solution, the OpenAPI XML documentation source generator produces
|
||||
too many lines of code causing a StackOverflowException when running on IIS. For that reason we disable the analyzer.
|
||||
See https://learn.microsoft.com/en-us/aspnet/core/fundamentals/openapi/openapi-comments?view=aspnetcore-10.0#disabling-xml-documentation-support -->
|
||||
<Target Name="DisableCompileTimeOpenApiXmlGenerator" BeforeTargets="CoreCompile" Condition="'$(IsPackable)' != 'false' or '$(IsTestProject)' == 'true'">
|
||||
<ItemGroup>
|
||||
<Analyzer Remove="@(Analyzer)" Condition="'%(Filename)' == 'Microsoft.AspNetCore.OpenApi.SourceGenerators'" />
|
||||
</ItemGroup>
|
||||
</Target>
|
||||
</Project>
|
||||
|
||||
+40
-47
@@ -8,79 +8,75 @@
|
||||
<ItemGroup>
|
||||
<GlobalPackageReference Include="Nerdbank.GitVersioning" Version="3.9.50" />
|
||||
<GlobalPackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.556" />
|
||||
<!-- TODO (V18): Bump Umbraco.Code to 3.0.0 stable before release of 18.0.0 -->
|
||||
<GlobalPackageReference Include="Umbraco.Code" Version="3.0.0-beta" />
|
||||
<GlobalPackageReference Include="Umbraco.Code" Version="2.4.0" />
|
||||
<GlobalPackageReference Include="Umbraco.GitVersioning.Extensions" Version="0.2.0" />
|
||||
</ItemGroup>
|
||||
<!-- Microsoft packages -->
|
||||
<ItemGroup>
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation" Version="10.0.7" />
|
||||
<!-- When updating this version, also update templates/UmbracoExtension/Umbraco.Extension.csproj -->
|
||||
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="5.3.0" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="5.3.0" />
|
||||
<PackageVersion Include="Microsoft.Data.Sqlite" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.Extensions.FileProviders.Embedded" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.Extensions.FileProviders.Physical" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Identity.Core" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Identity.Stores" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Options.DataAnnotations" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Caching.Hybrid" Version="10.5.0" />
|
||||
<PackageVersion Include="System.Linq.Async" Version="7.0.1" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation" Version="10.0.2" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="4.14.0" />
|
||||
<PackageVersion Include="Microsoft.Data.Sqlite" Version="10.0.2" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.2" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.2" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.2" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.2" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.2" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="10.0.2" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.2" />
|
||||
<PackageVersion Include="Microsoft.Extensions.FileProviders.Embedded" Version="10.0.2" />
|
||||
<PackageVersion Include="Microsoft.Extensions.FileProviders.Physical" Version="10.0.2" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.2" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.2" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Identity.Core" Version="10.0.2" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Identity.Stores" Version="10.0.2" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.2" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.2" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.2" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Options.DataAnnotations" Version="10.0.2" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Caching.Hybrid" Version="10.2.0" />
|
||||
<PackageVersion Include="System.Linq.Async" Version="7.0.0" />
|
||||
</ItemGroup>
|
||||
<!-- Umbraco packages -->
|
||||
<ItemGroup>
|
||||
<PackageVersion Include="Umbraco.JsonSchema.Extensions" Version="0.4.0" />
|
||||
<PackageVersion Include="Umbraco.JsonSchema.Extensions" Version="0.3.0" />
|
||||
</ItemGroup>
|
||||
<!-- Third-party packages -->
|
||||
<ItemGroup>
|
||||
<PackageVersion Include="Asp.Versioning.Mvc" Version="10.0.0" />
|
||||
<PackageVersion Include="Asp.Versioning.Mvc.ApiExplorer" Version="10.0.0" />
|
||||
<PackageVersion Include="Asp.Versioning.Mvc" Version="8.1.1" />
|
||||
<PackageVersion Include="Asp.Versioning.Mvc.ApiExplorer" Version="8.1.1" />
|
||||
<PackageVersion Include="Dazinator.Extensions.FileProviders" Version="2.0.0" />
|
||||
<PackageVersion Include="Examine" Version="3.8.0" />
|
||||
<PackageVersion Include="Examine.Core" Version="3.8.0" />
|
||||
<PackageVersion Include="Examine" Version="3.7.1" />
|
||||
<PackageVersion Include="Examine.Core" Version="3.7.1" />
|
||||
<PackageVersion Include="HtmlAgilityPack" Version="1.12.4" />
|
||||
<PackageVersion Include="JsonPatch.Net" Version="3.3.0" />
|
||||
<PackageVersion Include="K4os.Compression.LZ4" Version="1.3.8" />
|
||||
<PackageVersion Include="MailKit" Version="4.16.0" />
|
||||
<PackageVersion Include="Markdig" Version="1.1.3" />
|
||||
<PackageVersion Include="MailKit" Version="4.14.1" />
|
||||
<PackageVersion Include="Markdig" Version="0.44.0" />
|
||||
<PackageVersion Include="Markdown" Version="2.2.1" />
|
||||
<PackageVersion Include="MessagePack" Version="3.1.4" />
|
||||
<PackageVersion Include="MiniProfiler.AspNetCore.Mvc" Version="4.5.4" />
|
||||
<PackageVersion Include="MiniProfiler.Shared" Version="4.5.4" />
|
||||
<PackageVersion Include="ncrontab" Version="3.4.0" />
|
||||
<PackageVersion Include="NPoco" Version="6.2.0" />
|
||||
<PackageVersion Include="NPoco.SqlServer" Version="6.2.0" />
|
||||
<PackageVersion Include="OpenIddict.Abstractions" Version="7.5.0" />
|
||||
<PackageVersion Include="OpenIddict.AspNetCore" Version="7.5.0" />
|
||||
<PackageVersion Include="OpenIddict.EntityFrameworkCore" Version="7.5.0" />
|
||||
<PackageVersion Include="Serilog" Version="4.3.1" />
|
||||
<PackageVersion Include="Serilog.AspNetCore" Version="10.0.0" />
|
||||
<PackageVersion Include="NPoco" Version="6.1.0" />
|
||||
<PackageVersion Include="NPoco.SqlServer" Version="6.1.0" />
|
||||
<PackageVersion Include="OpenIddict.Abstractions" Version="7.2.0" />
|
||||
<PackageVersion Include="OpenIddict.AspNetCore" Version="7.2.0" />
|
||||
<PackageVersion Include="OpenIddict.EntityFrameworkCore" Version="7.2.0" />
|
||||
<PackageVersion Include="Serilog" Version="4.3.0" />
|
||||
<PackageVersion Include="Serilog.AspNetCore" Version="9.0.0" />
|
||||
<PackageVersion Include="Serilog.Enrichers.Process" Version="3.0.0" />
|
||||
<PackageVersion Include="Serilog.Enrichers.Thread" Version="4.0.0" />
|
||||
<PackageVersion Include="Serilog.Expressions" Version="5.0.0" />
|
||||
<PackageVersion Include="Serilog.Extensions.Hosting" Version="10.0.0" />
|
||||
<PackageVersion Include="Serilog.Extensions.Hosting" Version="9.0.0" />
|
||||
<PackageVersion Include="Serilog.Formatting.Compact" Version="3.0.0" />
|
||||
<PackageVersion Include="Serilog.Formatting.Compact.Reader" Version="4.0.0" />
|
||||
<PackageVersion Include="Serilog.Settings.Configuration" Version="10.0.0" />
|
||||
<PackageVersion Include="Serilog.Settings.Configuration" Version="9.0.0" />
|
||||
<PackageVersion Include="Serilog.Sinks.Async" Version="2.1.0" />
|
||||
<PackageVersion Include="Serilog.Sinks.File" Version="7.0.0" />
|
||||
<PackageVersion Include="Serilog.Sinks.Map" Version="2.0.0" />
|
||||
<PackageVersion Include="SixLabors.ImageSharp" Version="3.1.12" />
|
||||
<PackageVersion Include="SixLabors.ImageSharp.Web" Version="3.2.0" />
|
||||
<PackageVersion Include="Swashbuckle.AspNetCore.SwaggerUI" Version="10.1.7" />
|
||||
<PackageVersion Include="Swashbuckle.AspNetCore" Version="10.1.0" />
|
||||
</ItemGroup>
|
||||
<!-- Transitive pinned versions (only required because our direct dependencies have vulnerable versions of transitive dependencies) -->
|
||||
<ItemGroup>
|
||||
@@ -91,8 +87,5 @@
|
||||
<!-- Markdown references vulnerable version of the following: -->
|
||||
<!-- TODO (V19): Remove these pinned dependencies when the Markdown dependency is removed. -->
|
||||
<PackageVersion Include="System.Text.RegularExpressions" Version="4.3.1" />
|
||||
<!-- Examine (via Microsoft.AspNetCore.DataProtection 8.0.4) references a vulnerable version of the following: -->
|
||||
<!-- TODO: Remove this pinned dependency when Examine updates its Microsoft.AspNetCore.DataProtection reference. -->
|
||||
<PackageVersion Include="System.Security.Cryptography.Xml" Version="10.0.7" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
+17
-122
@@ -45,7 +45,7 @@ parameters:
|
||||
- name: integrationNonReleaseTestFilter
|
||||
displayName: TestFilter used for non-release type builds
|
||||
type: string
|
||||
default: "TestCategory!=LongRunning&TestCategory!=NonCritical"
|
||||
default: "--filter TestCategory!=LongRunning&TestCategory!=NonCritical"
|
||||
- name: integrationReleaseTestFilter
|
||||
displayName: TestFilter used for release type builds
|
||||
type: string
|
||||
@@ -53,7 +53,7 @@ parameters:
|
||||
- name: nonWindowsIntegrationNonReleaseTestFilter
|
||||
displayName: TestFilter used for non-release type builds on non Windows agents
|
||||
type: string
|
||||
default: "TestCategory!=LongRunning&TestCategory!=NonCritical"
|
||||
default: "--filter TestCategory!=LongRunning&TestCategory!=NonCritical"
|
||||
- name: nonWindowsIntegrationReleaseTestFilter
|
||||
displayName: TestFilter used for release type builds on non Windows agents
|
||||
type: string
|
||||
@@ -117,7 +117,7 @@ stages:
|
||||
artifactName: csharp-docs-dlls
|
||||
- powershell: |
|
||||
dotnet tool install --global CycloneDX
|
||||
dotnet-CycloneDX $(solution) --spec-version 1.5 --output $(Build.ArtifactStagingDirectory)/bom --filename bom-dotnet.xml
|
||||
dotnet-CycloneDX $(solution) --output $(Build.ArtifactStagingDirectory)/bom --filename bom-dotnet.xml
|
||||
displayName: 'Generate Backend BOM'
|
||||
- powershell: |
|
||||
npm install --global @cyclonedx/cyclonedx-npm
|
||||
@@ -175,34 +175,6 @@ stages:
|
||||
artifact: bom-frontend
|
||||
displayName: 'Publish Frontend BOM'
|
||||
|
||||
- job: C
|
||||
displayName: Build Test Helpers Package
|
||||
pool:
|
||||
vmImage: "ubuntu-latest"
|
||||
steps:
|
||||
- checkout: self
|
||||
submodules: false
|
||||
lfs: false
|
||||
fetchDepth: 500
|
||||
- template: templates/e2e-install.yml
|
||||
parameters:
|
||||
nodeVersion: ${{ variables.nodeVersion }}
|
||||
npm_config_cache: ${{ variables.npm_config_cache }}
|
||||
- template: templates/set-npm-version.yml
|
||||
parameters:
|
||||
workingDirectory: tests/Umbraco.Tests.AcceptanceTest
|
||||
- bash: |
|
||||
echo "##[command]Running npm pack"
|
||||
mkdir $(Build.ArtifactStagingDirectory)/npm-testhelpers
|
||||
npm pack --pack-destination $(Build.ArtifactStagingDirectory)/npm-testhelpers
|
||||
displayName: Run npm pack
|
||||
workingDirectory: tests/Umbraco.Tests.AcceptanceTest
|
||||
- task: PublishPipelineArtifact@1
|
||||
displayName: Publish Test Helpers npm artifact
|
||||
inputs:
|
||||
targetPath: $(Build.ArtifactStagingDirectory)/npm-testhelpers
|
||||
artifactName: npm-testhelpers
|
||||
|
||||
- stage: E2E_BOM
|
||||
displayName: E2E Tests BOM Generation
|
||||
dependsOn: []
|
||||
@@ -455,13 +427,13 @@ stages:
|
||||
projects: "tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj"
|
||||
testRunTitle: Integration Tests SQLite - $(Agent.OS)
|
||||
${{ if and(eq(variables['Agent.OS'],'Windows_NT'), or(variables.releaseTestFilter, parameters.forceReleaseTestFilter)) }}:
|
||||
arguments: '--filter "$(testFilter)" --configuration $(buildConfiguration) --no-build'
|
||||
arguments: '--filter "$(testFilter)" --configuration $(buildConfiguration) --no-build ${{parameters.integrationReleaseTestFilter}}'
|
||||
${{ elseif eq(variables['Agent.OS'],'Windows_NT') }}:
|
||||
arguments: '--filter "$(testFilter) & ${{parameters.integrationNonReleaseTestFilter}}" --configuration $(buildConfiguration) --no-build'
|
||||
arguments: '--filter "$(testFilter)" --configuration $(buildConfiguration) --no-build ${{parameters.integrationNonReleaseTestFilter}}'
|
||||
${{ elseif or(variables.releaseTestFilter, parameters.forceReleaseTestFilter) }}:
|
||||
arguments: '--filter "$(testFilter)" --configuration $(buildConfiguration) --no-build'
|
||||
arguments: '--filter "$(testFilter)" --configuration $(buildConfiguration) --no-build ${{parameters.nonWindowsIntegrationReleaseTestFilter}}'
|
||||
${{ else }}:
|
||||
arguments: '--filter "$(testFilter) & ${{parameters.nonWindowsIntegrationNonReleaseTestFilter}}" --configuration $(buildConfiguration) --no-build'
|
||||
arguments: '--filter "$(testFilter)" --configuration $(buildConfiguration) --no-build ${{parameters.nonWindowsIntegrationNonReleaseTestFilter}}'
|
||||
# Integration Tests (SQL Server)
|
||||
- job:
|
||||
timeoutInMinutes: 180
|
||||
@@ -569,13 +541,13 @@ stages:
|
||||
projects: "tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj"
|
||||
testRunTitle: Integration Tests SQL Server - $(Agent.OS)
|
||||
${{ if and(eq(variables['Agent.OS'],'Windows_NT'), or(variables.releaseTestFilter, parameters.forceReleaseTestFilter)) }}:
|
||||
arguments: '--filter "$(testFilter)" --configuration $(buildConfiguration) --no-build'
|
||||
arguments: '--filter "$(testFilter)" --configuration $(buildConfiguration) --no-build ${{parameters.integrationReleaseTestFilter}}'
|
||||
${{ elseif eq(variables['Agent.OS'],'Windows_NT') }}:
|
||||
arguments: '--filter "$(testFilter) & ${{parameters.integrationNonReleaseTestFilter}}" --configuration $(buildConfiguration) --no-build'
|
||||
arguments: '--filter "$(testFilter)" --configuration $(buildConfiguration) --no-build ${{parameters.integrationNonReleaseTestFilter}}'
|
||||
${{ elseif or(variables.releaseTestFilter, parameters.forceReleaseTestFilter) }}:
|
||||
arguments: '--filter "$(testFilter)" --configuration $(buildConfiguration) --no-build'
|
||||
arguments: '--filter "$(testFilter)" --configuration $(buildConfiguration) --no-build ${{parameters.nonWindowsIntegrationReleaseTestFilter}}'
|
||||
${{ else }}:
|
||||
arguments: '--filter "$(testFilter) & ${{parameters.nonWindowsIntegrationNonReleaseTestFilter}}" --configuration $(buildConfiguration) --no-build'
|
||||
arguments: '--filter "$(testFilter)" --configuration $(buildConfiguration) --no-build ${{parameters.nonWindowsIntegrationNonReleaseTestFilter}}'
|
||||
|
||||
# Stop SQL Server
|
||||
- pwsh: docker stop mssql
|
||||
@@ -607,6 +579,7 @@ stages:
|
||||
UMBRACO__CMS__GLOBAL__VERSIONCHECKPERIOD: 0
|
||||
UMBRACO__CMS__GLOBAL__USEHTTPS: true
|
||||
UMBRACO__CMS__HEALTHCHECKS__NOTIFICATION__ENABLED: false
|
||||
UMBRACO__CMS__KEEPALIVE__DISABLEKEEPALIVETASK: true
|
||||
UMBRACO__CMS__WEBROUTING__UMBRACOAPPLICATIONURL: https://localhost:44331/
|
||||
ASPNETCORE_URLS: https://localhost:44331
|
||||
jobs:
|
||||
@@ -859,65 +832,16 @@ stages:
|
||||
npm publish "${files[0]}"
|
||||
displayName: Push to npm (MyGet)
|
||||
workingDirectory: $(Pipeline.Workspace)/npm
|
||||
- job: PublishTestHelpersNpm
|
||||
displayName: Push TestHelpers to pre-release feed (npm)
|
||||
steps:
|
||||
- checkout: none
|
||||
- download: current
|
||||
artifact: npm-testhelpers
|
||||
- bash: |
|
||||
# Check if we are on a nightly build
|
||||
if [ $isNightly = "False" ]; then
|
||||
echo "##[debug]Prerelease build detected"
|
||||
registry="https://www.myget.org/F/umbracoprereleases/npm/"
|
||||
else
|
||||
echo "##[debug]Nightly build detected"
|
||||
registry="https://www.myget.org/F/umbraconightly/npm/"
|
||||
fi
|
||||
echo "@umbraco-cms:registry=$registry" >> .npmrc
|
||||
env:
|
||||
isNightly: ${{parameters.isNightly}}
|
||||
workingDirectory: $(Pipeline.Workspace)/npm-testhelpers
|
||||
displayName: Add scoped registry to .npmrc
|
||||
- task: npmAuthenticate@0
|
||||
displayName: Authenticate with npm (MyGet)
|
||||
inputs:
|
||||
workingFile: "$(Pipeline.Workspace)/npm-testhelpers/.npmrc"
|
||||
customEndpoint: "MyGet (npm) - Umbracoprereleases, MyGet (npm) - Umbraconightly"
|
||||
- bash: |
|
||||
# Setup temp npm project to load in defaults from the local .npmrc
|
||||
npm init -y
|
||||
|
||||
# Find the first .tgz file in the current directory and publish it
|
||||
files=( ./*.tgz )
|
||||
npm publish "${files[0]}"
|
||||
displayName: Push test helpers to npm (MyGet)
|
||||
workingDirectory: $(Pipeline.Workspace)/npm-testhelpers
|
||||
|
||||
- stage: Deploy_NuGet
|
||||
displayName: NuGet release
|
||||
dependsOn: Deploy_MyGet
|
||||
# Run only when Deploy_MyGet actually ran (succeeded or failed) — not when it was skipped due to an upstream test failure.
|
||||
# Inspect Deploy_MyGet's direct result rather than succeeded()/failed(), which are transitive across the full ancestor graph.
|
||||
# Approval is required every run via the WaitForApproval job below.
|
||||
condition: and(in(dependencies.Deploy_MyGet.result, 'Succeeded', 'SucceededWithIssues', 'Failed'), or(eq(dependencies.Build.outputs['A.build.NBGV_PublicRelease'], 'True'), ${{parameters.nuGetDeploy}}))
|
||||
condition: and(succeeded(), or(eq(dependencies.Build.outputs['A.build.NBGV_PublicRelease'], 'True'), ${{parameters.nuGetDeploy}}))
|
||||
jobs:
|
||||
- job: WaitForApproval
|
||||
displayName: Wait for manual approval
|
||||
pool: server
|
||||
steps:
|
||||
- task: ManualValidation@0
|
||||
displayName: Manual approval to push to NuGet
|
||||
timeoutInMinutes: 4320 # 3 days
|
||||
inputs:
|
||||
notifyUsers: ''
|
||||
instructions: 'Approve to push the NuGet release.'
|
||||
onTimeout: 'reject'
|
||||
- job: Push
|
||||
displayName: Push to NuGet
|
||||
dependsOn: WaitForApproval
|
||||
- job:
|
||||
pool:
|
||||
vmImage: "windows-latest" # NuGetCommand@2 is no longer supported on Ubuntu 24.04 so we'll use windows until an alternative is available.
|
||||
displayName: Push to NuGet
|
||||
steps:
|
||||
- checkout: none
|
||||
- task: DownloadPipelineArtifact@2
|
||||
@@ -935,10 +859,7 @@ stages:
|
||||
|
||||
- stage: Deploy_Npm
|
||||
displayName: Npm release
|
||||
# Inspect Deploy_NuGet.result directly so a MyGet failure (which is in the transitive ancestor graph)
|
||||
# doesn't cascade-skip this stage via succeeded(). Deploy_NuGet must itself have succeeded — a NuGet
|
||||
# failure deliberately blocks the npm release.
|
||||
condition: and(in(dependencies.Deploy_NuGet.result, 'Succeeded', 'SucceededWithIssues'), or(eq(dependencies.Build.outputs['A.build.NBGV_PublicRelease'], 'True'), ${{parameters.npmDeploy}}))
|
||||
condition: and(succeeded(), or(eq(dependencies.Build.outputs['A.build.NBGV_PublicRelease'], 'True'), ${{parameters.npmDeploy}}))
|
||||
dependsOn:
|
||||
- Deploy_NuGet
|
||||
jobs:
|
||||
@@ -965,29 +886,6 @@ stages:
|
||||
npm publish "${files[0]}"
|
||||
displayName: Push to npm
|
||||
workingDirectory: $(Pipeline.Workspace)/npm
|
||||
- job: PublishTestHelpers
|
||||
displayName: Push Test Helpers to NPM
|
||||
steps:
|
||||
- checkout: none
|
||||
- download: current
|
||||
artifact: npm-testhelpers
|
||||
- bash: echo "@umbraco-cms:registry=https://registry.npmjs.org" >> .npmrc
|
||||
workingDirectory: $(Pipeline.Workspace)/npm-testhelpers
|
||||
displayName: Add scoped registry to .npmrc
|
||||
- task: npmAuthenticate@0
|
||||
displayName: Authenticate with npm
|
||||
inputs:
|
||||
workingFile: $(Pipeline.Workspace)/npm-testhelpers/.npmrc
|
||||
customEndpoint: "NPM - Umbraco Backoffice"
|
||||
- script: |
|
||||
# Setup temp npm project to load in defaults from the local .npmrc
|
||||
npm init -y
|
||||
|
||||
# Find the first .tgz file in the current directory and publish it
|
||||
files=( ./*.tgz )
|
||||
npm publish "${files[0]}"
|
||||
displayName: Push test helpers to npm
|
||||
workingDirectory: $(Pipeline.Workspace)/npm-testhelpers
|
||||
|
||||
- stage: Upload_API_Docs
|
||||
pool:
|
||||
@@ -999,10 +897,7 @@ stages:
|
||||
- Build
|
||||
- Build_Docs
|
||||
- Deploy_NuGet
|
||||
# Build_Docs must have produced artifacts (we won't upload anything otherwise) and Deploy_NuGet must
|
||||
# have succeeded — a NuGet failure deliberately blocks the docs upload. Direct result checks avoid
|
||||
# transitive succeeded()/failed() which would cascade-skip on a MyGet failure.
|
||||
condition: and(in(dependencies.Build_Docs.result, 'Succeeded', 'SucceededWithIssues'), in(dependencies.Deploy_NuGet.result, 'Succeeded', 'SucceededWithIssues'), or(eq(dependencies.Build.outputs['A.build.NBGV_PublicRelease'], 'True'), ${{parameters.uploadApiDocs}}))
|
||||
condition: and(succeeded(), or(eq(dependencies.Build.outputs['A.build.NBGV_PublicRelease'], 'True'), ${{parameters.uploadApiDocs}}))
|
||||
jobs:
|
||||
- job:
|
||||
displayName: Upload C# Docs
|
||||
|
||||
@@ -5,9 +5,11 @@ trigger: none
|
||||
|
||||
schedules:
|
||||
- cron: '0 0 * * *'
|
||||
displayName: Daily 0AM build (main)
|
||||
displayName: Daily midnight build
|
||||
branches:
|
||||
include:
|
||||
- v15/dev
|
||||
- v16/dev
|
||||
- main
|
||||
|
||||
parameters:
|
||||
@@ -117,7 +119,7 @@ stages:
|
||||
- stage: Integration
|
||||
displayName: Integration Tests
|
||||
dependsOn: Build
|
||||
condition: and(succeeded(), ${{ eq(parameters.skipIntegrationTests, false) }})
|
||||
condition: ${{ eq(parameters.skipIntegrationTests, false) }}
|
||||
jobs:
|
||||
# Integration Tests (SQLite)
|
||||
- job:
|
||||
@@ -199,37 +201,31 @@ stages:
|
||||
SA_PASSWORD: UmbracoAcceptance123!
|
||||
strategy:
|
||||
matrix:
|
||||
# Windows is split into 5 parts (ManagementApi split in two to avoid memory pressure on LocalDb); Linux into 4.
|
||||
WindowsPart1Of5:
|
||||
# We split the tests into 4 parts for each OS to reduce the time it takes to run them on the pipeline
|
||||
WindowsPart1Of4:
|
||||
vmImage: "windows-latest"
|
||||
Tests__Database__DatabaseType: LocalDb
|
||||
Tests__Database__SQLServerMasterConnectionString: N/A
|
||||
# Filter tests that are part of the Umbraco.Infrastructure namespace but not part of the Umbraco.Infrastructure.Service namespace
|
||||
testFilter: "(FullyQualifiedName~Umbraco.Infrastructure) & (FullyQualifiedName!~Umbraco.Infrastructure.Service)"
|
||||
WindowsPart2Of5:
|
||||
WindowsPart2Of4:
|
||||
vmImage: "windows-latest"
|
||||
Tests__Database__DatabaseType: LocalDb
|
||||
Tests__Database__SQLServerMasterConnectionString: N/A
|
||||
# Filter tests that are part of the Umbraco.Infrastructure.Service namespace
|
||||
testFilter: "(FullyQualifiedName~Umbraco.Infrastructure.Service)"
|
||||
WindowsPart3Of5:
|
||||
WindowsPart3Of4:
|
||||
vmImage: "windows-latest"
|
||||
Tests__Database__DatabaseType: LocalDb
|
||||
Tests__Database__SQLServerMasterConnectionString: N/A
|
||||
# Filter tests that are not part of the Umbraco.Infrastructure and ManagementApi namespace.
|
||||
testFilter: "(FullyQualifiedName!~Umbraco.Infrastructure) & (FullyQualifiedName!~ManagementApi)"
|
||||
WindowsPart4Of5:
|
||||
WindowsPart4Of4:
|
||||
vmImage: "windows-latest"
|
||||
Tests__Database__DatabaseType: LocalDb
|
||||
Tests__Database__SQLServerMasterConnectionString: N/A
|
||||
# ManagementApi, heavier sub-namespaces. Trailing dots prevent "User." from matching "UserGroup." etc.
|
||||
testFilter: "FullyQualifiedName~ManagementApi & (FullyQualifiedName~ManagementApi.Element. | FullyQualifiedName~ManagementApi.User. | FullyQualifiedName~ManagementApi.Document. | FullyQualifiedName~ManagementApi.DataType. | FullyQualifiedName~ManagementApi.DocumentType. | FullyQualifiedName~ManagementApi.MediaType. | FullyQualifiedName~ManagementApi.Template.)"
|
||||
WindowsPart5Of5:
|
||||
vmImage: "windows-latest"
|
||||
Tests__Database__DatabaseType: LocalDb
|
||||
Tests__Database__SQLServerMasterConnectionString: N/A
|
||||
# ManagementApi, remainder (complement of Part4). vstest filters do not support group
|
||||
testFilter: "FullyQualifiedName~ManagementApi & FullyQualifiedName!~ManagementApi.Element. & FullyQualifiedName!~ManagementApi.User. & FullyQualifiedName!~ManagementApi.Document. & FullyQualifiedName!~ManagementApi.DataType. & FullyQualifiedName!~ManagementApi.DocumentType. & FullyQualifiedName!~ManagementApi.MediaType. & FullyQualifiedName!~ManagementApi.Template."
|
||||
# Filter tests that are part of the ManagementApi namespace.
|
||||
testFilter: "(FullyQualifiedName~ManagementApi)"
|
||||
LinuxPart1Of4:
|
||||
vmImage: "ubuntu-latest"
|
||||
Tests__Database__DatabaseType: SqlServer
|
||||
@@ -325,8 +321,7 @@ stages:
|
||||
|
||||
- stage: DefaultConfigE2E
|
||||
displayName: Default Config E2E Tests
|
||||
dependsOn: [Build, Integration]
|
||||
condition: in(dependencies.Build.result, 'Succeeded', 'SucceededWithIssues')
|
||||
dependsOn: Build
|
||||
variables:
|
||||
npm_config_cache: $(Pipeline.Workspace)/.npm_e2e
|
||||
# Enable console logging in Release mode
|
||||
@@ -345,6 +340,7 @@ stages:
|
||||
UMBRACO__CMS__GLOBAL__VERSIONCHECKPERIOD: 0
|
||||
UMBRACO__CMS__GLOBAL__USEHTTPS: true
|
||||
UMBRACO__CMS__HEALTHCHECKS__NOTIFICATION__ENABLED: false
|
||||
UMBRACO__CMS__KEEPALIVE__DISABLEKEEPALIVETASK: true
|
||||
UMBRACO__CMS__WEBROUTING__UMBRACOAPPLICATIONURL: https://localhost:44331/
|
||||
ASPNETCORE_URLS: https://localhost:44331
|
||||
jobs:
|
||||
@@ -506,8 +502,7 @@ stages:
|
||||
|
||||
- stage: AdditionalConfigE2E
|
||||
displayName: Additional Config E2E Tests
|
||||
dependsOn: [Build, DefaultConfigE2E]
|
||||
condition: in(dependencies.Build.result, 'Succeeded', 'SucceededWithIssues')
|
||||
dependsOn: Build
|
||||
variables:
|
||||
npm_config_cache: $(Pipeline.Workspace)/.npm_e2e
|
||||
ASPNETCORE_URLS: https://localhost:44331
|
||||
|
||||
@@ -10,7 +10,6 @@ schedules:
|
||||
include:
|
||||
- v13/dev
|
||||
- v16/dev
|
||||
- v18/dev
|
||||
- main
|
||||
|
||||
steps:
|
||||
|
||||
@@ -6,9 +6,16 @@ steps:
|
||||
versionSource: 'fromFile'
|
||||
versionFilePath: src/Umbraco.Web.UI.Client/.nvmrc
|
||||
|
||||
- template: set-npm-version.yml
|
||||
parameters:
|
||||
workingDirectory: src/Umbraco.Web.UI.Client
|
||||
- bash: |
|
||||
echo "##[command]Install nbgv"
|
||||
dotnet tool install --tool-path . nbgv
|
||||
echo "##[command]Running nbgv get-version"
|
||||
PACKAGE_VERSION=$(nbgv get-version -v NpmPackageVersion)
|
||||
echo "##[command]Running npm version"
|
||||
echo "##[debug]Version: $PACKAGE_VERSION"
|
||||
cd src/Umbraco.Web.UI.Client
|
||||
npm version $PACKAGE_VERSION --allow-same-version --no-git-tag-version
|
||||
displayName: Set NPM Version
|
||||
|
||||
- task: Cache@2
|
||||
displayName: Cache node_modules
|
||||
|
||||
@@ -29,7 +29,7 @@ steps:
|
||||
"UMBRACO_USER_LOGIN=${{ parameters.PlaywrightUserEmail }}
|
||||
UMBRACO_USER_PASSWORD=${{ parameters.PlaywrightPassword }}
|
||||
URL=${{ parameters.ASPNETCORE_URLS }}
|
||||
STORAGE_STATE_PATH=$(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest/playwright/.auth/user.json
|
||||
STORAGE_STAGE_PATH=$(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest/playwright/.auth/user.json
|
||||
CONSOLE_ERRORS_PATH=$(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest/console-errors.json" | Out-File .env
|
||||
displayName: Generate .env
|
||||
workingDirectory: $(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest
|
||||
@@ -47,7 +47,3 @@ steps:
|
||||
- script: npm ci --no-fund --no-audit --prefer-offline
|
||||
workingDirectory: $(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest
|
||||
displayName: Restore NPM packages
|
||||
|
||||
- script: npm run build
|
||||
workingDirectory: $(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest
|
||||
displayName: Build test helpers
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
parameters:
|
||||
- name: workingDirectory
|
||||
type: string
|
||||
|
||||
steps:
|
||||
- bash: |
|
||||
echo "##[command]Install nbgv"
|
||||
dotnet tool install --tool-path . nbgv
|
||||
echo "##[command]Running nbgv get-version"
|
||||
PACKAGE_VERSION=$(nbgv get-version -v NpmPackageVersion)
|
||||
echo "##[command]Running npm version"
|
||||
echo "##[debug]Version: $PACKAGE_VERSION"
|
||||
cd ${{ parameters.workingDirectory }}
|
||||
npm version $PACKAGE_VERSION --allow-same-version --no-git-tag-version
|
||||
displayName: Set NPM Version
|
||||
@@ -1,96 +0,0 @@
|
||||
# Visual Editor — Partial Re-render (Phase 3 remainder) — Design
|
||||
|
||||
**Status**: Implemented (spike passed 2026-06-11; see `2026-06-11-visual-editor-partial-rerender-plan.md`). Built via cache-node override + `IPublishedContentFactory` rather than a decorator — see the plan's "Deliberate deviation" note.
|
||||
**Date**: 2026-06-11
|
||||
**Author**: Rick Butterfield + Claude
|
||||
**Scope**: The "Still to build" items of Phase 3 in `docs/plans/visual-page-builder.md` — server-side partial re-render with unsaved values, and client-side DOM patching. Block manipulation itself is already done.
|
||||
**Relates to**: `docs/plans/visual-page-builder.md` §4.4 (original endpoint sketch), §2.3 (BlockPreview pattern); supersedes the isolated-region endpoint idea in §4.4 in favour of full-page render + client morph.
|
||||
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Make partial re-render the **single, universal mechanism** for reflecting edits in the visual editor preview, retiring both the optimistic-text-only path and the save-and-full-reload path. Every edit — plain text, RTE/Markdown/media, block content/settings, and structural block add/delete/move/reorder — is reflected by re-rendering the page server-side with the workspace's unsaved values and morphing the live iframe DOM in place (no reload, scroll/selection preserved).
|
||||
|
||||
## Decisions locked
|
||||
|
||||
| Decision | Outcome |
|
||||
|---|---|
|
||||
| Trigger scope | **All** edit types route through re-render: block content/settings, block add/delete/move/reorder, RTE/Markdown/transformed properties, and plain text. |
|
||||
| Feedback model | **Optimistic + authoritative**: instant optimistic `textContent` paint for plain text on keystroke; a debounced (~500ms) server re-render then replaces the region with true Razor output. Blocks/RTE show a subtle pending state (no meaningful optimistic paint) until the render returns. |
|
||||
| Rendering approach | **A — full-page render + client DOM morph.** One endpoint renders the whole page via the existing preview path with unsaved values injected; guest morphs the live DOM. Chosen over isolated-region (B) because only a full-page render covers arbitrary-template property placement with guaranteed fidelity, and over hybrid (C) for single-path simplicity. |
|
||||
| DOM patch | Bundle **morphdom** in the guest bundle; morph `<body>`, touching only changed nodes; preserves scroll. |
|
||||
| Failure mode | **Keep last good DOM + quiet notice.** Leave current DOM untouched, log, transient non-blocking indicator; the workspace already holds the edit so the next successful render reconciles. Never silently swallow. |
|
||||
| Save + SignalR | **Suppress self-reload, keep as multi-user net.** After a local save, a short-lived guard makes the editor ignore its own `refreshed` SignalR event (DOM already authoritative — no flicker). Refreshes not caused by this editor still reload. |
|
||||
|
||||
## Architecture & data flow
|
||||
|
||||
```
|
||||
edit (property / block / structural)
|
||||
→ element updates workspace value (source of truth) [+ optimistic textContent for plain text]
|
||||
→ UmbVisualEditorRenderController: debounce ~500ms, latest-wins (AbortController cancels in-flight)
|
||||
→ POST /umbraco/management/api/v1/visual-editor/render
|
||||
body: { unique, culture?, segment?, values: [{ alias, value, culture?, segment? }] }
|
||||
→ server:
|
||||
EnsureUmbracoContext + force preview mode + VisualEditorPropertyTracker.Enable() for the render scope
|
||||
base = DRAFT content from the published cache (preview read — same as the iframe shows)
|
||||
wrap in PropertyOverridePublishedContent(unsaved values)
|
||||
render the assigned template → HTML string (data-umb-* annotations emitted)
|
||||
→ { html }
|
||||
→ element posts umb:ve:render to the guest with the HTML
|
||||
→ guest morphs <body> (morphdom) → re-runs initRegions() → restores selection highlight
|
||||
```
|
||||
|
||||
The base is the **draft** content the iframe already renders (preview-mode cache read); the override layer is the workspace's even-newer unsaved edits on top.
|
||||
|
||||
## Server components (new)
|
||||
|
||||
| Unit | Project | Responsibility |
|
||||
|---|---|---|
|
||||
| Override-content builder (conversion) | `Umbraco.PublishedCache.HybridCache` (or a public seam exposed from it) | Produce an `IPublishedContent` representing the draft + unsaved overrides. **Approach proven by the spike** (and mirroring the in-tree `BlockElementService.BuildElementAsync`): for each overridden alias, run the editor-format value through `dataType.Editor.GetValueEditor().FromEditor(new ContentPropertyData(value, dataType.ConfigurationObject), null)` to get the source value; reuse the existing saved source values (`property.GetValue(published)`) for non-overridden aliases; assemble `PropertyData[]` → `ContentData` → `ContentCacheNode` → `IPublishedContentFactory.ToIPublishedContent(node, preview: true).CreateModel(...)`. Threads `Culture`/`Segment` onto `PropertyData` and sets `ContentData.CultureInfos` for variant content. **Not** a `GetProperty` decorator — a cache-node rebuild. (`IPublishedContentFactory` is `internal` to HybridCache, hence this unit lives there or a small public seam is added — resolved in the plan.) |
|
||||
| `IVisualEditorRenderService` + impl | `Umbraco.Web.Common` | Renders a supplied `IPublishedContent` to an HTML string. Modeled on `TemplateRenderer` (`src/Umbraco.Web.Common/Templates/TemplateRenderer.cs`): build an `IPublishedRequest` via `IPublishedRouter`, `SetPublishedContent(overriddenContent)`, set culture/segment + template, swap onto `UmbracoContext.PublishedRequest`, render the template view to a `StringWriter`, restore. Forces preview mode + enables `VisualEditorPropertyTracker` for the render scope so annotations are emitted. RTE-embedded blocks render via the partial-view block engine, which this render context satisfies. |
|
||||
| `RenderVisualEditorController` | `Umbraco.Cms.Api.Management` | `POST /umbraco/management/api/v1/visual-editor/render`, `[Authorize(Policy = BackOfficeAccess)]`. Ensures an `UmbracoContext`, resolves the draft content for `unique`, builds the override content from the request `values`, calls the render service, returns `{ html }`. |
|
||||
|
||||
**Value conversion — DE-RISKED by the spike (2026-06-11).** All three property kinds convert correctly via `IPublishedContentFactory.ToIPublishedContent`:
|
||||
- **TextBox** — `FromEditor` → string source → published string. Clean.
|
||||
- **Rich Text** — `FromEditor` → source JSON → `RteBlockRenderingValueConverter`; all link/url/image parsing happens at value-conversion time (no `IPublishedRequest` needed). RTE-*embedded blocks* additionally use the partial-view block engine at render time (covered by the full-page render context — smoke-test specifically).
|
||||
- **Block List** — `FromEditor` source IS the block JSON; the converter resolves element types from the published content-type cache (no parent content / `IPublishedRequest` needed). Blocks need an `Expose` entry for the relevant culture/segment to surface.
|
||||
|
||||
Recommended primitive: reuse `IPublishedContentFactory` rather than hand-assembling per property. Variant content must populate `PropertyData` per culture/segment + `ContentData.CultureInfos`, and read-time resolution depends on the ambient `IVariationContextAccessor`.
|
||||
|
||||
## Client components
|
||||
|
||||
| Unit | Responsibility |
|
||||
|---|---|
|
||||
| `UmbVisualEditorRenderController` (new sibling, follows the SignalR/router/resolver extraction pattern) | Debounce (~500ms) + latest-wins cancellation via `AbortController`. Collects the active variant's current values from the workspace, calls the endpoint, posts `umb:ve:render` to the guest with the returned HTML. On failure: keep DOM, log, transient notice. Invoked from every mutation site (property submit, block submit, add/move/delete/reorder, and the debounced optimistic text input). |
|
||||
| guest `injected.ts` | Bundle **morphdom**. Refactor the one-shot init (default outlines, drag-sort setup, add-button insertion, region discovery) into a re-runnable `initRegions()`. On `umb:ve:render`: morph `document.body` to the new HTML, then run `initRegions()` and restore the selection highlight. Delegated document-level listeners (click capture, mouseover) survive the morph; per-node styles/attributes are re-applied by `initRegions()`. |
|
||||
| element SignalR (`visual-editor-signalr.controller.ts` + element) | Reintroduce a short-lived **suppress-self-reload** guard set when this editor saves, so the `refreshed` event for our own document key is ignored. Refreshes outside the guard window still reload (multi-user / external cache changes). |
|
||||
|
||||
## Error handling
|
||||
|
||||
- Render failure (network/500/timeout): keep last good DOM, log, show a transient non-blocking "preview out of date" indicator. The edit is already in the workspace; a later successful render reconciles. No silent swallow.
|
||||
- Latest-wins: a newer edit aborts the in-flight render so stale HTML never overwrites newer DOM.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Render caching / output pooling beyond debounce + concurrency cap.
|
||||
- Headless / Delivery-API rendering in the iframe.
|
||||
- Surfacing validation state in the preview.
|
||||
- Server-side sub-region extraction (full-page render + client morph already delivers partial DOM updates).
|
||||
- Inline (`contenteditable`) editing — that is Phase 4 and now has its server-rendered source of truth from this phase.
|
||||
|
||||
## Testing
|
||||
|
||||
- **Backend integration test** (the riskiest, and testable C#, unlike the UI surface): the spike's throwaway test at `tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/PropertyEditors/VisualEditorConversionSpikeTests.cs` (uncommitted) is the basis — the plan's first task formalizes it into a real test of the override-content builder for TextBox, RTE, and Block List (assert converted-without-saving == save-then-read). A second test renders a seeded document through the render service and asserts the HTML reflects overridden values and carries `data-umb-*` annotations.
|
||||
- **Frontend**: `npm run build` + `npm run lint` + manual smoke (no VE test harness exists; consistent with the prior phase).
|
||||
|
||||
## Spike outcome (2026-06-11) — PASSED
|
||||
|
||||
A throwaway integration test (`VisualEditorConversionSpikeTests`, uncommitted) booted Umbraco on SQLite, seeded a doc with TextBox + Rich Text + Block List, and proved that each property's editor-format value converts to the correct published value **without saving**, via `FromEditor` + `IPublishedContentFactory.ToIPublishedContent`. All 3 assertions passed (convert-without-saving == save-then-read). Findings folded into "Server components" above:
|
||||
|
||||
- Conversion primitive: `IPublishedContentFactory` (HybridCache, `internal`) — plan must resolve the access seam.
|
||||
- Approach is a cache-node rebuild, **not** a `GetProperty` decorator (in-tree precedent: `BlockElementService`).
|
||||
- Variants: thread `Culture`/`Segment` + `ContentData.CultureInfos`; read-time needs `IVariationContextAccessor`.
|
||||
- Render-to-string is independently de-risked by the existing `TemplateRenderer`; RTE-embedded-block partials are the one spot needing the render context (not the value conversion).
|
||||
|
||||
No design fallback required — the approach is viable as chosen.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,85 +0,0 @@
|
||||
# Visual Editor Tidy-Up — Design
|
||||
|
||||
**Status**: Implemented (manual smoke pass pending)
|
||||
**Date**: 2026-06-11
|
||||
**Author**: Rick Butterfield + Claude
|
||||
**Scope**: Tidy-up round on `feature/visual-editor` after merging `main` — no new feature phases.
|
||||
**Relates to**: `docs/plans/visual-page-builder.md` (the feature plan; updated as part of this round)
|
||||
|
||||
---
|
||||
|
||||
## Decisions locked in this round
|
||||
|
||||
| Decision | Outcome |
|
||||
|---|---|
|
||||
| Architecture | **Embedded document-workspace view** is the current direction. The standalone-window evolution (plan doc §10, Open Q11) is **deferred**, not the next step. |
|
||||
| Round scope | **Tidy-up only** — security, semantics, refactor, docs. No partial re-render API, no inline editing. |
|
||||
| Editability semantics | **Strict opt-in everywhere** for document properties: a property is annotated/editable only when `appearance.editableInVisualEditor === true`. The frontend opt-out fallback is removed. |
|
||||
| Block modal properties | **No filter**: the block editing modal shows all of the element type's content/settings properties. The `EditableInVisualEditor` setting governs document property annotation only. |
|
||||
|
||||
## Why
|
||||
|
||||
The branch is functionally far ahead of its plan doc (Phases 1–2 complete plus most block manipulation), but an audit found:
|
||||
|
||||
1. **Security**: the guest script (`src/Umbraco.Web.UI.Client/src/apps/visual-editor/injected.ts:540`) accepts `message` events with no `evt.origin` check, and posts with target `'*'`. The backoffice-side listener also lacks source/origin validation.
|
||||
2. **Semantic mismatch**: backend tracking is strict opt-in (`PublishedContentExtensions.TrackVisualEditorAccess` checks `EditableInVisualEditor`), while the frontend had a conflicting "if none opt in, include all" fallback — dead code for properties, but confusing and wrong.
|
||||
3. **Maintainability**: `document-workspace-view-visual-editor.element.ts` is 1,210 lines with ~11 responsibilities.
|
||||
4. **Gap**: root-level empty Block Lists cannot offer "Add content" (container lacks a property-alias annotation; `injected.ts:1040` TODO).
|
||||
5. **Stale docs**: `visual-page-builder.md` predates the `EditableInVisualEditor` setting and records "standalone window" as decided.
|
||||
|
||||
## Changes
|
||||
|
||||
### 1. Security hardening
|
||||
|
||||
**Guest script** (`injected.ts`):
|
||||
- Derive `PARENT_ORIGIN` once: `document.referrer ? new URL(document.referrer).origin : window.location.origin`.
|
||||
- Incoming handler: drop messages where `evt.origin !== PARENT_ORIGIN`.
|
||||
- Outgoing: `window.parent.postMessage(msg, PARENT_ORIGIN)` instead of `'*'`.
|
||||
- Referrer-based derivation keeps cross-origin dev (Vite 5173 → server 44339) working.
|
||||
|
||||
**Workspace view element**: the `message` listener accepts only events where `evt.source === iframe.contentWindow` **and** `evt.origin` equals the server origin from `UMB_SERVER_CONTEXT`.
|
||||
|
||||
### 2. Strict opt-in semantics
|
||||
|
||||
In the element's property-structure resolution:
|
||||
- Remove the `anyExplicitlyEnabled` hybrid entirely.
|
||||
- Document property METADATA stays unfiltered (it doubles as block-config lookup for `#getBlocksConfig`); enforcement is at the interaction points instead: `#onPropertyClicked` and the property modal `onSetup` both require `editableInVisualEditor === true` (defense-in-depth on top of server-side annotation gating).
|
||||
- Remove the filter entirely from block content/settings structure resolution (blocks show all fields).
|
||||
- Drop the `as { editableInVisualEditor?: boolean }` casts — the generated API types carry `appearance.editableInVisualEditor` natively; the resolver maps it onto `UmbVisualEditorPropertyInfo.editableInVisualEditor`.
|
||||
|
||||
Backend is already strict — no backend change.
|
||||
|
||||
### 3. Element refactor (extraction-only)
|
||||
|
||||
Extract from `document-workspace-view-visual-editor.element.ts` into sibling files; no behavior change:
|
||||
|
||||
| New file | Responsibility |
|
||||
|---|---|
|
||||
| `visual-editor-signalr.controller.ts` | `HubConnection` lifecycle, `refreshed` event, refresh-suppression guard |
|
||||
| `visual-editor-property-structure.resolver.ts` | Document/block/settings property-structure resolution incl. composition-chain fetch and caching; `Map`-indexed by alias (replaces 6× O(n) `find()`); sole home of the opt-in filter |
|
||||
| `visual-editor-message-router.ts` | Typed message-map routing of guest messages (replaces 7-case switch); performs the origin/source validation from §1 |
|
||||
|
||||
The element keeps iframe lifecycle, modal registrations, selection state and preview URL — target ≤ ~600 lines. Also: `Object.keys(pastedBlocks.layout)[0]` → `Object.values(pastedBlocks.layout)[0]` (line 972).
|
||||
|
||||
### 4. Root-level empty block lists
|
||||
|
||||
- `BlockListTemplateExtensions` passes the property alias to the partial via `ViewData` (alias-aware overloads; empty models no longer short-circuit so the partial can render an annotated empty container in preview mode).
|
||||
- `Views/Partials/blocklist/default.cshtml` emits `data-umb-block-property="<alias>"` on the list container — a distinct attribute, because `data-umb-property` is the guest script's property-region selector and would turn the whole list into a clickable property region.
|
||||
- `injected.ts` resolves the alias from the container for empty root-level lists and renders the existing "Add content" button via a new `umb:ve:block-add-to-property` message (closes the `injected.ts:1040` TODO).
|
||||
|
||||
### 5. Docs & polish
|
||||
|
||||
- XML docs: class-level summary on `VisualEditorPropertyTracker`; `<param>` tags on `VisualEditorGuestScript.GetScriptTag()`.
|
||||
- `docs/plans/visual-page-builder.md`: refresh status header and phase statuses; close Open Q5 (setting shipped, strict opt-in); mark §10/Q11 standalone window **Deferred** with embedded view as current; update Appendix B attribute table.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Partial re-render API (Phase 3 remainder), inline editing (Phase 4), headless rendering, validation surfaced in preview, scroll retention.
|
||||
- Moving the visual editor to its own package / lifting block-manipulation logic to library level — revisit with Phase 3.
|
||||
- Automated tests for the visual editor (no harness exists for this surface yet; E2E coverage noted in the plan doc as future work).
|
||||
|
||||
## Verification
|
||||
|
||||
1. `npm run build` and `npm run lint` in `src/Umbraco.Web.UI.Client`.
|
||||
2. `dotnet build umbraco.sln` — zero errors, no new warnings.
|
||||
3. Manual smoke in the visual editor tab: property edit (flagged + unflagged property), block add/edit/settings/move/delete, empty root-level block list "Add content", save → SignalR refresh → selection restore, postMessage still works in dev (Vite) and built modes.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,125 +0,0 @@
|
||||
# Visual Editor — Framework-Emitted Empty-Block Affordance — Design
|
||||
|
||||
**Status**: Implemented
|
||||
**Date**: 2026-06-12
|
||||
**Author**: Rick Butterfield + Claude
|
||||
**Scope**: Move the "empty editable block property" visual-editor affordance (the annotated container that lets the guest offer an "Add content" button) out of per-view template code and into the framework block-rendering helpers, so it works automatically for every template — including custom ones — with zero template boilerplate.
|
||||
**Supersedes**: the per-view empty-state edits to `blockgrid/blocklist/singleblock/default.cshtml` (sample site) and `EmbeddedResources/BlockGrid/default.cshtml`, plus the `PropertyAliasViewDataKey` ViewData plumbing in `BlockListTemplateExtensions`/`BlockGridTemplateExtensions`.
|
||||
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
The visual editor needs a DOM anchor for empty, editable block properties so the guest can render an "Add content" affordance (it has no blocks to attach inter-block "+" buttons to). The current implementation puts this in the Razor templates:
|
||||
|
||||
- `GetBlock{List,Grid}HtmlAsync` short-circuits empty models to `HtmlString.Empty`.
|
||||
- Each `default.cshtml` was patched to read a `PropertyAliasViewDataKey` from ViewData and render an annotated empty `<div ... data-umb-block-property="{alias}">` in visual-editor mode.
|
||||
|
||||
This is unfriendly and incomplete:
|
||||
- Every block template (block list, block grid, single block — default **and** any custom template) must carry framework annotation boilerplate.
|
||||
- Custom templates that don't include it silently lose the feature.
|
||||
- It contrasts with regular property annotation, which is fully automatic (`UmbracoViewPage` wraps editable property output in `data-umb-property` spans with no template code).
|
||||
|
||||
## Goal
|
||||
|
||||
Make the empty-block affordance **fully automatic**: no template code, working for the default templates and any custom template, gated on the property's `EditableInVisualEditor` opt-in and on visual-editor/preview mode. Revert all per-view edits and the ViewData plumbing.
|
||||
|
||||
## Why not the obvious alternatives
|
||||
|
||||
- **Emit it from `UmbracoViewPage` (like `data-umb-property`)**: the automatic span is only emitted when the property is accessed via the tracked `IPublishedContent.Value()` path; the block helpers read the value via `GetProperty().GetValue()`, which bypasses the tracker. Making block access reliably tracked and anchoring an affordance on an empty span touches the core annotation pipeline — bigger and riskier (this is the deferred "unify all property annotation" direction).
|
||||
- **Emit HTML from the Core block model**: `BlockListModel`/`BlockGridModel` live in `Umbraco.Core`, which has no web/HTML concern — emitting annotation markup from the model crosses a layer boundary.
|
||||
|
||||
## Approach (chosen)
|
||||
|
||||
The block-rendering helpers in `Umbraco.Web.Common` are the web-layer choke point essentially all block rendering flows through. Move the empty-state emission there.
|
||||
|
||||
### Component 1 — Helpers emit the annotated container
|
||||
|
||||
In `BlockListTemplateExtensions`, `BlockGridTemplateExtensions`, and the single-block rendering helper:
|
||||
|
||||
- When the model is **empty** AND `VisualEditorPropertyTracker.IsEnabled` AND the property's `PropertyType.EditableInVisualEditor` is `true`, return a minimal annotated container as an `HtmlString`:
|
||||
- Block list: `<div class="umb-block-list" data-umb-block-property="{alias}"></div>`
|
||||
- Block grid: `<div class="umb-block-grid" data-umb-block-property="{alias}"></div>` (with the existing `data-grid-columns`/`--umb-block-grid--grid-columns` styling, defaulting columns to `12`)
|
||||
- Single block: an analogous annotated empty container (see Component 3)
|
||||
- Otherwise return `HtmlString.Empty` exactly as today. Non-empty models render their partial unchanged.
|
||||
|
||||
The helper builds this small fixed container directly (no partial, no ViewData). The `PropertyAliasViewDataKey` constant, the `WithPropertyAlias` helper, and the alias-via-ViewData private overloads are **removed** from both extensions.
|
||||
|
||||
Gating predicate (shared intent across all three helpers): `model is empty && VisualEditorPropertyTracker.IsEnabled && propertyType?.EditableInVisualEditor == true`.
|
||||
|
||||
### Component 2 — Emission lives in the alias-bearing overloads only (no model metadata)
|
||||
|
||||
The helpers have three call styles:
|
||||
|
||||
| Overload | Has alias + editable flag? |
|
||||
|---|---|
|
||||
| `GetBlock*HtmlAsync(IPublishedContent content, string alias[, template])` | Yes — resolves the `IPublishedProperty` (`alias`, `PropertyType.EditableInVisualEditor`) |
|
||||
| `GetBlock*HtmlAsync(IPublishedProperty property[, template])` | Yes — `property.Alias`, `property.PropertyType.EditableInVisualEditor` |
|
||||
| `GetBlock*HtmlAsync(BlockListModel/BlockGridModel model[, template])` | **No** |
|
||||
|
||||
The empty-state container is emitted **only by the two alias-bearing overloads**, because they carry the alias and editable flag regardless of whether the value is empty.
|
||||
|
||||
**Why not "alias on the model" (rejected):** empty block values resolve to a process-wide **singleton** — the value creators return `BlockListModel.Empty` / `BlockGridModel.Empty` (`public static`), and an empty single block converts to `null`. There is no per-property instance to carry an alias for the empty case, and setting a mutable alias on the shared singleton would corrupt every empty block property on the site. The alias is also unavailable where the model is built (the value *creators* don't receive `IPublishedPropertyType` — only the *converters* do). So model metadata is out; **no changes to Core models, value creators, or converters.**
|
||||
|
||||
**Consequence for the model-only overload:** `GetBlock*HtmlAsync(Model.BlockProperty)` (model-only, including the bare ModelsBuilder property) keeps its current behaviour — empty renders nothing, no affordance. The alias-bearing overload (`GetBlock*HtmlAsync(Model, "alias")` / `(IPublishedProperty)`) is the documented, default pattern used by all sample templates (and `Home.cshtml` was aligned to it), so "fully automatic" holds for the standard pattern. The model-only gap is in the same class as fully hand-rolled rendering — see Out of scope.
|
||||
|
||||
### Component 3 — Single block
|
||||
|
||||
The single-block helper is `SingleBlockTemplateExtensions.GetBlockHtmlAsync`; an empty single-block property surfaces as a **null** `BlockListItem` (the helper already returns `HtmlString.Empty` for null). Emit an annotated empty container when the value is null/empty + `VisualEditorPropertyTracker.IsEnabled` + the property is `EditableInVisualEditor`.
|
||||
|
||||
Consistent with Component 2: annotation comes only from the **alias-bearing overloads** — `GetBlockHtmlAsync(IPublishedProperty)` and `GetBlockHtmlAsync(IPublishedContent, alias)` — which expose `property.Alias` and `property.PropertyType.EditableInVisualEditor` even when `property.GetValue()` is null. The model-only `GetBlockHtmlAsync(BlockListItem? model)` overload, given a null model, has no alias and cannot annotate (documented gap; the sample/default and documented usage use the alias-bearing overloads).
|
||||
|
||||
"Add content" reuses the existing `umb:ve:block-add-to-property` message (single-block semantics: one block, `insertIndex 0`). The guest gains a single-block empty-container branch mirroring the list/grid ones (or a shared selector). Exact container markup + the guest branch are finalized in the plan.
|
||||
|
||||
### Component 4 — Guest + element (mostly unchanged)
|
||||
|
||||
- The guest already attaches the "Add content" placeholder to empty `.umb-block-list` / `.umb-block-grid` containers carrying `data-umb-block-property`, and the element's grid-aware add (`#resolveBlockSchemaAlias`) already produces the correct list/grid value shape. These are unchanged.
|
||||
- The only guest addition is the single-block empty-container handling.
|
||||
- The `data-umb-block-property` attribute and the `umb:ve:block-add-to-property` postMessage protocol are retained — the helper now emits the attribute that the templates previously emitted.
|
||||
|
||||
### Component 5 — Revert the per-view changes
|
||||
|
||||
Revert to original form (removing the empty-state boilerplate and ViewData reads):
|
||||
- `src/Umbraco.Web.UI/Views/Partials/blockgrid/default.cshtml`
|
||||
- `src/Umbraco.Web.UI/Views/Partials/blocklist/default.cshtml`
|
||||
- `src/Umbraco.Web.UI/Views/Partials/singleblock/default.cshtml` (unchanged from original — never modified, but confirm it needs no edit under the new mechanism)
|
||||
- `src/Umbraco.Core/EmbeddedResources/BlockGrid/default.cshtml`
|
||||
|
||||
`Home.cshtml`'s switch to the alias-aware overload (`GetBlockGridHtmlAsync(Model, "bodyText")`) may be **kept or reverted** — under Component 2 the model-only overload also works, so reverting it is safe; keeping it is harmless. The plan picks one (default: keep, as the alias-aware overload is the documented norm).
|
||||
|
||||
## Data flow (after)
|
||||
|
||||
```
|
||||
template: @await Html.GetBlockGridHtmlAsync(Model, "bodyText") (or Model.BodyText, or an IPublishedProperty)
|
||||
→ helper resolves model + property alias + EditableInVisualEditor
|
||||
→ model non-empty? → render partial as today (unchanged)
|
||||
→ model empty?
|
||||
→ VisualEditorPropertyTracker.IsEnabled && EditableInVisualEditor?
|
||||
→ return <div class="umb-block-grid" data-umb-block-property="bodyText"></div>
|
||||
→ else HtmlString.Empty (production: nothing, as today)
|
||||
→ guest sees the empty annotated container → renders "Add content" → umb:ve:block-add-to-property
|
||||
→ element #onBlockAddToProperty → grid/list-aware value creation (unchanged)
|
||||
```
|
||||
|
||||
## Error handling / edge cases
|
||||
|
||||
- Not in VE/preview, or property not editable, or model non-empty → byte-for-byte the same output as before this change (no behavioural change to production rendering).
|
||||
- Property alias unknown on the model-only overload (metadata not populated, e.g. a model constructed outside the value creators) → no annotation (graceful: treated as "alias unknown", returns empty as today). Not silent in a harmful way — it just falls back to current behaviour.
|
||||
- A custom template that hand-renders blocks without any `GetBlock*HtmlAsync` helper → no affordance. Documented as the one uncovered path (the helper is the documented rendering API).
|
||||
|
||||
## Testing
|
||||
|
||||
- **Backend (unit/integration)**: the block helpers return an annotated container for an empty editable block property when `VisualEditorPropertyTracker.IsEnabled`, and `HtmlString.Empty` when (a) the tracker is disabled, (b) the property is not `EditableInVisualEditor`, or (c) the model is non-empty. Cover all three overloads (content+alias, property, model-only) — the model-only case asserts the `PropertyAlias` metadata path.
|
||||
- **Value-creator test**: the produced block model carries the correct `PropertyAlias` / `EditableInVisualEditor` metadata.
|
||||
- **Frontend/guest**: `npm run build` + `npm run lint` + manual smoke (no VE guest test harness; consistent with the feature's established posture). Manual smoke covers empty list, empty grid, empty single block in the visual editor.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Unifying all property annotation under a single `data-umb-property` mechanism (the deferred Approach 2).
|
||||
- Shipping a default block-list render template (block list intentionally ships none).
|
||||
- Covering hand-rolled block rendering that bypasses the `GetBlock*HtmlAsync` helpers.
|
||||
- Covering the **model-only** helper overload (`GetBlock*HtmlAsync(Model.BlockProperty)`): empty values resolve to the shared `.Empty` singleton (or `null` for single block), which has no per-property identity to annotate. Use the alias-bearing overload (`GetBlock*HtmlAsync(Model, "alias")`) — the documented default — to get the empty-state affordance.
|
||||
|
||||
## Implementation note
|
||||
|
||||
This change **reverts** the prior per-view empty-state commits and the ViewData plumbing in favour of the helper-based mechanism. Those commits remain in history as superseded steps; the revert is part of this work, not a separate cleanup.
|
||||
@@ -1,947 +0,0 @@
|
||||
# Visual Editor — Framework-Emitted Empty-Block Affordance — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Make the visual-editor empty-block "Add content" affordance fully automatic via the block-rendering helpers, removing the per-view template boilerplate and the ViewData plumbing.
|
||||
|
||||
**Architecture:** The block helpers (`BlockListTemplateExtensions`, `BlockGridTemplateExtensions`, `SingleBlockTemplateExtensions` in `Umbraco.Web.Common`) emit an annotated empty container `<div class="umb-block-{list,grid,single}" data-umb-block-property="{alias}">` themselves — but only from the **alias-bearing overloads** (which carry the alias + `PropertyType.EditableInVisualEditor` even when the value is empty), and only when `VisualEditorPropertyTracker.IsEnabled` and the property is editable-in-VE. A shared `BlockEmptyState` helper DRYs the gating + markup. The default views revert to their plain form, and the ViewData plumbing is deleted. No changes to Core models / value creators / converters.
|
||||
|
||||
**Tech Stack:** C# / ASP.NET Core Razor helpers (`Umbraco.Web.Common`), Razor views (`Umbraco.Web.UI`, embedded `Umbraco.Core`), TypeScript guest (`injected.ts`) + Lit element (backoffice client). Working dir for ALL tasks: `D:/CMS/Umbraco-CMS/.worktrees/feature-visual-editor`.
|
||||
|
||||
**Spec:** `docs/plans/2026-06-12-visual-editor-block-empty-state-design.md`
|
||||
|
||||
**Standing instruction:** the user asked for **no commits yet**. Implement and verify each task; leave changes in the working tree **uncommitted**. The "Commit" steps below are written for completeness but are GATED — do not run them until the user approves committing. Report each task's diff for review instead.
|
||||
|
||||
**Verified facts:**
|
||||
- Empty block values resolve to the shared singletons `BlockListModel.Empty` / `BlockGridModel.Empty`; an empty single block converts to `null`. Hence the alias can only come from the alias-bearing overloads, not the model. (No model/creator/converter changes.)
|
||||
- `IPublishedPropertyType` exposes `string Alias` and `bool EditableInVisualEditor` (default `false`) — `src/Umbraco.Core/Models/PublishedContent/IPublishedPropertyType.cs:27,52`.
|
||||
- `VisualEditorPropertyTracker.IsEnabled` is a public static in `Umbraco.Cms.Core.Models.PublishedContent`.
|
||||
- `SingleBlockValue : BlockValue<SingleBlockLayoutItem>` with `PropertyEditorAlias => Constants.PropertyEditors.Aliases.SingleBlock` — so single-block add reuses `addBlockToValue` with the single-block schema alias.
|
||||
- The guest already has empty-container branches for `.umb-block-list` and `.umb-block-grid` reading `dataset.umbBlockProperty`; there is **no** single-block handling.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Shared empty-state helper
|
||||
|
||||
**Files:**
|
||||
- Create: `src/Umbraco.Web.Common/Extensions/BlockEmptyState.cs`
|
||||
- Test: `tests/Umbraco.Tests.UnitTests/Umbraco.Web.Common/Extensions/BlockEmptyStateTests.cs`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `tests/Umbraco.Tests.UnitTests/Umbraco.Web.Common/Extensions/BlockEmptyStateTests.cs`:
|
||||
|
||||
```csharp
|
||||
using Microsoft.AspNetCore.Html;
|
||||
using NUnit.Framework;
|
||||
using System.IO;
|
||||
using System.Text.Encodings.Web;
|
||||
using Umbraco.Cms.Core.Models.PublishedContent;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Web.Common.Extensions;
|
||||
|
||||
[TestFixture]
|
||||
public class BlockEmptyStateTests
|
||||
{
|
||||
[TearDown]
|
||||
public void TearDown() => VisualEditorPropertyTracker.Disable();
|
||||
|
||||
private static string Render(IHtmlContent content)
|
||||
{
|
||||
using var writer = new StringWriter();
|
||||
content.WriteTo(writer, HtmlEncoder.Default);
|
||||
return writer.ToString();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Returns_Annotated_Container_When_Enabled_And_Editable()
|
||||
{
|
||||
VisualEditorPropertyTracker.Enable();
|
||||
IHtmlContent result = BlockEmptyState.Container("umb-block-list", "bodyText", editableInVisualEditor: true);
|
||||
var html = Render(result);
|
||||
Assert.That(html, Does.Contain("class=\"umb-block-list\""));
|
||||
Assert.That(html, Does.Contain("data-umb-block-property=\"bodyText\""));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Returns_Empty_When_Tracker_Disabled()
|
||||
{
|
||||
VisualEditorPropertyTracker.Disable();
|
||||
IHtmlContent result = BlockEmptyState.Container("umb-block-list", "bodyText", editableInVisualEditor: true);
|
||||
Assert.That(Render(result), Is.Empty);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Returns_Empty_When_Not_Editable()
|
||||
{
|
||||
VisualEditorPropertyTracker.Enable();
|
||||
IHtmlContent result = BlockEmptyState.Container("umb-block-grid", "bodyText", editableInVisualEditor: false);
|
||||
Assert.That(Render(result), Is.Empty);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Returns_Empty_When_Alias_Missing()
|
||||
{
|
||||
VisualEditorPropertyTracker.Enable();
|
||||
IHtmlContent result = BlockEmptyState.Container("umb-block-list", string.Empty, editableInVisualEditor: true);
|
||||
Assert.That(Render(result), Is.Empty);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Encodes_Alias_And_Class()
|
||||
{
|
||||
VisualEditorPropertyTracker.Enable();
|
||||
IHtmlContent result = BlockEmptyState.Container("umb-block-list", "a\"b", editableInVisualEditor: true);
|
||||
var html = Render(result);
|
||||
Assert.That(html, Does.Not.Contain("a\"b"));
|
||||
Assert.That(html, Does.Contain("a"b").Or.Contain("a"b"));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the test to verify it fails**
|
||||
|
||||
Run: `dotnet test tests/Umbraco.Tests.UnitTests --filter "FullyQualifiedName~BlockEmptyStateTests"`
|
||||
Expected: FAIL (build error — `BlockEmptyState` does not exist).
|
||||
|
||||
- [ ] **Step 3: Implement `BlockEmptyState`**
|
||||
|
||||
Create `src/Umbraco.Web.Common/Extensions/BlockEmptyState.cs`:
|
||||
|
||||
```csharp
|
||||
using System.Text.Encodings.Web;
|
||||
using Microsoft.AspNetCore.Html;
|
||||
using Umbraco.Cms.Core.Models.PublishedContent;
|
||||
|
||||
namespace Umbraco.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// Produces the annotated empty container the visual editor uses to offer an "add content"
|
||||
/// affordance on an empty, editable block property. Returns empty content outside the visual editor.
|
||||
/// </summary>
|
||||
internal static class BlockEmptyState
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns an annotated empty container (<c><div class="{cssClass}" data-umb-block-property="{alias}"></c>)
|
||||
/// when the property is editable in the visual editor and the visual editor is active; otherwise empty content.
|
||||
/// </summary>
|
||||
public static IHtmlContent Container(string cssClass, string propertyAlias, bool editableInVisualEditor)
|
||||
{
|
||||
if (!editableInVisualEditor
|
||||
|| string.IsNullOrEmpty(propertyAlias)
|
||||
|| !VisualEditorPropertyTracker.IsEnabled)
|
||||
{
|
||||
return HtmlString.Empty;
|
||||
}
|
||||
|
||||
var encodedClass = HtmlEncoder.Default.Encode(cssClass);
|
||||
var encodedAlias = HtmlEncoder.Default.Encode(propertyAlias);
|
||||
return new HtmlString($"<div class=\"{encodedClass}\" data-umb-block-property=\"{encodedAlias}\"></div>");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the test to verify it passes**
|
||||
|
||||
Run: `dotnet test tests/Umbraco.Tests.UnitTests --filter "FullyQualifiedName~BlockEmptyStateTests"`
|
||||
Expected: PASS (5 passed).
|
||||
|
||||
- [ ] **Step 5: Commit (GATED — only if the user has approved committing)**
|
||||
|
||||
```bash
|
||||
git add src/Umbraco.Web.Common/Extensions/BlockEmptyState.cs tests/Umbraco.Tests.UnitTests/Umbraco.Web.Common/Extensions/BlockEmptyStateTests.cs
|
||||
git commit -m "feat(visual-editor): shared empty-state container helper for block properties"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Block list helper emits the affordance; remove ViewData plumbing
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Umbraco.Web.Common/Extensions/BlockListTemplateExtensions.cs`
|
||||
- Test: `tests/Umbraco.Tests.UnitTests/Umbraco.Web.Common/Extensions/BlockListTemplateExtensionsTests.cs`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `tests/Umbraco.Tests.UnitTests/Umbraco.Web.Common/Extensions/BlockListTemplateExtensionsTests.cs`:
|
||||
|
||||
```csharp
|
||||
using System.IO;
|
||||
using System.Text.Encodings.Web;
|
||||
using Microsoft.AspNetCore.Html;
|
||||
using Microsoft.AspNetCore.Mvc.Rendering;
|
||||
using Moq;
|
||||
using NUnit.Framework;
|
||||
using Umbraco.Cms.Core.Models.Blocks;
|
||||
using Umbraco.Cms.Core.Models.PublishedContent;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Web.Common.Extensions;
|
||||
|
||||
[TestFixture]
|
||||
public class BlockListTemplateExtensionsTests
|
||||
{
|
||||
[TearDown]
|
||||
public void TearDown() => VisualEditorPropertyTracker.Disable();
|
||||
|
||||
private static string Render(IHtmlContent content)
|
||||
{
|
||||
using var writer = new StringWriter();
|
||||
content.WriteTo(writer, HtmlEncoder.Default);
|
||||
return writer.ToString();
|
||||
}
|
||||
|
||||
private static IPublishedProperty EmptyEditableProperty(string alias, bool editable)
|
||||
{
|
||||
var propertyType = new Mock<IPublishedPropertyType>();
|
||||
propertyType.SetupGet(x => x.Alias).Returns(alias);
|
||||
propertyType.SetupGet(x => x.EditableInVisualEditor).Returns(editable);
|
||||
|
||||
var property = new Mock<IPublishedProperty>();
|
||||
property.SetupGet(x => x.Alias).Returns(alias);
|
||||
property.SetupGet(x => x.PropertyType).Returns(propertyType.Object);
|
||||
property.Setup(x => x.GetValue(null, null)).Returns(BlockListModel.Empty);
|
||||
return property.Object;
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Empty_Editable_Property_In_VisualEditor_Emits_Annotated_Container()
|
||||
{
|
||||
VisualEditorPropertyTracker.Enable();
|
||||
IHtmlContent result = await Mock.Of<IHtmlHelper>().GetBlockListHtmlAsync(EmptyEditableProperty("bodyText", editable: true));
|
||||
var html = Render(result);
|
||||
Assert.That(html, Does.Contain("class=\"umb-block-list\""));
|
||||
Assert.That(html, Does.Contain("data-umb-block-property=\"bodyText\""));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Empty_NonEditable_Property_Emits_Nothing()
|
||||
{
|
||||
VisualEditorPropertyTracker.Enable();
|
||||
IHtmlContent result = await Mock.Of<IHtmlHelper>().GetBlockListHtmlAsync(EmptyEditableProperty("bodyText", editable: false));
|
||||
Assert.That(Render(result), Is.Empty);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Empty_Property_Outside_VisualEditor_Emits_Nothing()
|
||||
{
|
||||
VisualEditorPropertyTracker.Disable();
|
||||
IHtmlContent result = await Mock.Of<IHtmlHelper>().GetBlockListHtmlAsync(EmptyEditableProperty("bodyText", editable: true));
|
||||
Assert.That(Render(result), Is.Empty);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the test to verify it fails**
|
||||
|
||||
Run: `dotnet test tests/Umbraco.Tests.UnitTests --filter "FullyQualifiedName~BlockListTemplateExtensionsTests"`
|
||||
Expected: FAIL — the current helper short-circuits empty to `HtmlString.Empty` (no container), so the first test fails.
|
||||
|
||||
- [ ] **Step 3: Rewrite `BlockListTemplateExtensions.cs`**
|
||||
|
||||
Replace the full contents of `src/Umbraco.Web.Common/Extensions/BlockListTemplateExtensions.cs` with:
|
||||
|
||||
```csharp
|
||||
using Microsoft.AspNetCore.Html;
|
||||
using Microsoft.AspNetCore.Mvc.Rendering;
|
||||
using Umbraco.Cms.Core.Models.Blocks;
|
||||
using Umbraco.Cms.Core.Models.PublishedContent;
|
||||
|
||||
namespace Umbraco.Extensions;
|
||||
|
||||
public static class BlockListTemplateExtensions
|
||||
{
|
||||
public const string DefaultFolder = "blocklist/";
|
||||
public const string DefaultTemplate = "default";
|
||||
|
||||
#region Async
|
||||
|
||||
public static async Task<IHtmlContent> GetBlockListHtmlAsync(this IHtmlHelper html, BlockListModel? model, string template = DefaultTemplate)
|
||||
{
|
||||
if (model?.Count == 0)
|
||||
{
|
||||
return HtmlString.Empty;
|
||||
}
|
||||
|
||||
return await html.PartialAsync(DefaultFolderTemplate(template), model);
|
||||
}
|
||||
|
||||
public static async Task<IHtmlContent> GetBlockListHtmlAsync(this IHtmlHelper html, IPublishedProperty property, string template = DefaultTemplate)
|
||||
=> await GetBlockListHtmlAsync(html, property.GetValue() as BlockListModel, template, property.Alias, property.PropertyType.EditableInVisualEditor);
|
||||
|
||||
public static async Task<IHtmlContent> GetBlockListHtmlAsync(this IHtmlHelper html, IPublishedContent contentItem, string propertyAlias)
|
||||
=> await GetBlockListHtmlAsync(html, contentItem, propertyAlias, DefaultTemplate);
|
||||
|
||||
public static async Task<IHtmlContent> GetBlockListHtmlAsync(this IHtmlHelper html, IPublishedContent contentItem, string propertyAlias, string template)
|
||||
{
|
||||
IPublishedProperty property = GetRequiredProperty(contentItem, propertyAlias);
|
||||
return await GetBlockListHtmlAsync(html, property.GetValue() as BlockListModel, template, property.Alias, property.PropertyType.EditableInVisualEditor);
|
||||
}
|
||||
|
||||
private static async Task<IHtmlContent> GetBlockListHtmlAsync(IHtmlHelper html, BlockListModel? model, string template, string propertyAlias, bool editableInVisualEditor)
|
||||
{
|
||||
if (model is null || model.Count == 0)
|
||||
{
|
||||
return BlockEmptyState.Container("umb-block-list", propertyAlias, editableInVisualEditor);
|
||||
}
|
||||
|
||||
return await html.PartialAsync(DefaultFolderTemplate(template), model);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Sync
|
||||
|
||||
public static IHtmlContent GetBlockListHtml(this IHtmlHelper html, BlockListModel? model, string template = DefaultTemplate)
|
||||
{
|
||||
if (model?.Count == 0)
|
||||
{
|
||||
return HtmlString.Empty;
|
||||
}
|
||||
|
||||
return html.Partial(DefaultFolderTemplate(template), model);
|
||||
}
|
||||
|
||||
public static IHtmlContent GetBlockListHtml(this IHtmlHelper html, IPublishedProperty property, string template = DefaultTemplate)
|
||||
=> GetBlockListHtml(html, property.GetValue() as BlockListModel, template, property.Alias, property.PropertyType.EditableInVisualEditor);
|
||||
|
||||
public static IHtmlContent GetBlockListHtml(this IHtmlHelper html, IPublishedContent contentItem, string propertyAlias)
|
||||
=> GetBlockListHtml(html, contentItem, propertyAlias, DefaultTemplate);
|
||||
|
||||
public static IHtmlContent GetBlockListHtml(this IHtmlHelper html, IPublishedContent contentItem, string propertyAlias, string template)
|
||||
{
|
||||
IPublishedProperty property = GetRequiredProperty(contentItem, propertyAlias);
|
||||
return GetBlockListHtml(html, property.GetValue() as BlockListModel, template, property.Alias, property.PropertyType.EditableInVisualEditor);
|
||||
}
|
||||
|
||||
private static IHtmlContent GetBlockListHtml(IHtmlHelper html, BlockListModel? model, string template, string propertyAlias, bool editableInVisualEditor)
|
||||
{
|
||||
if (model is null || model.Count == 0)
|
||||
{
|
||||
return BlockEmptyState.Container("umb-block-list", propertyAlias, editableInVisualEditor);
|
||||
}
|
||||
|
||||
return html.Partial(DefaultFolderTemplate(template), model);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private static string DefaultFolderTemplate(string template) => $"{DefaultFolder}{template}";
|
||||
|
||||
private static IPublishedProperty GetRequiredProperty(IPublishedContent contentItem, string propertyAlias)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(propertyAlias);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(propertyAlias))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"Value can't be empty or consist only of white-space characters.",
|
||||
nameof(propertyAlias));
|
||||
}
|
||||
|
||||
IPublishedProperty? property = contentItem.GetProperty(propertyAlias);
|
||||
if (property == null)
|
||||
{
|
||||
throw new InvalidOperationException("No property type found with alias " + propertyAlias);
|
||||
}
|
||||
|
||||
return property;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This removes `PropertyAliasViewDataKey`, `WithPropertyAlias`, the `Microsoft.AspNetCore.Mvc.ViewFeatures` using, and the old alias-via-ViewData private overloads.
|
||||
|
||||
- [ ] **Step 4: Run the test to verify it passes**
|
||||
|
||||
Run: `dotnet test tests/Umbraco.Tests.UnitTests --filter "FullyQualifiedName~BlockListTemplateExtensionsTests"`
|
||||
Expected: PASS (3 passed).
|
||||
|
||||
- [ ] **Step 5: Build Web.Common**
|
||||
|
||||
Run: `dotnet build src/Umbraco.Web.Common/Umbraco.Web.Common.csproj`
|
||||
Expected: 0 errors.
|
||||
|
||||
- [ ] **Step 6: Commit (GATED)**
|
||||
|
||||
```bash
|
||||
git add src/Umbraco.Web.Common/Extensions/BlockListTemplateExtensions.cs tests/Umbraco.Tests.UnitTests/Umbraco.Web.Common/Extensions/BlockListTemplateExtensionsTests.cs
|
||||
git commit -m "feat(visual-editor): block list helper emits empty-state affordance, drop ViewData plumbing"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Block grid helper emits the affordance; remove ViewData plumbing
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Umbraco.Web.Common/Extensions/BlockGridTemplateExtensions.cs`
|
||||
- Test: `tests/Umbraco.Tests.UnitTests/Umbraco.Web.Common/Extensions/BlockGridTemplateExtensionsTests.cs`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `tests/Umbraco.Tests.UnitTests/Umbraco.Web.Common/Extensions/BlockGridTemplateExtensionsTests.cs`:
|
||||
|
||||
```csharp
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text.Encodings.Web;
|
||||
using Microsoft.AspNetCore.Html;
|
||||
using Microsoft.AspNetCore.Mvc.Rendering;
|
||||
using Moq;
|
||||
using NUnit.Framework;
|
||||
using Umbraco.Cms.Core.Models.Blocks;
|
||||
using Umbraco.Cms.Core.Models.PublishedContent;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Web.Common.Extensions;
|
||||
|
||||
[TestFixture]
|
||||
public class BlockGridTemplateExtensionsTests
|
||||
{
|
||||
[TearDown]
|
||||
public void TearDown() => VisualEditorPropertyTracker.Disable();
|
||||
|
||||
private static string Render(IHtmlContent content)
|
||||
{
|
||||
using var writer = new StringWriter();
|
||||
content.WriteTo(writer, HtmlEncoder.Default);
|
||||
return writer.ToString();
|
||||
}
|
||||
|
||||
private static IPublishedProperty EmptyEditableProperty(string alias, bool editable)
|
||||
{
|
||||
var emptyGrid = new BlockGridModel(new List<BlockGridItem>(), null);
|
||||
|
||||
var propertyType = new Mock<IPublishedPropertyType>();
|
||||
propertyType.SetupGet(x => x.Alias).Returns(alias);
|
||||
propertyType.SetupGet(x => x.EditableInVisualEditor).Returns(editable);
|
||||
|
||||
var property = new Mock<IPublishedProperty>();
|
||||
property.SetupGet(x => x.Alias).Returns(alias);
|
||||
property.SetupGet(x => x.PropertyType).Returns(propertyType.Object);
|
||||
property.Setup(x => x.GetValue(null, null)).Returns(emptyGrid);
|
||||
return property.Object;
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Empty_Editable_Property_In_VisualEditor_Emits_Annotated_Container()
|
||||
{
|
||||
VisualEditorPropertyTracker.Enable();
|
||||
IHtmlContent result = await Mock.Of<IHtmlHelper>().GetBlockGridHtmlAsync(EmptyEditableProperty("bodyText", editable: true));
|
||||
var html = Render(result);
|
||||
Assert.That(html, Does.Contain("class=\"umb-block-grid\""));
|
||||
Assert.That(html, Does.Contain("data-umb-block-property=\"bodyText\""));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Empty_NonEditable_Property_Emits_Nothing()
|
||||
{
|
||||
VisualEditorPropertyTracker.Enable();
|
||||
IHtmlContent result = await Mock.Of<IHtmlHelper>().GetBlockGridHtmlAsync(EmptyEditableProperty("bodyText", editable: false));
|
||||
Assert.That(Render(result), Is.Empty);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Empty_Property_Outside_VisualEditor_Emits_Nothing()
|
||||
{
|
||||
VisualEditorPropertyTracker.Disable();
|
||||
IHtmlContent result = await Mock.Of<IHtmlHelper>().GetBlockGridHtmlAsync(EmptyEditableProperty("bodyText", editable: true));
|
||||
Assert.That(Render(result), Is.Empty);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
(Note: `new BlockGridModel(new List<BlockGridItem>(), null)` is used instead of `BlockGridModel.Empty` because `Empty` has `Count == 0` and either works; the explicit list keeps the test independent of the singleton.)
|
||||
|
||||
- [ ] **Step 2: Run the test to verify it fails**
|
||||
|
||||
Run: `dotnet test tests/Umbraco.Tests.UnitTests --filter "FullyQualifiedName~BlockGridTemplateExtensionsTests"`
|
||||
Expected: FAIL (no container emitted by current helper).
|
||||
|
||||
- [ ] **Step 3: Edit `BlockGridTemplateExtensions.cs`**
|
||||
|
||||
In `src/Umbraco.Web.Common/Extensions/BlockGridTemplateExtensions.cs`:
|
||||
|
||||
(a) Remove the `using Microsoft.AspNetCore.Mvc.ViewFeatures;` line.
|
||||
|
||||
(b) Remove the `PropertyAliasViewDataKey` const + its XML doc (lines 20-24).
|
||||
|
||||
(c) Replace the async property/content overloads + private method (lines 51-66) — change the property overloads to pass the alias **and** editable flag, and rewrite the private method to emit the empty-state:
|
||||
|
||||
```csharp
|
||||
/// <inheritdoc cref="GetBlockGridHtmlAsync(Microsoft.AspNetCore.Mvc.Rendering.IHtmlHelper,Umbraco.Cms.Core.Models.Blocks.BlockGridModel?,string)"/>
|
||||
public static async Task<IHtmlContent> GetBlockGridHtmlAsync(this IHtmlHelper html, IPublishedProperty property, string template = DefaultTemplate)
|
||||
=> await GetBlockGridHtmlAsync(html, property.GetValue() as BlockGridModel, template, property.Alias, property.PropertyType.EditableInVisualEditor);
|
||||
|
||||
/// <inheritdoc cref="GetBlockGridHtmlAsync(Microsoft.AspNetCore.Mvc.Rendering.IHtmlHelper,Umbraco.Cms.Core.Models.Blocks.BlockGridModel?,string)"/>
|
||||
public static async Task<IHtmlContent> GetBlockGridHtmlAsync(this IHtmlHelper html, IPublishedContent contentItem, string propertyAlias)
|
||||
=> await GetBlockGridHtmlAsync(html, contentItem, propertyAlias, DefaultTemplate);
|
||||
|
||||
public static async Task<IHtmlContent> GetBlockGridHtmlAsync(this IHtmlHelper html, IPublishedContent contentItem, string propertyAlias, string template)
|
||||
{
|
||||
IPublishedProperty prop = GetRequiredProperty(contentItem, propertyAlias);
|
||||
return await GetBlockGridHtmlAsync(html, prop.GetValue() as BlockGridModel, template, prop.Alias, prop.PropertyType.EditableInVisualEditor);
|
||||
}
|
||||
|
||||
private static async Task<IHtmlContent> GetBlockGridHtmlAsync(IHtmlHelper html, BlockGridModel? model, string template, string propertyAlias, bool editableInVisualEditor)
|
||||
{
|
||||
if (model is null || model.Count == 0)
|
||||
{
|
||||
return BlockEmptyState.Container("umb-block-grid", propertyAlias, editableInVisualEditor);
|
||||
}
|
||||
|
||||
return await html.PartialAsync(DefaultFolderTemplate(template), model);
|
||||
}
|
||||
```
|
||||
|
||||
(d) Mirror the same change in the sync region (lines 104-118):
|
||||
|
||||
```csharp
|
||||
/// <inheritdoc cref="GetBlockGridHtmlAsync(Microsoft.AspNetCore.Mvc.Rendering.IHtmlHelper,Umbraco.Cms.Core.Models.Blocks.BlockGridModel?,string)"/>
|
||||
public static IHtmlContent GetBlockGridHtml(this IHtmlHelper html, IPublishedProperty property, string template = DefaultTemplate)
|
||||
=> GetBlockGridHtml(html, property.GetValue() as BlockGridModel, template, property.Alias, property.PropertyType.EditableInVisualEditor);
|
||||
|
||||
/// <inheritdoc cref="GetBlockGridHtmlAsync(Microsoft.AspNetCore.Mvc.Rendering.IHtmlHelper,Umbraco.Cms.Core.Models.Blocks.BlockGridModel?,string)"/>
|
||||
public static IHtmlContent GetBlockGridHtml(this IHtmlHelper html, IPublishedContent contentItem, string propertyAlias)
|
||||
=> GetBlockGridHtml(html, contentItem, propertyAlias, DefaultTemplate);
|
||||
|
||||
public static IHtmlContent GetBlockGridHtml(this IHtmlHelper html, IPublishedContent contentItem, string propertyAlias, string template)
|
||||
{
|
||||
IPublishedProperty prop = GetRequiredProperty(contentItem, propertyAlias);
|
||||
return GetBlockGridHtml(html, prop.GetValue() as BlockGridModel, template, prop.Alias, prop.PropertyType.EditableInVisualEditor);
|
||||
}
|
||||
|
||||
private static IHtmlContent GetBlockGridHtml(IHtmlHelper html, BlockGridModel? model, string template, string propertyAlias, bool editableInVisualEditor)
|
||||
{
|
||||
if (model is null || model.Count == 0)
|
||||
{
|
||||
return BlockEmptyState.Container("umb-block-grid", propertyAlias, editableInVisualEditor);
|
||||
}
|
||||
|
||||
return html.Partial(DefaultFolderTemplate(template), model);
|
||||
}
|
||||
```
|
||||
|
||||
(e) Remove the now-unused `WithPropertyAlias` private method (lines 139-140). Leave `GetBlockGridItemsHtmlAsync`/`GetBlockGridItemAreasHtmlAsync`/etc. and `GetRequiredProperty` unchanged. The model-only `GetBlockGridHtmlAsync(BlockGridModel? model, ...)` overload (lines 41-49) keeps its `model?.Count == 0 → HtmlString.Empty` form unchanged.
|
||||
|
||||
- [ ] **Step 4: Run the test to verify it passes**
|
||||
|
||||
Run: `dotnet test tests/Umbraco.Tests.UnitTests --filter "FullyQualifiedName~BlockGridTemplateExtensionsTests"`
|
||||
Expected: PASS (3 passed).
|
||||
|
||||
- [ ] **Step 5: Build Web.Common**
|
||||
|
||||
Run: `dotnet build src/Umbraco.Web.Common/Umbraco.Web.Common.csproj`
|
||||
Expected: 0 errors.
|
||||
|
||||
- [ ] **Step 6: Commit (GATED)**
|
||||
|
||||
```bash
|
||||
git add src/Umbraco.Web.Common/Extensions/BlockGridTemplateExtensions.cs tests/Umbraco.Tests.UnitTests/Umbraco.Web.Common/Extensions/BlockGridTemplateExtensionsTests.cs
|
||||
git commit -m "feat(visual-editor): block grid helper emits empty-state affordance, drop ViewData plumbing"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Single block helper emits the affordance
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Umbraco.Web.Common/Extensions/SingleBlockTemplateExtensions.cs`
|
||||
- Test: `tests/Umbraco.Tests.UnitTests/Umbraco.Web.Common/Extensions/SingleBlockTemplateExtensionsTests.cs`
|
||||
|
||||
The single-block value is a `BlockListItem?`; empty = `null`. The alias-bearing overloads have the property even when the value is null.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `tests/Umbraco.Tests.UnitTests/Umbraco.Web.Common/Extensions/SingleBlockTemplateExtensionsTests.cs`:
|
||||
|
||||
```csharp
|
||||
using System.IO;
|
||||
using System.Text.Encodings.Web;
|
||||
using Microsoft.AspNetCore.Html;
|
||||
using Microsoft.AspNetCore.Mvc.Rendering;
|
||||
using Moq;
|
||||
using NUnit.Framework;
|
||||
using Umbraco.Cms.Core.Models.Blocks;
|
||||
using Umbraco.Cms.Core.Models.PublishedContent;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Web.Common.Extensions;
|
||||
|
||||
[TestFixture]
|
||||
public class SingleBlockTemplateExtensionsTests
|
||||
{
|
||||
[TearDown]
|
||||
public void TearDown() => VisualEditorPropertyTracker.Disable();
|
||||
|
||||
private static string Render(IHtmlContent content)
|
||||
{
|
||||
using var writer = new StringWriter();
|
||||
content.WriteTo(writer, HtmlEncoder.Default);
|
||||
return writer.ToString();
|
||||
}
|
||||
|
||||
private static IPublishedProperty NullEditableProperty(string alias, bool editable)
|
||||
{
|
||||
var propertyType = new Mock<IPublishedPropertyType>();
|
||||
propertyType.SetupGet(x => x.Alias).Returns(alias);
|
||||
propertyType.SetupGet(x => x.EditableInVisualEditor).Returns(editable);
|
||||
|
||||
var property = new Mock<IPublishedProperty>();
|
||||
property.SetupGet(x => x.Alias).Returns(alias);
|
||||
property.SetupGet(x => x.PropertyType).Returns(propertyType.Object);
|
||||
property.Setup(x => x.GetValue(null, null)).Returns((object?)null);
|
||||
return property.Object;
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Empty_Editable_Single_Block_In_VisualEditor_Emits_Annotated_Container()
|
||||
{
|
||||
VisualEditorPropertyTracker.Enable();
|
||||
IHtmlContent result = await Mock.Of<IHtmlHelper>().GetBlockHtmlAsync(NullEditableProperty("hero", editable: true));
|
||||
var html = Render(result);
|
||||
Assert.That(html, Does.Contain("class=\"umb-single-block\""));
|
||||
Assert.That(html, Does.Contain("data-umb-block-property=\"hero\""));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Empty_NonEditable_Single_Block_Emits_Nothing()
|
||||
{
|
||||
VisualEditorPropertyTracker.Enable();
|
||||
IHtmlContent result = await Mock.Of<IHtmlHelper>().GetBlockHtmlAsync(NullEditableProperty("hero", editable: false));
|
||||
Assert.That(Render(result), Is.Empty);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Empty_Single_Block_Outside_VisualEditor_Emits_Nothing()
|
||||
{
|
||||
VisualEditorPropertyTracker.Disable();
|
||||
IHtmlContent result = await Mock.Of<IHtmlHelper>().GetBlockHtmlAsync(NullEditableProperty("hero", editable: true));
|
||||
Assert.That(Render(result), Is.Empty);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the test to verify it fails**
|
||||
|
||||
Run: `dotnet test tests/Umbraco.Tests.UnitTests --filter "FullyQualifiedName~SingleBlockTemplateExtensionsTests"`
|
||||
Expected: FAIL (current helper returns `HtmlString.Empty` for null model).
|
||||
|
||||
- [ ] **Step 3: Edit `SingleBlockTemplateExtensions.cs`**
|
||||
|
||||
Change the alias-bearing overloads to pass the alias + editable flag through to a private method that emits the empty-state. The model-only overloads keep their `model is null → HtmlString.Empty` behaviour.
|
||||
|
||||
Replace the async property/content overloads (lines 27-37) with:
|
||||
|
||||
```csharp
|
||||
public static async Task<IHtmlContent> GetBlockHtmlAsync(this IHtmlHelper html, IPublishedProperty property, string template = DefaultTemplate)
|
||||
=> await GetBlockHtmlAsync(html, property.GetValue() as BlockListItem, template, property.Alias, property.PropertyType.EditableInVisualEditor);
|
||||
|
||||
public static async Task<IHtmlContent> GetBlockHtmlAsync(this IHtmlHelper html, IPublishedContent contentItem, string propertyAlias)
|
||||
=> await GetBlockHtmlAsync(html, contentItem, propertyAlias, DefaultTemplate);
|
||||
|
||||
public static async Task<IHtmlContent> GetBlockHtmlAsync(this IHtmlHelper html, IPublishedContent contentItem, string propertyAlias, string template)
|
||||
{
|
||||
IPublishedProperty property = GetRequiredProperty(contentItem, propertyAlias);
|
||||
return await GetBlockHtmlAsync(html, property.GetValue() as BlockListItem, template, property.Alias, property.PropertyType.EditableInVisualEditor);
|
||||
}
|
||||
|
||||
private static async Task<IHtmlContent> GetBlockHtmlAsync(IHtmlHelper html, BlockListItem? model, string template, string propertyAlias, bool editableInVisualEditor)
|
||||
{
|
||||
if (model is null)
|
||||
{
|
||||
return BlockEmptyState.Container("umb-single-block", propertyAlias, editableInVisualEditor);
|
||||
}
|
||||
|
||||
return await html.PartialAsync(DefaultFolderTemplate(template), model);
|
||||
}
|
||||
```
|
||||
|
||||
Replace the sync property/content overloads (lines 52-62) with:
|
||||
|
||||
```csharp
|
||||
public static IHtmlContent GetBlockHtml(this IHtmlHelper html, IPublishedProperty property, string template = DefaultTemplate)
|
||||
=> GetBlockHtml(html, property.GetValue() as BlockListItem, template, property.Alias, property.PropertyType.EditableInVisualEditor);
|
||||
|
||||
public static IHtmlContent GetBlockHtml(this IHtmlHelper html, IPublishedContent contentItem, string propertyAlias)
|
||||
=> GetBlockHtml(html, contentItem, propertyAlias, DefaultTemplate);
|
||||
|
||||
public static IHtmlContent GetBlockHtml(this IHtmlHelper html, IPublishedContent contentItem, string propertyAlias, string template)
|
||||
{
|
||||
IPublishedProperty property = GetRequiredProperty(contentItem, propertyAlias);
|
||||
return GetBlockHtml(html, property.GetValue() as BlockListItem, template, property.Alias, property.PropertyType.EditableInVisualEditor);
|
||||
}
|
||||
|
||||
private static IHtmlContent GetBlockHtml(IHtmlHelper html, BlockListItem? model, string template, string propertyAlias, bool editableInVisualEditor)
|
||||
{
|
||||
if (model is null)
|
||||
{
|
||||
return BlockEmptyState.Container("umb-single-block", propertyAlias, editableInVisualEditor);
|
||||
}
|
||||
|
||||
return html.Partial(DefaultFolderTemplate(template), model);
|
||||
}
|
||||
```
|
||||
|
||||
Leave the model-only `GetBlockHtmlAsync(BlockListItem? model, ...)` / `GetBlockHtml(BlockListItem? model, ...)` overloads (lines 17-25, 42-50), `SingleBlockPartialWithFallback`, `DefaultFolderTemplate`, and `GetRequiredProperty` unchanged.
|
||||
|
||||
- [ ] **Step 4: Run the test to verify it passes**
|
||||
|
||||
Run: `dotnet test tests/Umbraco.Tests.UnitTests --filter "FullyQualifiedName~SingleBlockTemplateExtensionsTests"`
|
||||
Expected: PASS (3 passed).
|
||||
|
||||
- [ ] **Step 5: Build + commit (GATED)**
|
||||
|
||||
```bash
|
||||
dotnet build src/Umbraco.Web.Common/Umbraco.Web.Common.csproj
|
||||
git add src/Umbraco.Web.Common/Extensions/SingleBlockTemplateExtensions.cs tests/Umbraco.Tests.UnitTests/Umbraco.Web.Common/Extensions/SingleBlockTemplateExtensionsTests.cs
|
||||
git commit -m "feat(visual-editor): single block helper emits empty-state affordance"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Revert the views to their plain form
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Umbraco.Web.UI/Views/Partials/blocklist/default.cshtml`
|
||||
- Modify: `src/Umbraco.Web.UI/Views/Partials/blockgrid/default.cshtml`
|
||||
- Modify: `src/Umbraco.Core/EmbeddedResources/BlockGrid/default.cshtml`
|
||||
|
||||
These no longer carry empty-state logic — the helper handles it. (The `singleblock/default.cshtml` was never modified and stays as-is: the helper now handles the empty/null case before the partial is invoked, so the partial only ever renders a non-null block.)
|
||||
|
||||
- [ ] **Step 1: Revert `blocklist/default.cshtml`**
|
||||
|
||||
Replace the full contents of `src/Umbraco.Web.UI/Views/Partials/blocklist/default.cshtml` with:
|
||||
|
||||
```razor
|
||||
@inherits Umbraco.Cms.Web.Common.Views.UmbracoViewPage<Umbraco.Cms.Core.Models.Blocks.BlockListModel>
|
||||
@{
|
||||
if (Model?.Any() != true) { return; }
|
||||
}
|
||||
<div class="umb-block-list">
|
||||
@foreach (var block in Model)
|
||||
{
|
||||
if (block?.ContentKey == null) { continue; }
|
||||
var data = block.Content;
|
||||
|
||||
<div data-umb-block-key="@block.ContentKey" data-umb-content-type="@data.ContentType.Alias">
|
||||
@await Html.PartialAsync("blocklist/Components/" + data.ContentType.Alias, block)
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
```
|
||||
|
||||
(Note: the per-block `data-umb-block-key`/`data-umb-content-type` annotations on populated blocks are retained — they were present before the empty-state work and are needed for selecting existing blocks. Only the empty-state `data-umb-block-property` + ViewData read are removed.)
|
||||
|
||||
- [ ] **Step 2: Revert `blockgrid/default.cshtml`**
|
||||
|
||||
Replace the full contents of `src/Umbraco.Web.UI/Views/Partials/blockgrid/default.cshtml` with:
|
||||
|
||||
```razor
|
||||
@using Umbraco.Extensions
|
||||
@inherits Umbraco.Cms.Web.Common.Views.UmbracoViewPage<Umbraco.Cms.Core.Models.Blocks.BlockGridModel>
|
||||
@{
|
||||
if (Model?.Any() != true) { return; }
|
||||
var gridColumns = Model.GridColumns?.ToString() ?? "12";
|
||||
}
|
||||
|
||||
<div class="umb-block-grid" data-grid-columns="@(gridColumns)" style="--umb-block-grid--grid-columns: @(gridColumns);">
|
||||
@await Html.GetBlockGridItemsHtmlAsync(Model)
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Revert embedded `BlockGrid/default.cshtml`**
|
||||
|
||||
Replace the full contents of `src/Umbraco.Core/EmbeddedResources/BlockGrid/default.cshtml` with the identical plain form:
|
||||
|
||||
```razor
|
||||
@using Umbraco.Extensions
|
||||
@inherits Umbraco.Cms.Web.Common.Views.UmbracoViewPage<Umbraco.Cms.Core.Models.Blocks.BlockGridModel>
|
||||
@{
|
||||
if (Model?.Any() != true) { return; }
|
||||
var gridColumns = Model.GridColumns?.ToString() ?? "12";
|
||||
}
|
||||
|
||||
<div class="umb-block-grid" data-grid-columns="@(gridColumns)" style="--umb-block-grid--grid-columns: @(gridColumns);">
|
||||
@await Html.GetBlockGridItemsHtmlAsync(Model)
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Confirm no remaining references to the removed ViewData keys**
|
||||
|
||||
Run: `grep -rn "PropertyAliasViewDataKey\|umbBlockListPropertyAlias\|umbBlockGridPropertyAlias" src/`
|
||||
Expected: zero hits (the consts were removed in Tasks 2-3 and the views no longer read them).
|
||||
|
||||
- [ ] **Step 5: Build Web.UI (validates compile; Razor is runtime-compiled)**
|
||||
|
||||
Run: `dotnet build src/Umbraco.Web.UI/Umbraco.Web.UI.csproj`
|
||||
Expected: 0 errors. (Stop any running dev instance first to avoid DLL file-locks.)
|
||||
|
||||
- [ ] **Step 6: Commit (GATED)**
|
||||
|
||||
```bash
|
||||
git add src/Umbraco.Web.UI/Views/Partials/blocklist/default.cshtml src/Umbraco.Web.UI/Views/Partials/blockgrid/default.cshtml src/Umbraco.Core/EmbeddedResources/BlockGrid/default.cshtml
|
||||
git commit -m "refactor(visual-editor): revert block view empty-state boilerplate (now framework-emitted)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Guest — single-block empty-container branch
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Umbraco.Web.UI.Client/src/apps/visual-editor/injected.ts`
|
||||
|
||||
The guest already handles empty `.umb-block-list` and `.umb-block-grid` containers (unchanged — the helper now emits the same `data-umb-block-property` markup). Add a parallel branch for the single-block container class `umb-single-block`.
|
||||
|
||||
- [ ] **Step 1: Add the single-block empty-container branch**
|
||||
|
||||
In `insertAddButtons()`, immediately after the existing empty `.umb-block-grid` branch (the block that does `document.querySelectorAll<HTMLElement>('.umb-block-grid').forEach(...)`), add:
|
||||
|
||||
```typescript
|
||||
// Empty single block at root level. The container carries data-umb-block-property
|
||||
// (emitted by the single block helper in visual-editor mode).
|
||||
document.querySelectorAll<HTMLElement>('.umb-single-block').forEach((single) => {
|
||||
if (single.querySelector(BLOCK_SELECTOR)) return; // Has a block
|
||||
if (single.querySelector(`[${ADD_BTN_ATTR}]`)) return; // Already handled
|
||||
|
||||
const propertyAlias = single.dataset.umbBlockProperty || '';
|
||||
if (!propertyAlias) return;
|
||||
|
||||
single.appendChild(
|
||||
createEmptyPlaceholder(() => {
|
||||
send({ type: 'umb:ve:block-add-to-property', propertyAlias, insertIndex: 0 });
|
||||
}),
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
Also update the file-header doc comment line for `data-umb-block-property` to read: `Property alias on a block list, block grid, or single block container (empty-state block creation)`.
|
||||
|
||||
- [ ] **Step 2: Build the client**
|
||||
|
||||
Run: `cd src/Umbraco.Web.UI.Client && npm run build`
|
||||
Expected: tsc exits 0. (Allow up to 600000ms.)
|
||||
|
||||
- [ ] **Step 3: Commit (GATED)**
|
||||
|
||||
```bash
|
||||
git add src/Umbraco.Web.UI.Client/src/apps/visual-editor/injected.ts
|
||||
git commit -m "feat(visual-editor): single block empty-state add-content affordance (guest)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Element — single-block-aware add
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Umbraco.Web.UI.Client/src/packages/documents/documents/workspace/views/visual-editor/document-workspace-view-visual-editor.element.ts`
|
||||
|
||||
When the guest sends `umb:ve:block-add-to-property` for a single-block property, the element must create a single-block-shaped value (layout key `Umbraco.SingleBlock`). Today `#resolveBlockSchemaAlias` only maps grid vs list; extend it for single block so `addBlockToValue` writes the right layout key.
|
||||
|
||||
- [ ] **Step 1: Confirm the single-block client constants**
|
||||
|
||||
Run: `grep -rn "PROPERTY_EDITOR_SCHEMA_ALIAS\|PROPERTY_EDITOR_UI_ALIAS" src/Umbraco.Web.UI.Client/src/packages/block/block-single/`
|
||||
Expected: find the exported constants for the single block editor — the schema alias (value `Umbraco.SingleBlock`) and the UI alias (value `Umb.PropertyEditorUi.BlockSingle` or similar). Note their exact exported names and the import path (`@umbraco-cms/backoffice/block-single`). If the names differ from those used below, substitute the real names.
|
||||
|
||||
- [ ] **Step 2: Extend `#resolveBlockSchemaAlias`**
|
||||
|
||||
Add the import (next to the existing block-grid import):
|
||||
|
||||
```typescript
|
||||
import {
|
||||
UMB_BLOCK_SINGLE_PROPERTY_EDITOR_SCHEMA_ALIAS,
|
||||
UMB_BLOCK_SINGLE_PROPERTY_EDITOR_UI_ALIAS,
|
||||
} from '@umbraco-cms/backoffice/block-single';
|
||||
```
|
||||
|
||||
Replace `#resolveBlockSchemaAlias` with:
|
||||
|
||||
```typescript
|
||||
#resolveBlockSchemaAlias(propertyAlias: string): string {
|
||||
const editorUiAlias = this.#structures.getDocumentProperty(propertyAlias)?.editorUiAlias ?? '';
|
||||
if (editorUiAlias === UMB_BLOCK_GRID_PROPERTY_EDITOR_UI_ALIAS) {
|
||||
return UMB_BLOCK_GRID_PROPERTY_EDITOR_SCHEMA_ALIAS;
|
||||
}
|
||||
if (editorUiAlias === UMB_BLOCK_SINGLE_PROPERTY_EDITOR_UI_ALIAS) {
|
||||
return UMB_BLOCK_SINGLE_PROPERTY_EDITOR_SCHEMA_ALIAS;
|
||||
}
|
||||
return UMB_BLOCK_LIST_PROPERTY_EDITOR_SCHEMA_ALIAS;
|
||||
}
|
||||
```
|
||||
|
||||
(`addBlockToValue` keys its grid-specific layout logic on `UMB_BLOCK_GRID_PROPERTY_EDITOR_SCHEMA_ALIAS`; for the single-block alias it falls through to the plain list-shaped layout item, which matches `SingleBlockValue`'s `BlockValue<SingleBlockLayoutItem>` structure — one block under the `Umbraco.SingleBlock` layout key, no columnSpan/rowSpan.)
|
||||
|
||||
- [ ] **Step 3: Build the client**
|
||||
|
||||
Run: `cd src/Umbraco.Web.UI.Client && npm run build`
|
||||
Expected: tsc exits 0. (Allow up to 600000ms.) If the single-block constant names differ, fix the import to the real names found in Step 1.
|
||||
|
||||
- [ ] **Step 4: Commit (GATED)**
|
||||
|
||||
```bash
|
||||
git add src/Umbraco.Web.UI.Client/src/packages/documents/documents/workspace/views/visual-editor/document-workspace-view-visual-editor.element.ts
|
||||
git commit -m "feat(visual-editor): single-block-aware add for empty single block properties"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Final verification + mark spec implemented
|
||||
|
||||
- [ ] **Step 1: Unit tests**
|
||||
|
||||
Run: `dotnet test tests/Umbraco.Tests.UnitTests --filter "FullyQualifiedName~TemplateExtensionsTests|FullyQualifiedName~BlockEmptyStateTests"`
|
||||
Expected: all helper + empty-state tests pass.
|
||||
|
||||
- [ ] **Step 2: Full client build + lint**
|
||||
|
||||
```bash
|
||||
cd src/Umbraco.Web.UI.Client
|
||||
npm run build
|
||||
npm run lint
|
||||
```
|
||||
Expected: build exits 0; lint reports no NEW errors in the visual-editor files (the `umb:ve:*` keys are already lint-exempt).
|
||||
|
||||
- [ ] **Step 3: Full solution build**
|
||||
|
||||
Run: `dotnet build umbraco.sln`
|
||||
Expected: 0 errors (pre-existing StyleCop warnings out of scope).
|
||||
|
||||
- [ ] **Step 4: Manual smoke** (run the site, backoffice at https://localhost:44339/umbraco)
|
||||
|
||||
1. Empty editable block **list** property → preview shows the annotated empty container with an "Add content" button; clicking it adds a block.
|
||||
2. Empty editable block **grid** property (e.g. Blogpost `bodyText`) → same.
|
||||
3. Empty editable **single block** property → same; clicking adds exactly one block.
|
||||
4. A **non-editable** empty block property → renders nothing, no affordance.
|
||||
5. A custom template that renders a block property via `@Html.GetBlock*HtmlAsync(Model, "alias")` → affordance appears with **no template code** for the empty state.
|
||||
6. Non-empty block properties render unchanged.
|
||||
|
||||
- [ ] **Step 5: Update the design doc status**
|
||||
|
||||
In `docs/plans/2026-06-12-visual-editor-block-empty-state-design.md` replace:
|
||||
|
||||
```markdown
|
||||
**Status**: Approved design, pending implementation plan
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```markdown
|
||||
**Status**: Implemented
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Commit (GATED)**
|
||||
|
||||
```bash
|
||||
git add docs/plans/2026-06-12-visual-editor-block-empty-state-design.md
|
||||
git commit -m "docs(visual-editor): mark framework-emitted empty-block affordance implemented"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-review notes
|
||||
|
||||
- **Spec coverage:** Component 1 (helper emits container) → Tasks 2-4 + the `BlockEmptyState` helper (Task 1); Component 2 (alias-bearing overloads only, no model metadata) → Tasks 2-4 pass alias + `EditableInVisualEditor` from the property; Component 3 (single block) → Tasks 4, 6, 7; Component 4 (guest/element) → Tasks 6-7; Component 5 (revert views) → Task 5; Testing → unit tests in Tasks 1-4 + manual in Task 8.
|
||||
- **Verify-at-execution (not placeholders):** the single-block client constant names (Task 7 Step 1) — exact exported names confirmed by grep before use.
|
||||
- **No model/creator/converter changes** — consistent with the singleton finding.
|
||||
- **Commits are GATED** per the user's "no commits yet" instruction — execute and review; commit only on approval.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -13,8 +13,7 @@ Shared infrastructure for Umbraco CMS REST APIs (Management and Delivery).
|
||||
### Key Technologies
|
||||
|
||||
- **ASP.NET Core** - Web framework
|
||||
- **Microsoft.AspNetCore.OpenApi** - OpenAPI document generation
|
||||
- **Swashbuckle.AspNetCore.SwaggerUI** - Swagger UI for browsing API documentation
|
||||
- **Swashbuckle** - OpenAPI/Swagger documentation generation
|
||||
- **OpenIddict** - OAuth 2.0/OpenID Connect authentication
|
||||
- **Asp.Versioning** - API versioning
|
||||
- **System.Text.Json** - Polymorphic JSON serialization
|
||||
@@ -28,18 +27,14 @@ Shared infrastructure for Umbraco CMS REST APIs (Management and Delivery).
|
||||
|
||||
```
|
||||
Umbraco.Cms.Api.Common/
|
||||
├── OpenApi/ # OpenAPI transformers and schema generators
|
||||
│ ├── UmbracoSchemaIdGenerator.cs # Generates schema IDs (e.g., "PagedUserModel")
|
||||
│ ├── UmbracoOperationIdTransformer.cs # Generates operation IDs
|
||||
│ ├── SortTagsAndPathsTransformer.cs # Sorts OpenAPI tags and paths
|
||||
│ ├── TagActionsByGroupNameTransformer.cs # Tags operations by controller group
|
||||
│ ├── FixFileReturnTypesTransformer.cs # Fixes file return type schemas
|
||||
│ ├── RequireNonNullablePropertiesSchemaTransformer.cs # Schema nullability
|
||||
│ └── OpenApiRouteTemplatePipelineFilter.cs # Adds OpenAPI endpoints
|
||||
├── OpenApi/ # Schema/Operation ID handlers for Swagger
|
||||
│ ├── SchemaIdHandler.cs # Generates schema IDs (e.g., "PagedUserModel")
|
||||
│ ├── OperationIdHandler.cs # Generates operation IDs
|
||||
│ └── SubTypesHandler.cs # Polymorphism support
|
||||
├── Serialization/ # JSON type resolution
|
||||
│ └── UmbracoJsonTypeInfoResolver.cs
|
||||
├── Configuration/ # Options configuration
|
||||
│ ├── ConfigureUmbracoOpenApiOptionsBase.cs
|
||||
│ ├── ConfigureUmbracoSwaggerGenOptions.cs
|
||||
│ └── ConfigureOpenIddict.cs
|
||||
├── DependencyInjection/ # Service registration
|
||||
│ ├── UmbracoBuilderApiExtensions.cs
|
||||
@@ -52,8 +47,9 @@ Umbraco.Cms.Api.Common/
|
||||
|
||||
### Design Patterns
|
||||
|
||||
1. **Builder Pattern** - `ProblemDetailsBuilder` for fluent error responses
|
||||
2. **Options Pattern** - All configuration via `IConfigureOptions<T>`
|
||||
1. **Strategy Pattern** - `ISchemaIdHandler`, `IOperationIdHandler` (extensible via inheritance)
|
||||
2. **Builder Pattern** - `ProblemDetailsBuilder` for fluent error responses
|
||||
3. **Options Pattern** - All configuration via `IConfigureOptions<T>`
|
||||
|
||||
---
|
||||
|
||||
@@ -65,12 +61,25 @@ See "Quick Reference" section at bottom for common commands.
|
||||
|
||||
## 3. Key Patterns
|
||||
|
||||
### Schema ID Generation (OpenApi/UmbracoSchemaIdGenerator.cs)
|
||||
### Virtual Handlers for Extensibility
|
||||
|
||||
Static utility class that generates OpenAPI schema IDs following Umbraco's naming conventions:
|
||||
Handlers are intentionally virtual to allow consuming APIs to override:
|
||||
|
||||
```csharp
|
||||
// Add "Model" suffix to avoid TypeScript name clashes
|
||||
// NOTE: Left unsealed on purpose, so it is extendable.
|
||||
public class SchemaIdHandler : ISchemaIdHandler
|
||||
{
|
||||
public virtual bool CanHandle(Type type) { }
|
||||
public virtual string Handle(Type type) { }
|
||||
}
|
||||
```
|
||||
|
||||
**Why**: Management and Delivery APIs can customize schema/operation ID generation.
|
||||
|
||||
### Schema ID Sanitization (OpenApi/SchemaIdHandler.cs:24-29, 32)
|
||||
|
||||
```csharp
|
||||
// Add "Model" suffix to avoid TypeScript name clashes (lines 24-29)
|
||||
if (name.EndsWith("Model") == false)
|
||||
{
|
||||
// because some models names clash with common classes in TypeScript (i.e. Document),
|
||||
@@ -78,12 +87,10 @@ if (name.EndsWith("Model") == false)
|
||||
name = $"{name}Model";
|
||||
}
|
||||
|
||||
// Remove invalid characters to prevent OpenAPI generation errors
|
||||
// Remove invalid characters to prevent OpenAPI generation errors (line 32)
|
||||
return Regex.Replace(name, @"[^\w]", string.Empty);
|
||||
```
|
||||
|
||||
**Generic Type Handling**: `PagedViewModel<RelationItemViewModel>` becomes `PagedRelationItemModel`
|
||||
|
||||
### Polymorphic Deserialization (Serialization/UmbracoJsonTypeInfoResolver.cs:29-35)
|
||||
|
||||
```csharp
|
||||
@@ -109,12 +116,9 @@ if (type.IsInterface is false)
|
||||
dotnet test tests/Umbraco.Tests.Integration/
|
||||
|
||||
# Verify OpenAPI generation
|
||||
# 1. Run the application: dotnet run --project src/Umbraco.Web.UI
|
||||
# 2. Navigate to /umbraco/openapi/ for Swagger UI
|
||||
# 1. Run Management API
|
||||
# 2. Navigate to /umbraco/swagger/
|
||||
# 3. Check schema IDs and operation IDs
|
||||
# OpenAPI JSON documents available at:
|
||||
# - /umbraco/openapi/management.json (Management API)
|
||||
# - /umbraco/openapi/delivery.json (Delivery API)
|
||||
```
|
||||
|
||||
**Focus areas when testing**:
|
||||
@@ -203,24 +207,49 @@ catch (NotSupportedException exception)
|
||||
|
||||
**Issue**: Type names like `Document` clash with TypeScript built-ins.
|
||||
|
||||
**Solution**: `UmbracoSchemaIdGenerator` adds "Model" suffix to all schema names.
|
||||
**Solution**: Add "Model" suffix (OpenApi/SchemaIdHandler.cs:24-29)
|
||||
|
||||
### Generic Type Handling
|
||||
|
||||
**Issue**: `PagedViewModel<T>` needs flattened schema name.
|
||||
|
||||
**Solution**: `UmbracoSchemaIdGenerator.Generate()` flattens generic types:
|
||||
- `PagedViewModel<RelationItemViewModel>` becomes `PagedRelationItemModel`
|
||||
**Solution** (OpenApi/SchemaIdHandler.cs:41-50):
|
||||
```csharp
|
||||
private string HandleGenerics(string name, Type type)
|
||||
{
|
||||
if (!type.IsGenericType)
|
||||
return name;
|
||||
|
||||
// use attribute custom name or append the generic type names
|
||||
// turns "PagedViewModel<RelationItemViewModel>" into "PagedRelationItem"
|
||||
return $"{name}{string.Join(string.Empty, type.GenericTypeArguments.Select(SanitizedTypeName))}";
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Extending This Library
|
||||
|
||||
### Adding Custom OpenAPI Transformers
|
||||
### Adding a Custom OpenAPI Handler
|
||||
|
||||
OpenAPI transformers are scoped per-document. To customize a document, implement `IOpenApiDocumentTransformer`, `IOpenApiOperationTransformer`, or `IOpenApiSchemaTransformer` and register with your OpenAPI options.
|
||||
1. **Implement interface**:
|
||||
```csharp
|
||||
public class MySchemaIdHandler : SchemaIdHandler
|
||||
{
|
||||
public override bool CanHandle(Type type)
|
||||
=> type.Namespace?.StartsWith("MyProject") is true;
|
||||
|
||||
For schema ID generation, use the static `UmbracoSchemaIdGenerator.Generate(Type)` method.
|
||||
public override string Handle(Type type)
|
||||
=> $"My{base.Handle(type)}";
|
||||
}
|
||||
```
|
||||
|
||||
2. **Register in consuming API**:
|
||||
```csharp
|
||||
builder.Services.AddSingleton<ISchemaIdHandler, MySchemaIdHandler>();
|
||||
```
|
||||
|
||||
**Note**: Handlers registered later take precedence in the selector.
|
||||
|
||||
### Customizing Problem Details
|
||||
|
||||
@@ -240,9 +269,13 @@ return BadRequest(problemDetails);
|
||||
|
||||
## 8. Project-Specific Notes
|
||||
|
||||
### Per-Document Transformer Scoping
|
||||
### Why Virtual Handlers?
|
||||
|
||||
With Microsoft.AspNetCore.OpenApi, transformers are configured per OpenAPI document. This means custom transformers only apply to the documents they're registered with, not globally. Each API (Management, Delivery) configures its own transformers via `ConfigureUmbracoOpenApiOptionsBase` subclasses.
|
||||
**Decision**: Make `SchemaIdHandler`, `OperationIdHandler`, etc. virtual.
|
||||
|
||||
**Why**: Management API and Delivery API have different schema ID requirements. Virtual methods allow override without rewriting the entire handler.
|
||||
|
||||
**Example**: Management API might prefix all schemas with "Management", Delivery API with "Delivery".
|
||||
|
||||
### Performance: Subtype Caching
|
||||
|
||||
@@ -271,13 +304,9 @@ With Microsoft.AspNetCore.OpenApi, transformers are configured per OpenAPI docum
|
||||
- Version: See `Directory.Packages.props`
|
||||
- Uses ASP.NET Core Data Protection for token encryption
|
||||
|
||||
**Microsoft.AspNetCore.OpenApi**:
|
||||
- OpenAPI 3.1.1 document generation
|
||||
- Custom transformers: `SchemaIdTransformer`, `OperationIdTransformer`, `MimeTypeDocumentTransformer`, `ServerTransformer`
|
||||
|
||||
**Swashbuckle.AspNetCore.SwaggerUI**:
|
||||
- Swagger UI for browsing and testing API endpoints
|
||||
- Accessed at `/umbraco/openapi/`
|
||||
**Swashbuckle**:
|
||||
- OpenAPI 3.0 document generation
|
||||
- Custom filters: `EnumSchemaFilter`, `MimeTypeDocumentFilter`, `RemoveSecuritySchemesDocumentFilter`
|
||||
|
||||
**Asp.Versioning**:
|
||||
- API versioning via `ApiVersion` attribute
|
||||
@@ -289,7 +318,7 @@ With Microsoft.AspNetCore.OpenApi, transformers are configured per OpenAPI docum
|
||||
|
||||
### Usage Pattern
|
||||
|
||||
Consuming APIs call `builder.AddUmbracoOpenApi().AddUmbracoOpenIddict()`
|
||||
Consuming APIs call `builder.AddUmbracoApiOpenApiUI().AddUmbracoOpenIddict()`
|
||||
|
||||
---
|
||||
|
||||
@@ -317,8 +346,7 @@ dotnet list src/Umbraco.Cms.Api.Common/Umbraco.Cms.Api.Common.csproj package --v
|
||||
| Class | Purpose | File |
|
||||
|-------|---------|------|
|
||||
| `ProblemDetailsBuilder` | Build RFC 7807 error responses | Builders/ProblemDetailsBuilder.cs |
|
||||
| `UmbracoSchemaIdGenerator` | Generate OpenAPI schema IDs | OpenApi/UmbracoSchemaIdGenerator.cs |
|
||||
| `UmbracoOperationIdTransformer` | Generate operation IDs | OpenApi/UmbracoOperationIdTransformer.cs |
|
||||
| `SchemaIdHandler` | Generate OpenAPI schema IDs | OpenApi/SchemaIdHandler.cs |
|
||||
| `UmbracoJsonTypeInfoResolver` | Polymorphic JSON serialization | Serialization/UmbracoJsonTypeInfoResolver.cs |
|
||||
| `UmbracoBuilderAuthExtensions` | Configure OpenIddict | DependencyInjection/UmbracoBuilderAuthExtensions.cs |
|
||||
| `HideBackOfficeTokensHandler` | Secure cookie-based token storage | DependencyInjection/HideBackOfficeTokensHandler.cs |
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
using System.Reflection;
|
||||
using Asp.Versioning;
|
||||
using Microsoft.AspNetCore.Mvc.Abstractions;
|
||||
using Microsoft.AspNetCore.Mvc.ApiExplorer;
|
||||
using Microsoft.AspNetCore.Mvc.Controllers;
|
||||
using Umbraco.Cms.Api.Common.OpenApi;
|
||||
|
||||
namespace Umbraco.Cms.Api.Common.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Configures the OpenAPI options for the Default API.
|
||||
/// </summary>
|
||||
internal class ConfigureDefaultApiOptions : ConfigureUmbracoOpenApiOptionsBase
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override string ApiName => DefaultApiConfiguration.ApiName;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override string ApiTitle => "Default API";
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override string ApiVersion => "Latest";
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override string ApiDescription => "All endpoints not defined under specific APIs";
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override bool ShouldInclude(ApiDescription apiDescription)
|
||||
{
|
||||
// Exclude controllers with ExcludeFromDefaultOpenApiDocumentAttribute
|
||||
if (apiDescription.ActionDescriptor is ControllerActionDescriptor controllerActionDescriptor
|
||||
&& controllerActionDescriptor.ControllerTypeInfo.GetCustomAttribute<ExcludeFromDefaultOpenApiDocumentAttribute>() is not null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Include if explicitly mapped to this document
|
||||
if (base.ShouldInclude(apiDescription))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Include endpoints not explicitly assigned to another document
|
||||
ApiVersionMetadata apiVersionMetadata = apiDescription.ActionDescriptor.ApiVersionMetadata;
|
||||
return string.IsNullOrEmpty(apiVersionMetadata.Name);
|
||||
}
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
using Asp.Versioning;
|
||||
using Microsoft.AspNetCore.Mvc.Abstractions;
|
||||
using Microsoft.AspNetCore.Mvc.ApiExplorer;
|
||||
using Microsoft.AspNetCore.Mvc.Controllers;
|
||||
using Microsoft.AspNetCore.OpenApi;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.OpenApi;
|
||||
using Umbraco.Cms.Api.Common.OpenApi;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Api.Common.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Base class for configuring OpenAPI options for Umbraco APIs.
|
||||
/// </summary>
|
||||
internal abstract class ConfigureUmbracoOpenApiOptionsBase : IConfigureNamedOptions<OpenApiOptions>
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the name/identifier of the API to configure.
|
||||
/// </summary>
|
||||
protected abstract string ApiName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the name/identifier of the API to configure.
|
||||
/// </summary>
|
||||
protected abstract string ApiTitle { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the version of the API to configure.
|
||||
/// </summary>
|
||||
protected abstract string ApiVersion { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the description of the API to configure.
|
||||
/// </summary>
|
||||
protected abstract string ApiDescription { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Configure(OpenApiOptions options) => Configure(Options.DefaultName, options);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Configure(string? name, OpenApiOptions options)
|
||||
{
|
||||
if (name != ApiName)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ConfigureOpenApi(options);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configure the OpenAPI options for the specified API.
|
||||
/// </summary>
|
||||
/// <param name="options">The <see cref="OpenApiOptions"/> instance to configure.</param>
|
||||
protected virtual void ConfigureOpenApi(OpenApiOptions options)
|
||||
{
|
||||
options.AddDocumentTransformer((document, _, _) =>
|
||||
{
|
||||
document.Info = new OpenApiInfo
|
||||
{
|
||||
Title = ApiTitle,
|
||||
Version = ApiVersion,
|
||||
Description = ApiDescription,
|
||||
};
|
||||
document.Servers?.Clear();
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
|
||||
options.ShouldInclude = ShouldInclude;
|
||||
options.CreateSchemaReferenceId = UmbracoSchemaIdGenerator.CreateSchemaReferenceId;
|
||||
|
||||
options.AddOperationTransformer<UmbracoOperationIdTransformer>();
|
||||
|
||||
// Tag actions by group name and cleanup unused tags (caused by the tag changes)
|
||||
options
|
||||
.AddOperationTransformer<TagActionsByGroupNameTransformer>()
|
||||
.AddDocumentTransformer<TagActionsByGroupNameTransformer>()
|
||||
.AddDocumentTransformer<SortTagsAndPathsTransformer>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether the specified API description should be included in this OpenAPI document.
|
||||
/// </summary>
|
||||
/// <param name="apiDescription">The API description to evaluate.</param>
|
||||
/// <returns><c>true</c> if the endpoint should be included; otherwise, <c>false</c>.</returns>
|
||||
protected virtual bool ShouldInclude(ApiDescription apiDescription)
|
||||
{
|
||||
if (apiDescription.ActionDescriptor is ControllerActionDescriptor controllerActionDescriptor
|
||||
&& controllerActionDescriptor.HasMapToApiAttribute(ApiName))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
ApiVersionMetadata apiVersionMetadata = apiDescription.ActionDescriptor.ApiVersionMetadata;
|
||||
return apiVersionMetadata.Name == ApiName;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
using Microsoft.AspNetCore.Mvc.ApiExplorer;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.OpenApi;
|
||||
using Swashbuckle.AspNetCore.SwaggerGen;
|
||||
using Umbraco.Cms.Api.Common.OpenApi;
|
||||
using Umbraco.Cms.Core.DependencyInjection;
|
||||
|
||||
namespace Umbraco.Cms.Api.Common.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Configures Swagger/OpenAPI generation options for Umbraco APIs.
|
||||
/// </summary>
|
||||
public class ConfigureUmbracoSwaggerGenOptions : IConfigureOptions<SwaggerGenOptions>
|
||||
{
|
||||
private readonly IOperationIdSelector _operationIdSelector;
|
||||
private readonly ISchemaIdSelector _schemaIdSelector;
|
||||
private readonly ISubTypesSelector _subTypesSelector;
|
||||
private readonly IDocumentInclusionSelector _documentInclusionSelector;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ConfigureUmbracoSwaggerGenOptions"/> class.
|
||||
/// </summary>
|
||||
/// <param name="operationIdSelector">The operation ID selector.</param>
|
||||
/// <param name="schemaIdSelector">The schema ID selector.</param>
|
||||
/// <param name="subTypesSelector">The sub-types selector for polymorphism support.</param>
|
||||
/// <param name="documentInclusionSelector">The document inclusion selector.</param>
|
||||
public ConfigureUmbracoSwaggerGenOptions(
|
||||
IOperationIdSelector operationIdSelector,
|
||||
ISchemaIdSelector schemaIdSelector,
|
||||
ISubTypesSelector subTypesSelector,
|
||||
IDocumentInclusionSelector documentInclusionSelector)
|
||||
{
|
||||
_operationIdSelector = operationIdSelector;
|
||||
_schemaIdSelector = schemaIdSelector;
|
||||
_subTypesSelector = subTypesSelector;
|
||||
_documentInclusionSelector = documentInclusionSelector;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ConfigureUmbracoSwaggerGenOptions"/> class.
|
||||
/// </summary>
|
||||
/// <param name="operationIdSelector">The operation ID selector.</param>
|
||||
/// <param name="schemaIdSelector">The schema ID selector.</param>
|
||||
/// <param name="subTypesSelector">The sub-types selector for polymorphism support.</param>
|
||||
[Obsolete("Please use the constructor with all parameters. Scheduled for removal in Umbraco 19.")]
|
||||
public ConfigureUmbracoSwaggerGenOptions(
|
||||
IOperationIdSelector operationIdSelector,
|
||||
ISchemaIdSelector schemaIdSelector,
|
||||
ISubTypesSelector subTypesSelector)
|
||||
: this(
|
||||
operationIdSelector,
|
||||
schemaIdSelector,
|
||||
subTypesSelector,
|
||||
StaticServiceProvider.Instance.GetRequiredService<IDocumentInclusionSelector>())
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Configure(SwaggerGenOptions swaggerGenOptions)
|
||||
{
|
||||
swaggerGenOptions.SwaggerDoc(
|
||||
DefaultApiConfiguration.ApiName,
|
||||
new OpenApiInfo
|
||||
{
|
||||
Title = "Default API",
|
||||
Version = "Latest",
|
||||
Description = "All endpoints not defined under specific APIs",
|
||||
});
|
||||
|
||||
swaggerGenOptions.CustomOperationIds(description => _operationIdSelector.OperationId(description));
|
||||
swaggerGenOptions.DocInclusionPredicate(_documentInclusionSelector.Include);
|
||||
swaggerGenOptions.TagActionsBy(api =>
|
||||
api.GroupName is null
|
||||
? []
|
||||
: new[] { api.GroupName });
|
||||
swaggerGenOptions.OrderActionsBy(ActionOrderBy);
|
||||
swaggerGenOptions.SchemaFilter<EnumSchemaFilter>();
|
||||
swaggerGenOptions.CustomSchemaIds(_schemaIdSelector.SchemaId);
|
||||
swaggerGenOptions.SelectSubTypesUsing(_subTypesSelector.SubTypes);
|
||||
swaggerGenOptions.SupportNonNullableReferenceTypes();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a sort key for API actions.
|
||||
/// </summary>
|
||||
/// <param name="apiDesc">The API description.</param>
|
||||
/// <returns>A string used to sort API operations in the documentation.</returns>
|
||||
/// <remarks>
|
||||
/// See https://github.com/domaindrivendev/Swashbuckle.AspNetCore#change-operation-sort-order-eg-for-ui-sorting.
|
||||
/// </remarks>
|
||||
private static string ActionOrderBy(ApiDescription apiDesc)
|
||||
=> $"{apiDesc.GroupName}_{apiDesc.ActionDescriptor.AttributeRouteInfo?.Template ?? apiDesc.ActionDescriptor.RouteValues["controller"]}_{(apiDesc.ActionDescriptor.RouteValues.TryGetValue("action", out var action) ? action : null)}_{apiDesc.HttpMethod}";
|
||||
}
|
||||
@@ -1,10 +1,7 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Security.Cryptography;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using OpenIddict.Abstractions;
|
||||
using OpenIddict.Server;
|
||||
using OpenIddict.Validation;
|
||||
using Umbraco.Cms.Core;
|
||||
@@ -28,7 +25,6 @@ internal sealed class HideBackOfficeTokensHandler
|
||||
: IOpenIddictServerHandler<OpenIddictServerEvents.ApplyTokenResponseContext>,
|
||||
IOpenIddictServerHandler<OpenIddictServerEvents.ApplyAuthorizationResponseContext>,
|
||||
IOpenIddictServerHandler<OpenIddictServerEvents.ExtractTokenRequestContext>,
|
||||
IOpenIddictServerHandler<OpenIddictServerEvents.ExtractRevocationRequestContext>,
|
||||
IOpenIddictValidationHandler<OpenIddictValidationEvents.ProcessAuthenticationContext>,
|
||||
INotificationHandler<UserLogoutSuccessNotification>
|
||||
{
|
||||
@@ -37,16 +33,13 @@ internal sealed class HideBackOfficeTokensHandler
|
||||
// The __Host- prefix enforces secure cookies at browser level (requires Secure, Path=/, no Domain).
|
||||
// For local development over HTTP, we use a simpler prefix to avoid browser rejection.
|
||||
private const string SecureCookiePrefix = "__Host-";
|
||||
private readonly string _accessTokenCookieName = "umbAccessToken";
|
||||
private readonly string _refreshTokenCookieName = "umbRefreshToken";
|
||||
private readonly string _pkceCodeCookieName = "umbPkceCode";
|
||||
private const string AccessTokenCookieName = "umbAccessToken";
|
||||
private const string RefreshTokenCookieName = "umbRefreshToken";
|
||||
private const string PkceCodeCookieName = "umbPkceCode";
|
||||
|
||||
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||
private readonly IDataProtectionProvider _dataProtectionProvider;
|
||||
private readonly ILogger<HideBackOfficeTokensHandler> _logger;
|
||||
#pragma warning disable CS0618 // Type or member is obsolete
|
||||
private readonly BackOfficeTokenCookieSettings _backOfficeTokenCookieSettings;
|
||||
#pragma warning restore CS0618 // Type or member is obsolete
|
||||
private readonly GlobalSettings _globalSettings;
|
||||
|
||||
/// <summary>
|
||||
@@ -54,27 +47,18 @@ internal sealed class HideBackOfficeTokensHandler
|
||||
/// </summary>
|
||||
/// <param name="httpContextAccessor">The HTTP context accessor.</param>
|
||||
/// <param name="dataProtectionProvider">The data protection provider for encrypting cookie values.</param>
|
||||
/// <param name="logger">The logger.</param>
|
||||
/// <param name="backOfficeTokenCookieSettings">The back-office token cookie settings.</param>
|
||||
/// <param name="globalSettings">The global settings.</param>
|
||||
public HideBackOfficeTokensHandler(
|
||||
IHttpContextAccessor httpContextAccessor,
|
||||
IDataProtectionProvider dataProtectionProvider,
|
||||
ILogger<HideBackOfficeTokensHandler> logger,
|
||||
#pragma warning disable CS0618 // Type or member is obsolete
|
||||
IOptions<BackOfficeTokenCookieSettings> backOfficeTokenCookieSettings,
|
||||
#pragma warning restore CS0618 // Type or member is obsolete
|
||||
IOptions<GlobalSettings> globalSettings)
|
||||
{
|
||||
_httpContextAccessor = httpContextAccessor;
|
||||
_dataProtectionProvider = dataProtectionProvider;
|
||||
_logger = logger;
|
||||
_backOfficeTokenCookieSettings = backOfficeTokenCookieSettings.Value;
|
||||
_globalSettings = globalSettings.Value;
|
||||
|
||||
_accessTokenCookieName += _backOfficeTokenCookieSettings.SiteName;
|
||||
_refreshTokenCookieName += _backOfficeTokenCookieSettings.SiteName;
|
||||
_pkceCodeCookieName += _backOfficeTokenCookieSettings.SiteName;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -94,13 +78,13 @@ internal sealed class HideBackOfficeTokensHandler
|
||||
|
||||
if (context.Response.AccessToken is not null)
|
||||
{
|
||||
SetCookie(httpContext, _accessTokenCookieName, context.Response.AccessToken);
|
||||
SetCookie(httpContext, AccessTokenCookieName, context.Response.AccessToken);
|
||||
context.Response.AccessToken = RedactedTokenValue;
|
||||
}
|
||||
|
||||
if (context.Response.RefreshToken is not null)
|
||||
{
|
||||
SetCookie(httpContext, _refreshTokenCookieName, context.Response.RefreshToken);
|
||||
SetCookie(httpContext, RefreshTokenCookieName, context.Response.RefreshToken);
|
||||
context.Response.RefreshToken = RedactedTokenValue;
|
||||
}
|
||||
|
||||
@@ -122,7 +106,7 @@ internal sealed class HideBackOfficeTokensHandler
|
||||
|
||||
if (context.Response.Code is not null)
|
||||
{
|
||||
SetCookie(GetHttpContext(), _pkceCodeCookieName, context.Response.Code);
|
||||
SetCookie(GetHttpContext(), PkceCodeCookieName, context.Response.Code);
|
||||
context.Response.Code = RedactedTokenValue;
|
||||
}
|
||||
|
||||
@@ -144,12 +128,12 @@ internal sealed class HideBackOfficeTokensHandler
|
||||
|
||||
// Handle when the PKCE code is being exchanged for an access token.
|
||||
if (context.Request.Code == RedactedTokenValue
|
||||
&& TryGetCookie(httpContext, _pkceCodeCookieName, out var code))
|
||||
&& TryGetCookie(httpContext, PkceCodeCookieName, out var code))
|
||||
{
|
||||
context.Request.Code = code;
|
||||
|
||||
// We won't need the PKCE cookie after this, let's remove it.
|
||||
RemoveCookie(httpContext, _pkceCodeCookieName);
|
||||
RemoveCookie(httpContext, PkceCodeCookieName);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -160,7 +144,7 @@ internal sealed class HideBackOfficeTokensHandler
|
||||
|
||||
// Handle when a refresh token is being exchanged for a new access token.
|
||||
if (context.Request.RefreshToken == RedactedTokenValue
|
||||
&& TryGetCookie(httpContext, _refreshTokenCookieName, out var refreshToken))
|
||||
&& TryGetCookie(httpContext, RefreshTokenCookieName, out var refreshToken))
|
||||
{
|
||||
context.Request.RefreshToken = refreshToken;
|
||||
}
|
||||
@@ -175,40 +159,6 @@ internal sealed class HideBackOfficeTokensHandler
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// This is invoked when a token revocation request is received.
|
||||
/// </summary>
|
||||
public ValueTask HandleAsync(OpenIddictServerEvents.ExtractRevocationRequestContext context)
|
||||
{
|
||||
if (context.Request?.ClientId != Constants.OAuthClientIds.BackOffice)
|
||||
{
|
||||
// Only ever handle the back-office client.
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
HttpContext httpContext = GetHttpContext();
|
||||
|
||||
// Determine which cookie to read based on the token type hint.
|
||||
var cookieName = context.Request.TokenTypeHint == OpenIddictConstants.TokenTypeHints.RefreshToken
|
||||
? _refreshTokenCookieName
|
||||
: _accessTokenCookieName;
|
||||
|
||||
if (context.Request.Token == RedactedTokenValue
|
||||
&& TryGetCookie(httpContext, cookieName, out var token))
|
||||
{
|
||||
context.Request.Token = token;
|
||||
}
|
||||
else
|
||||
{
|
||||
// If we got here, either the token was not redacted, or nothing was found in the expected cookie.
|
||||
// If OpenIddict found a token, it could be an old token that is potentially still valid. For security
|
||||
// reasons, we cannot accept that; at this point, we expect the tokens to be explicitly redacted.
|
||||
context.Request.Token = null;
|
||||
}
|
||||
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// This is invoked when extracting the auth context for a client request.
|
||||
/// </summary>
|
||||
@@ -220,7 +170,7 @@ internal sealed class HideBackOfficeTokensHandler
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
if (TryGetCookie(GetHttpContext(), _accessTokenCookieName, out var accessToken))
|
||||
if (TryGetCookie(GetHttpContext(), AccessTokenCookieName, out var accessToken))
|
||||
{
|
||||
context.AccessToken = accessToken;
|
||||
}
|
||||
@@ -240,8 +190,8 @@ internal sealed class HideBackOfficeTokensHandler
|
||||
return;
|
||||
}
|
||||
|
||||
RemoveCookie(httpContext, _accessTokenCookieName);
|
||||
RemoveCookie(httpContext, _refreshTokenCookieName);
|
||||
RemoveCookie(httpContext, AccessTokenCookieName);
|
||||
RemoveCookie(httpContext, RefreshTokenCookieName);
|
||||
}
|
||||
|
||||
private HttpContext GetHttpContext()
|
||||
@@ -297,19 +247,8 @@ internal sealed class HideBackOfficeTokensHandler
|
||||
var key = GetCookieKey(httpContext, cookieName);
|
||||
if (httpContext.Request.Cookies.TryGetValue(key, out var cookieValue))
|
||||
{
|
||||
try
|
||||
{
|
||||
value = EncryptionHelper.Decrypt(cookieValue, _dataProtectionProvider);
|
||||
return true;
|
||||
}
|
||||
catch (CryptographicException ex)
|
||||
{
|
||||
// Decryption can fail if the data protection key ring has changed
|
||||
// (e.g., after deployment, app pool recycle, or slot swap).
|
||||
// Treat this as a missing cookie — the user will need to re-authenticate.
|
||||
_logger.LogWarning(ex, "Failed to decrypt back-office token cookie '{CookieName}'. The user will need to re-authenticate.", cookieName);
|
||||
RemoveCookie(httpContext, cookieName);
|
||||
}
|
||||
value = EncryptionHelper.Decrypt(cookieValue, _dataProtectionProvider);
|
||||
return true;
|
||||
}
|
||||
|
||||
value = null;
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
using Microsoft.AspNetCore.Http.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Umbraco.Cms.Api.Common.DependencyInjection;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for replacing the internal Microsoft.AspNetCore.OpenApi schema service registration.
|
||||
/// </summary>
|
||||
internal static class OpenApiSchemaServiceExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// The full name of the internal Microsoft type whose registration is replaced.
|
||||
/// Used for a stringly-typed <see cref="ServiceDescriptor"/> lookup because the type is not publicly accessible.
|
||||
/// </summary>
|
||||
internal const string OpenApiSchemaServiceFullName = "Microsoft.AspNetCore.OpenApi.OpenApiSchemaService";
|
||||
|
||||
/// <summary>
|
||||
/// Replaces the internal Microsoft <c>OpenApiSchemaService</c> registration for the specified document so that schema
|
||||
/// generation uses the named <see cref="JsonOptions"/> rather than the default HTTP JSON options.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="documentName">The OpenAPI document key (matches the keyed singleton registered by <c>AddOpenApi(documentName)</c>).</param>
|
||||
/// <param name="jsonOptionsName">The named <see cref="JsonOptions"/> to use during schema generation for this document.</param>
|
||||
/// <returns>The same <see cref="IServiceCollection"/> for chaining.</returns>
|
||||
/// <remarks>
|
||||
/// Workaround for <see href="https://github.com/dotnet/aspnetcore/issues/66340">dotnet/aspnetcore#66340</see>.
|
||||
/// </remarks>
|
||||
public static IServiceCollection ReplaceOpenApiSchemaService(
|
||||
this IServiceCollection services,
|
||||
string documentName,
|
||||
string jsonOptionsName)
|
||||
=> services.ReplaceOpenApiSchemaService(
|
||||
documentName,
|
||||
sp => sp.GetRequiredService<IOptionsMonitor<JsonOptions>>().Get(jsonOptionsName));
|
||||
|
||||
/// <summary>
|
||||
/// Replaces the internal Microsoft <c>OpenApiSchemaService</c> registration for the specified document so that schema
|
||||
/// generation uses the <see cref="JsonOptions"/> instance produced by the supplied factory. Use this overload when
|
||||
/// the options need to be resolved from the service provider, computed at the last moment, or built in a way that
|
||||
/// doesn't fit the named-options lookup.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="documentName">The OpenAPI document key.</param>
|
||||
/// <param name="jsonOptionsFactory">Factory invoked when the schema service is first resolved. Receives the resolving <see cref="IServiceProvider"/> and returns the <see cref="JsonOptions"/> to use.</param>
|
||||
/// <returns>The same <see cref="IServiceCollection"/> for chaining.</returns>
|
||||
/// <remarks>
|
||||
/// Workaround for <see href="https://github.com/dotnet/aspnetcore/issues/66340">dotnet/aspnetcore#66340</see>.
|
||||
/// </remarks>
|
||||
public static IServiceCollection ReplaceOpenApiSchemaService(
|
||||
this IServiceCollection services,
|
||||
string documentName,
|
||||
Func<IServiceProvider, JsonOptions> jsonOptionsFactory)
|
||||
{
|
||||
ServiceDescriptor descriptor = services.FirstOrDefault(sd =>
|
||||
sd.ServiceType.FullName == OpenApiSchemaServiceFullName
|
||||
&& Equals(sd.ServiceKey, documentName))
|
||||
?? throw new InvalidOperationException(
|
||||
$"Could not find a registration for {OpenApiSchemaServiceFullName} keyed with '{documentName}'. "
|
||||
+ $"Ensure AddOpenApi(\"{documentName}\") has been called before {nameof(ReplaceOpenApiSchemaService)}, "
|
||||
+ "or check whether the internal Microsoft.AspNetCore.OpenApi registration shape has changed.");
|
||||
|
||||
services.Remove(descriptor);
|
||||
services.AddKeyedSingleton(
|
||||
descriptor.ServiceType,
|
||||
documentName,
|
||||
(sp, key) => ActivatorUtilities.CreateInstance(
|
||||
sp,
|
||||
descriptor.ServiceType,
|
||||
key,
|
||||
Options.Create(jsonOptionsFactory(sp))));
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Swashbuckle.AspNetCore.SwaggerUI;
|
||||
using Umbraco.Cms.Api.Common.OpenApi;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Api.Common.DependencyInjection;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for <see cref="IServiceCollection"/> to configure OpenAPI services.
|
||||
/// </summary>
|
||||
public static class OpenApiServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds an OpenAPI document to the OpenAPI UI document selector dropdown.
|
||||
/// </summary>
|
||||
/// <param name="services">The <see cref="IServiceCollection"/> instance.</param>
|
||||
/// <param name="documentName">The name/identifier of the OpenAPI document.</param>
|
||||
/// <param name="documentTitle">The title to display in the UI dropdown. Defaults to <paramref name="documentName"/> if not specified.</param>
|
||||
/// <returns>The <see cref="IServiceCollection"/> instance.</returns>
|
||||
public static IServiceCollection AddOpenApiDocumentToUi(
|
||||
this IServiceCollection services,
|
||||
string documentName,
|
||||
string? documentTitle = null)
|
||||
=> services.AddOpenApiDocumentToUi(documentName, () => documentTitle);
|
||||
|
||||
/// <summary>
|
||||
/// Adds an OpenAPI document to the OpenAPI UI document selector dropdown, resolving the title lazily so
|
||||
/// callers (such as builder-pattern helpers) can defer it until SwaggerUI options are resolved.
|
||||
/// </summary>
|
||||
/// <param name="services">The <see cref="IServiceCollection"/> instance.</param>
|
||||
/// <param name="documentName">The name/identifier of the OpenAPI document.</param>
|
||||
/// <param name="documentTitleFactory">Factory invoked when SwaggerUI options are resolved. Returning <c>null</c> falls back to <paramref name="documentName"/>.</param>
|
||||
/// <returns>The <see cref="IServiceCollection"/> instance.</returns>
|
||||
internal static IServiceCollection AddOpenApiDocumentToUi(
|
||||
this IServiceCollection services,
|
||||
string documentName,
|
||||
Func<string?> documentTitleFactory)
|
||||
{
|
||||
services.AddOptions<SwaggerUIOptions>()
|
||||
.Configure<IOptions<UmbracoOpenApiOptions>>((swaggerUiOptions, openApiOptions) =>
|
||||
{
|
||||
var openApiRoute = openApiOptions.Value.RouteTemplate.Replace("{documentName}", documentName).EnsureStartsWith("/");
|
||||
swaggerUiOptions.SwaggerEndpoint(openApiRoute, documentTitleFactory() ?? documentName);
|
||||
swaggerUiOptions.ConfigObject.Urls = swaggerUiOptions.ConfigObject.Urls.OrderBy(x => x.Name);
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,9 @@
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Umbraco.Cms.Api.Common.Configuration;
|
||||
using Umbraco.Cms.Api.Common.OpenApi;
|
||||
using Umbraco.Cms.Api.Common.Serialization;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.DependencyInjection;
|
||||
using Umbraco.Cms.Core.Hosting;
|
||||
using Umbraco.Cms.Web.Common.ApplicationBuilder;
|
||||
using IHostingEnvironment = Umbraco.Cms.Core.Hosting.IHostingEnvironment;
|
||||
|
||||
namespace Umbraco.Cms.Api.Common.DependencyInjection;
|
||||
|
||||
@@ -21,51 +16,26 @@ public static class UmbracoBuilderApiExtensions
|
||||
/// Adds Umbraco API OpenAPI/Swagger UI services to the builder.
|
||||
/// </summary>
|
||||
/// <param name="builder">The Umbraco builder.</param>
|
||||
internal static void AddUmbracoOpenApi(this IUmbracoBuilder builder)
|
||||
/// <returns>The Umbraco builder for method chaining.</returns>
|
||||
public static IUmbracoBuilder AddUmbracoApiOpenApiUI(this IUmbracoBuilder builder)
|
||||
{
|
||||
if (builder.Services.Any(x => !x.IsKeyedService && x.ImplementationType == typeof(UmbracoJsonTypeInfoResolver)))
|
||||
if (builder.Services.Any(x => !x.IsKeyedService && x.ImplementationType == typeof(OperationIdSelector)))
|
||||
{
|
||||
return;
|
||||
return builder;
|
||||
}
|
||||
|
||||
builder.Services.AddOptions<UmbracoOpenApiOptions>()
|
||||
.Configure<IHostingEnvironment, IWebHostEnvironment>((options, hostingEnv, webHostEnv) =>
|
||||
{
|
||||
options.Enabled = webHostEnv.IsProduction() is false;
|
||||
var backOfficePath = hostingEnv.GetBackOfficePath().TrimStart(Constants.CharArrays.ForwardSlash);
|
||||
options.RouteTemplate = $"{backOfficePath}/openapi/{{documentName}}.json";
|
||||
options.UiRoutePrefix = $"{backOfficePath}/openapi";
|
||||
});
|
||||
builder.AddUmbracoOpenApiDocument<ConfigureDefaultApiOptions>(DefaultApiConfiguration.ApiName, "Default API");
|
||||
builder.Services.AddSwaggerGen();
|
||||
builder.Services.ConfigureOptions<ConfigureUmbracoSwaggerGenOptions>();
|
||||
builder.Services.AddSingleton<IUmbracoJsonTypeInfoResolver, UmbracoJsonTypeInfoResolver>();
|
||||
builder.Services.Configure<UmbracoPipelineOptions>(options => options.AddFilter(new OpenApiRouteTemplatePipelineFilter("UmbracoApiCommon")));
|
||||
}
|
||||
builder.Services.AddSingleton<IOperationIdSelector, OperationIdSelector>();
|
||||
builder.Services.AddSingleton<IOperationIdHandler, OperationIdHandler>();
|
||||
builder.Services.AddSingleton<ISchemaIdSelector, SchemaIdSelector>();
|
||||
builder.Services.AddSingleton<ISchemaIdHandler, SchemaIdHandler>();
|
||||
builder.Services.AddSingleton<ISubTypesSelector, SubTypesSelector>();
|
||||
builder.Services.AddSingleton<ISubTypesHandler, SubTypesHandler>();
|
||||
builder.Services.AddSingleton<IDocumentInclusionSelector, DocumentInclusionSelector>();
|
||||
builder.Services.Configure<UmbracoPipelineOptions>(options => options.AddFilter(new SwaggerRouteTemplatePipelineFilter("UmbracoApiCommon")));
|
||||
|
||||
/// <summary>
|
||||
/// Adds and configures an Umbraco OpenAPI document with shared transformers.
|
||||
/// </summary>
|
||||
/// <param name="builder">The Umbraco builder.</param>
|
||||
/// <param name="apiName">The name/identifier of the API.</param>
|
||||
/// <param name="apiTitle">The title of the API.</param>
|
||||
/// <param name="jsonOptionsName">
|
||||
/// Optional named <c>JsonOptions</c> to use for schema generation instead of the default HTTP JSON options.
|
||||
/// When specified, replaces the internal <c>OpenApiSchemaService</c> registration for this document.
|
||||
/// </param>
|
||||
/// <typeparam name="TConfigureOptions">The type used to configure the OpenAPI options.</typeparam>
|
||||
internal static void AddUmbracoOpenApiDocument<TConfigureOptions>(
|
||||
this IUmbracoBuilder builder,
|
||||
string apiName,
|
||||
string apiTitle,
|
||||
string? jsonOptionsName = null)
|
||||
where TConfigureOptions : ConfigureUmbracoOpenApiOptionsBase
|
||||
{
|
||||
builder.Services.AddOpenApi(apiName);
|
||||
builder.Services.ConfigureOptions<TConfigureOptions>();
|
||||
builder.Services.AddOpenApiDocumentToUi(apiName, apiTitle);
|
||||
|
||||
if (jsonOptionsName is not null)
|
||||
{
|
||||
builder.Services.ReplaceOpenApiSchemaService(apiName, jsonOptionsName);
|
||||
}
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,12 +145,6 @@ public static class UmbracoBuilderAuthExtensions
|
||||
.UseSingletonHandler<HideBackOfficeTokensHandler>()
|
||||
.SetOrder(OpenIddict.Server.AspNetCore.OpenIddictServerAspNetCoreHandlers.ExtractPostRequest<OpenIddictServerEvents.ExtractTokenRequestContext>.Descriptor.Order + 1);
|
||||
});
|
||||
options.AddEventHandler<OpenIddictServerEvents.ExtractRevocationRequestContext>(configuration =>
|
||||
{
|
||||
configuration
|
||||
.UseSingletonHandler<HideBackOfficeTokensHandler>()
|
||||
.SetOrder(OpenIddict.Server.AspNetCore.OpenIddictServerAspNetCoreHandlers.ExtractPostRequest<OpenIddictServerEvents.ExtractRevocationRequestContext>.Descriptor.Order + 1);
|
||||
});
|
||||
})
|
||||
|
||||
// Register the OpenIddict validation components.
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
using Microsoft.AspNetCore.Http.Json;
|
||||
using Microsoft.AspNetCore.OpenApi;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Umbraco.Cms.Api.Common.DependencyInjection;
|
||||
using Umbraco.Cms.Core.DependencyInjection;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Api.Common.OpenApi;
|
||||
|
||||
/// <summary>
|
||||
/// Fluent builder for configuring a custom OpenAPI document.
|
||||
/// </summary>
|
||||
public sealed class BackOfficeOpenApiDocumentBuilder
|
||||
{
|
||||
private readonly List<Action<OpenApiOptions>> _configurations = [];
|
||||
|
||||
private string? _title;
|
||||
private string? _uiTitle;
|
||||
private bool _includedInUi = true;
|
||||
private Func<IServiceProvider, JsonOptions>? _httpJsonOptionsFactory;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="BackOfficeOpenApiDocumentBuilder"/> class.
|
||||
/// </summary>
|
||||
/// <param name="documentName">The name of the OpenAPI document being configured.</param>
|
||||
internal BackOfficeOpenApiDocumentBuilder(string documentName)
|
||||
=> DocumentName = documentName;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the name of the OpenAPI document being configured.
|
||||
/// </summary>
|
||||
public string DocumentName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Sets the document's <c>Info.Title</c>. Also used as the UI dropdown label unless overridden via
|
||||
/// <see cref="WithUiTitle"/>.
|
||||
/// </summary>
|
||||
/// <param name="title">The title to display.</param>
|
||||
/// <returns>The same builder for chaining.</returns>
|
||||
public BackOfficeOpenApiDocumentBuilder WithTitle(string title)
|
||||
{
|
||||
_title = title;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Overrides the UI dropdown label for this document.
|
||||
/// </summary>
|
||||
/// <param name="uiTitle">The label to display.</param>
|
||||
/// <returns>The same builder for chaining.</returns>
|
||||
public BackOfficeOpenApiDocumentBuilder WithUiTitle(string uiTitle)
|
||||
{
|
||||
_uiTitle = uiTitle;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Excludes this document from the UI dropdown.
|
||||
/// </summary>
|
||||
/// <returns>The same builder for chaining.</returns>
|
||||
public BackOfficeOpenApiDocumentBuilder ExcludeFromUi()
|
||||
{
|
||||
_includedInUi = false;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds an <see cref="OpenApiOptions"/> configuration callback. Multiple calls compose.
|
||||
/// </summary>
|
||||
/// <param name="configure">Callback to configure the options.</param>
|
||||
/// <returns>The same builder for chaining.</returns>
|
||||
public BackOfficeOpenApiDocumentBuilder ConfigureOpenApiOptions(Action<OpenApiOptions> configure)
|
||||
{
|
||||
_configurations.Add(configure);
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the named <see cref="JsonOptions">Microsoft.AspNetCore.Http.Json.JsonOptions</see> used when
|
||||
/// generating this document's schema. Use this to match the serialization conventions of the API
|
||||
/// endpoints the document describes.
|
||||
/// </summary>
|
||||
/// <param name="jsonOptionsName">The name of the registered HTTP <see cref="JsonOptions"/> to apply.</param>
|
||||
/// <returns>The same builder for chaining.</returns>
|
||||
public BackOfficeOpenApiDocumentBuilder WithJsonOptions(string jsonOptionsName)
|
||||
=> WithJsonOptions(sp => sp.GetRequiredService<IOptionsMonitor<JsonOptions>>().Get(jsonOptionsName));
|
||||
|
||||
/// <summary>
|
||||
/// Sets the <see cref="JsonOptions">Microsoft.AspNetCore.Http.Json.JsonOptions</see> used when
|
||||
/// generating this document's schema. Use this to match the serialization conventions of the API
|
||||
/// endpoints the document describes.
|
||||
/// </summary>
|
||||
/// <param name="jsonOptions">The HTTP JSON options to apply.</param>
|
||||
/// <returns>The same builder for chaining.</returns>
|
||||
public BackOfficeOpenApiDocumentBuilder WithJsonOptions(JsonOptions jsonOptions)
|
||||
=> WithJsonOptions(_ => jsonOptions);
|
||||
|
||||
/// <summary>
|
||||
/// Sets a factory that produces the <see cref="JsonOptions">Microsoft.AspNetCore.Http.Json.JsonOptions</see>
|
||||
/// used when generating this document's schema. Use this to match the serialization conventions of the
|
||||
/// API endpoints the document describes.
|
||||
/// </summary>
|
||||
/// <param name="jsonOptionsFactory">Factory invoked when the schema service is first resolved.</param>
|
||||
/// <returns>The same builder for chaining.</returns>
|
||||
public BackOfficeOpenApiDocumentBuilder WithJsonOptions(Func<IServiceProvider, JsonOptions> jsonOptionsFactory)
|
||||
{
|
||||
_httpJsonOptionsFactory = jsonOptionsFactory;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies the accumulated configuration to the supplied <see cref="IUmbracoBuilder"/>'s service
|
||||
/// collection. Called by <c>AddBackOfficeOpenApiDocument</c> once the user-supplied callback returns.
|
||||
/// </summary>
|
||||
/// <param name="builder">The Umbraco builder to register services against.</param>
|
||||
internal void Build(IUmbracoBuilder builder)
|
||||
{
|
||||
builder.Services.AddOpenApi(
|
||||
DocumentName,
|
||||
options =>
|
||||
{
|
||||
options.ShouldInclude = apiDescription =>
|
||||
apiDescription.ActionDescriptor.HasMapToApiAttribute(DocumentName);
|
||||
|
||||
options.CreateSchemaReferenceId = UmbracoSchemaIdGenerator.CreateSchemaReferenceId;
|
||||
|
||||
if (_title is not null)
|
||||
{
|
||||
options.AddDocumentTransformer((document, _, _) =>
|
||||
{
|
||||
document.Info.Title = _title;
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
}
|
||||
|
||||
// Generate operation IDs using Umbraco's naming conventions.
|
||||
options.AddOperationTransformer<UmbracoOperationIdTransformer>();
|
||||
|
||||
// Trim redundant JSON-equivalent MIME types (e.g. text/json, application/*+json, text/plain)
|
||||
// that ASP.NET Core adds alongside application/json.
|
||||
options.AddOperationTransformer<MimeTypesTransformer>();
|
||||
|
||||
// Mark non-nullable properties as required so generated SDKs reflect the C# nullability.
|
||||
options.AddSchemaTransformer<RequireNonNullablePropertiesSchemaTransformer>();
|
||||
|
||||
// Tag actions by group name and cleanup unused tags (caused by the tag changes).
|
||||
options
|
||||
.AddOperationTransformer<TagActionsByGroupNameTransformer>()
|
||||
.AddDocumentTransformer<TagActionsByGroupNameTransformer>()
|
||||
.AddDocumentTransformer<SortTagsAndPathsTransformer>();
|
||||
|
||||
foreach (Action<OpenApiOptions> configure in _configurations)
|
||||
{
|
||||
configure(options);
|
||||
}
|
||||
});
|
||||
|
||||
if (_includedInUi)
|
||||
{
|
||||
builder.Services.AddOpenApiDocumentToUi(DocumentName, _uiTitle ?? _title);
|
||||
}
|
||||
|
||||
if (_httpJsonOptionsFactory is not null)
|
||||
{
|
||||
builder.Services.ReplaceOpenApiSchemaService(DocumentName, _httpJsonOptionsFactory);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using Asp.Versioning;
|
||||
using Microsoft.AspNetCore.Mvc.Abstractions;
|
||||
using Microsoft.AspNetCore.Mvc.ApiExplorer;
|
||||
using Microsoft.AspNetCore.Mvc.Controllers;
|
||||
using Umbraco.Cms.Api.Common.Configuration;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Api.Common.OpenApi;
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether an API description should be included in a specific documentation set based on the document name
|
||||
/// and API metadata.
|
||||
/// </summary>
|
||||
public class DocumentInclusionSelector : IDocumentInclusionSelector
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public bool Include(string documentName, ApiDescription apiDescription)
|
||||
{
|
||||
if (apiDescription.ActionDescriptor is ControllerActionDescriptor controllerActionDescriptor
|
||||
&& controllerActionDescriptor.HasMapToApiAttribute(documentName))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
ApiVersionMetadata apiVersionMetadata = apiDescription.ActionDescriptor.GetApiVersionMetadata();
|
||||
return apiVersionMetadata.Name == documentName
|
||||
|| (string.IsNullOrEmpty(apiVersionMetadata.Name) && documentName == DefaultApiConfiguration.ApiName);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
using System.Reflection;
|
||||
using System.Runtime.Serialization;
|
||||
using System.Text.Json.Nodes;
|
||||
using Microsoft.OpenApi;
|
||||
using Swashbuckle.AspNetCore.SwaggerGen;
|
||||
|
||||
namespace Umbraco.Cms.Api.Common.OpenApi;
|
||||
|
||||
/// <summary>
|
||||
/// A schema filter that converts enum schemas to string type with enum member names.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This filter ensures enums are represented as strings in the OpenAPI schema,
|
||||
/// using <see cref="EnumMemberAttribute"/> values when available.
|
||||
/// </remarks>
|
||||
public class EnumSchemaFilter : ISchemaFilter
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public void Apply(IOpenApiSchema model, SchemaFilterContext context)
|
||||
{
|
||||
if (model is not OpenApiSchema schema || context.Type.IsEnum is false)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
schema.Type = JsonSchemaType.String;
|
||||
schema.Format = null;
|
||||
schema.Enum = new List<JsonNode>();
|
||||
foreach (var name in Enum.GetNames(context.Type))
|
||||
{
|
||||
var actualName = context.Type.GetField(name)?.GetCustomAttribute<EnumMemberAttribute>()?.Value ?? name;
|
||||
schema.Enum.Add(actualName);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
namespace Umbraco.Cms.Api.Common.OpenApi;
|
||||
|
||||
/// <summary>
|
||||
/// Excludes the controller from the default OpenAPI document.
|
||||
/// Use this when you have a custom OpenAPI document for your API.
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Class)]
|
||||
public sealed class ExcludeFromDefaultOpenApiDocumentAttribute : Attribute
|
||||
{
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
using System.IO.Pipelines;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.OpenApi;
|
||||
using Microsoft.OpenApi;
|
||||
|
||||
namespace Umbraco.Cms.Api.Common.OpenApi;
|
||||
|
||||
/// <summary>
|
||||
/// Transformer to fix file return types in OpenAPI schema.
|
||||
/// </summary>
|
||||
/// <remarks>Can be removed once https://github.com/dotnet/aspnetcore/pull/63504 and
|
||||
/// https://github.com/dotnet/aspnetcore/pull/64562 are released.</remarks>
|
||||
internal class FixFileReturnTypesTransformer : IOpenApiSchemaTransformer
|
||||
{
|
||||
private static readonly Type[] _binaryStringTypes =
|
||||
[
|
||||
typeof(IFormFile),
|
||||
typeof(FileResult),
|
||||
typeof(Stream),
|
||||
typeof(PipeReader),
|
||||
];
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task TransformAsync(
|
||||
OpenApiSchema schema,
|
||||
OpenApiSchemaTransformerContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (_binaryStringTypes.Any(possibleBaseType => possibleBaseType.IsAssignableFrom(context.JsonTypeInfo.Type)) is false)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// Clear all properties
|
||||
schema.Properties?.Clear();
|
||||
schema.Required?.Clear();
|
||||
|
||||
// Make it an inline schema
|
||||
schema.Metadata?.Remove("x-schema-id");
|
||||
|
||||
// Set type to string with binary format
|
||||
schema.Type = JsonSchemaType.String;
|
||||
schema.Format = "binary";
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using Microsoft.AspNetCore.Mvc.ApiExplorer;
|
||||
|
||||
namespace Umbraco.Cms.Api.Common.OpenApi;
|
||||
|
||||
/// <summary>
|
||||
/// Defines a method that determines whether a given API description should be included in a specific documentation
|
||||
/// document.
|
||||
/// </summary>
|
||||
public interface IDocumentInclusionSelector
|
||||
{
|
||||
/// <summary>
|
||||
/// Determines whether the specified API description should be included in the generated documentation for the given
|
||||
/// document name.
|
||||
/// </summary>
|
||||
/// <param name="documentName">The name of the documentation document being generated.</param>
|
||||
/// <param name="apiDescription">The API description to evaluate for inclusion.</param>
|
||||
/// <returns>true if the API description should be included in the documentation; otherwise, false.</returns>
|
||||
bool Include(string documentName, ApiDescription apiDescription);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using Microsoft.AspNetCore.Mvc.ApiExplorer;
|
||||
|
||||
namespace Umbraco.Cms.Api.Common.OpenApi;
|
||||
|
||||
/// <summary>
|
||||
/// Defines a handler for generating OpenAPI operation IDs.
|
||||
/// </summary>
|
||||
public interface IOperationIdHandler
|
||||
{
|
||||
/// <summary>
|
||||
/// Determines whether this handler can generate an operation ID for the specified API description.
|
||||
/// </summary>
|
||||
/// <param name="apiDescription">The API description to check.</param>
|
||||
/// <returns><c>true</c> if this handler can handle the API description; otherwise, <c>false</c>.</returns>
|
||||
bool CanHandle(ApiDescription apiDescription);
|
||||
|
||||
/// <summary>
|
||||
/// Generates an operation ID for the specified API description.
|
||||
/// </summary>
|
||||
/// <param name="apiDescription">The API description to generate an operation ID for.</param>
|
||||
/// <returns>The generated operation ID.</returns>
|
||||
string Handle(ApiDescription apiDescription);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using Asp.Versioning;
|
||||
using Microsoft.AspNetCore.Mvc.ApiExplorer;
|
||||
|
||||
namespace Umbraco.Cms.Api.Common.OpenApi;
|
||||
|
||||
/// <summary>
|
||||
/// Defines a selector for choosing operation IDs from registered handlers.
|
||||
/// </summary>
|
||||
public interface IOperationIdSelector
|
||||
{
|
||||
/// <summary>
|
||||
/// Selects an operation ID for the specified API description.
|
||||
/// </summary>
|
||||
/// <param name="apiDescription">The API description to generate an operation ID for.</param>
|
||||
/// <returns>The operation ID, or <c>null</c> if none could be determined.</returns>
|
||||
string? OperationId(ApiDescription apiDescription);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace Umbraco.Cms.Api.Common.OpenApi;
|
||||
|
||||
/// <summary>
|
||||
/// Defines a handler for generating OpenAPI schema IDs.
|
||||
/// </summary>
|
||||
public interface ISchemaIdHandler
|
||||
{
|
||||
/// <summary>
|
||||
/// Determines whether this handler can generate a schema ID for the specified type.
|
||||
/// </summary>
|
||||
/// <param name="type">The type to check.</param>
|
||||
/// <returns><c>true</c> if this handler can handle the type; otherwise, <c>false</c>.</returns>
|
||||
bool CanHandle(Type type);
|
||||
|
||||
/// <summary>
|
||||
/// Generates a schema ID for the specified type.
|
||||
/// </summary>
|
||||
/// <param name="type">The type to generate a schema ID for.</param>
|
||||
/// <returns>The generated schema ID.</returns>
|
||||
string Handle(Type type);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace Umbraco.Cms.Api.Common.OpenApi;
|
||||
|
||||
/// <summary>
|
||||
/// Defines a selector for choosing schema IDs from registered handlers.
|
||||
/// </summary>
|
||||
public interface ISchemaIdSelector
|
||||
{
|
||||
/// <summary>
|
||||
/// Selects a schema ID for the specified type.
|
||||
/// </summary>
|
||||
/// <param name="type">The type to generate a schema ID for.</param>
|
||||
/// <returns>The schema ID.</returns>
|
||||
string SchemaId(Type type);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace Umbraco.Cms.Api.Common.OpenApi;
|
||||
|
||||
/// <summary>
|
||||
/// Defines a handler for discovering sub-types for polymorphic OpenAPI schemas.
|
||||
/// </summary>
|
||||
public interface ISubTypesHandler
|
||||
{
|
||||
/// <summary>
|
||||
/// Determines whether this handler can discover sub-types for the specified type and document.
|
||||
/// </summary>
|
||||
/// <param name="type">The type to check.</param>
|
||||
/// <param name="documentName">The OpenAPI document name.</param>
|
||||
/// <returns><c>true</c> if this handler can handle the type; otherwise, <c>false</c>.</returns>
|
||||
bool CanHandle(Type type, string documentName);
|
||||
|
||||
/// <summary>
|
||||
/// Discovers sub-types for the specified type.
|
||||
/// </summary>
|
||||
/// <param name="type">The type to discover sub-types for.</param>
|
||||
/// <returns>An enumerable of discovered sub-types.</returns>
|
||||
IEnumerable<Type> Handle(Type type);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace Umbraco.Cms.Api.Common.OpenApi;
|
||||
|
||||
/// <summary>
|
||||
/// Defines a selector for choosing sub-types from registered handlers.
|
||||
/// </summary>
|
||||
public interface ISubTypesSelector
|
||||
{
|
||||
/// <summary>
|
||||
/// Selects sub-types for the specified type for polymorphic OpenAPI schema generation.
|
||||
/// </summary>
|
||||
/// <param name="type">The type to find sub-types for.</param>
|
||||
/// <returns>An enumerable of sub-types.</returns>
|
||||
IEnumerable<Type> SubTypes(Type type);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using Microsoft.OpenApi;
|
||||
using Swashbuckle.AspNetCore.SwaggerGen;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Api.Common.OpenApi;
|
||||
|
||||
/// <summary>
|
||||
/// This filter explicitly removes all other mime types than application/json from a named OpenAPI document when application/json is accepted.
|
||||
/// </summary>
|
||||
public class MimeTypeDocumentFilter : IDocumentFilter
|
||||
{
|
||||
private readonly string _documentName;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="MimeTypeDocumentFilter"/> class.
|
||||
/// </summary>
|
||||
/// <param name="documentName">The name of the OpenAPI document to filter.</param>
|
||||
public MimeTypeDocumentFilter(string documentName) => _documentName = documentName;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Apply(OpenApiDocument swaggerDoc, DocumentFilterContext context)
|
||||
{
|
||||
if (context.DocumentName != _documentName)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
OpenApiOperation[] operations = swaggerDoc.Paths
|
||||
.SelectMany(path => path.Value.Operations?.Values ?? Enumerable.Empty<OpenApiOperation>())
|
||||
.ToArray();
|
||||
|
||||
static void RemoveUnwantedMimeTypes(IDictionary<string, OpenApiMediaType>? content)
|
||||
{
|
||||
if (content is null || content.ContainsKey("application/json") is false)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
content.RemoveAll(r => r.Key != "application/json");
|
||||
}
|
||||
|
||||
OpenApiRequestBody[] requestBodies = operations
|
||||
.Select(operation => operation.RequestBody)
|
||||
.OfType<OpenApiRequestBody>()
|
||||
.ToArray();
|
||||
foreach (OpenApiRequestBody requestBody in requestBodies)
|
||||
{
|
||||
RemoveUnwantedMimeTypes(requestBody.Content);
|
||||
}
|
||||
|
||||
OpenApiResponse[] responses = operations
|
||||
.SelectMany(operation => operation.Responses?.Values ?? Enumerable.Empty<IOpenApiResponse>())
|
||||
.OfType<OpenApiResponse>()
|
||||
.ToArray();
|
||||
foreach (OpenApiResponse response in responses)
|
||||
{
|
||||
RemoveUnwantedMimeTypes(response.Content);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
using System.Net.Mime;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.OpenApi;
|
||||
using Microsoft.OpenApi;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Api.Common.OpenApi;
|
||||
|
||||
/// <summary>
|
||||
/// Trims redundant JSON-equivalent media types from OpenAPI operations.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// ASP.NET Core's content negotiation populates operations with several media types that all serialize to JSON
|
||||
/// (<c>text/json</c>, <c>application/*+json</c>, and <c>text/plain</c> alongside <c>application/json</c>).
|
||||
/// When <c>application/json</c> is present on a response or request body, this transformer strips those
|
||||
/// equivalents so OpenAPI consumers and generated SDKs aren't burdened with variants that produce identical
|
||||
/// payloads. Non-JSON media types (e.g. <c>application/xml</c>, <c>application/octet-stream</c>) are preserved.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Request bodies additionally honour <c>[Consumes]</c>: when the attribute is present, the request content is
|
||||
/// replaced entirely with the declared content types, taking precedence over the
|
||||
/// JSON-equivalent stripping above.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal class MimeTypesTransformer : IOpenApiOperationTransformer
|
||||
{
|
||||
private static readonly string[] _jsonEquivalentMimeTypes =
|
||||
[
|
||||
MediaTypeNames.Text.Plain,
|
||||
"application/*+json",
|
||||
"text/json"
|
||||
];
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task TransformAsync(
|
||||
OpenApiOperation operation,
|
||||
OpenApiOperationTransformerContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// For request bodies, keep only the content types declared in [Consumes], or fall back to application/json.
|
||||
if (operation.RequestBody?.Content is { } requestContent)
|
||||
{
|
||||
var explicitContentTypes = context.Description.ActionDescriptor.EndpointMetadata
|
||||
.OfType<ConsumesAttribute>()
|
||||
.SelectMany(p => p.ContentTypes)
|
||||
.Distinct()
|
||||
.ToArray();
|
||||
|
||||
if (explicitContentTypes.Length != 0)
|
||||
{
|
||||
// Replace content types entirely with what [Consumes] declares,
|
||||
// preserving the schema from the existing entry.
|
||||
OpenApiMediaType? existingMediaType = requestContent.Values.FirstOrDefault();
|
||||
requestContent.Clear();
|
||||
foreach (var contentType in explicitContentTypes)
|
||||
{
|
||||
requestContent[contentType] = existingMediaType ?? new OpenApiMediaType();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
RemoveJsonEquivalentMimeTypes(requestContent);
|
||||
}
|
||||
}
|
||||
|
||||
// For responses, drop JSON-equivalent media types when application/json is present.
|
||||
foreach (IOpenApiResponse response in (operation.Responses ?? []).Values)
|
||||
{
|
||||
if (response is OpenApiResponse openApiResponse)
|
||||
{
|
||||
RemoveJsonEquivalentMimeTypes(openApiResponse.Content);
|
||||
}
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static void RemoveJsonEquivalentMimeTypes(IDictionary<string, OpenApiMediaType>? content)
|
||||
{
|
||||
if (content?.ContainsKey(MediaTypeNames.Application.Json) != true)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
content.RemoveAll(r => _jsonEquivalentMimeTypes.Contains(r.Key, StringComparer.OrdinalIgnoreCase));
|
||||
}
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Swashbuckle.AspNetCore.SwaggerUI;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Web.Common.ApplicationBuilder;
|
||||
|
||||
namespace Umbraco.Cms.Api.Common.OpenApi;
|
||||
|
||||
internal class OpenApiRouteTemplatePipelineFilter : UmbracoPipelineFilter
|
||||
{
|
||||
public OpenApiRouteTemplatePipelineFilter(string name)
|
||||
: base(name)
|
||||
{
|
||||
PostPipeline = PostPipelineAction;
|
||||
PreMapEndpoints = OnPreMapEndpointsAction;
|
||||
}
|
||||
|
||||
private static void PostPipelineAction(IApplicationBuilder applicationBuilder)
|
||||
{
|
||||
UmbracoOpenApiOptions options = applicationBuilder.ApplicationServices
|
||||
.GetRequiredService<IOptions<UmbracoOpenApiOptions>>().Value;
|
||||
|
||||
if (options.Enabled is false || options.DefaultUiEnabled is false)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
applicationBuilder.UseSwaggerUI(swaggerUiOptions => ConfigureSwaggerUi(swaggerUiOptions, options));
|
||||
}
|
||||
|
||||
private static void OnPreMapEndpointsAction(IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
UmbracoOpenApiOptions options = endpoints.ServiceProvider
|
||||
.GetRequiredService<IOptions<UmbracoOpenApiOptions>>().Value;
|
||||
|
||||
if (options.Enabled is false)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
endpoints.MapOpenApi(options.RouteTemplate);
|
||||
}
|
||||
|
||||
private static void ConfigureSwaggerUi(SwaggerUIOptions swaggerUiOptions, UmbracoOpenApiOptions options)
|
||||
{
|
||||
swaggerUiOptions.RoutePrefix = options.UiRoutePrefix;
|
||||
|
||||
// Add custom configuration from https://swagger.io/docs/open-source-tools/swagger-ui/usage/configuration/
|
||||
swaggerUiOptions.ConfigObject.PersistAuthorization = true; // persists authorization data so it would not be lost on browser close/refresh
|
||||
swaggerUiOptions.ConfigObject.Filter = string.Empty; // Enable the filter with an empty string as default filter.
|
||||
|
||||
swaggerUiOptions.OAuthClientId(Constants.OAuthClientIds.OpenApiUi);
|
||||
swaggerUiOptions.OAuthUsePkce();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
using Asp.Versioning;
|
||||
using Microsoft.AspNetCore.Mvc.ApiExplorer;
|
||||
using Microsoft.AspNetCore.Mvc.Controllers;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Api.Common.OpenApi;
|
||||
|
||||
/// <summary>
|
||||
/// Default handler for generating OpenAPI operation IDs for Umbraco API controllers.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Left unsealed on purpose, so it is extendable by consuming APIs.
|
||||
/// </remarks>
|
||||
public class OperationIdHandler : IOperationIdHandler
|
||||
{
|
||||
private readonly ApiVersioningOptions _apiVersioningOptions;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="OperationIdHandler"/> class.
|
||||
/// </summary>
|
||||
/// <param name="apiVersioningOptions">The API versioning options.</param>
|
||||
public OperationIdHandler(IOptions<ApiVersioningOptions> apiVersioningOptions)
|
||||
=> _apiVersioningOptions = apiVersioningOptions.Value;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public bool CanHandle(ApiDescription apiDescription)
|
||||
{
|
||||
if (apiDescription.ActionDescriptor is not ControllerActionDescriptor controllerActionDescriptor)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return CanHandle(apiDescription, controllerActionDescriptor);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether this handler can process the API description based on the controller namespace.
|
||||
/// </summary>
|
||||
/// <param name="apiDescription">The API description.</param>
|
||||
/// <param name="controllerActionDescriptor">The controller action descriptor.</param>
|
||||
/// <returns><c>true</c> if the controller is in an Umbraco.Cms.Api namespace; otherwise, <c>false</c>.</returns>
|
||||
protected virtual bool CanHandle(ApiDescription apiDescription, ControllerActionDescriptor controllerActionDescriptor)
|
||||
=> controllerActionDescriptor.ControllerTypeInfo.Namespace?.StartsWith("Umbraco.Cms.Api") is true;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public virtual string Handle(ApiDescription apiDescription)
|
||||
=> UmbracoOperationId(apiDescription);
|
||||
|
||||
/// <summary>
|
||||
/// Generates a unique operation identifier for a given API following Umbraco's operation id naming conventions.
|
||||
/// </summary>
|
||||
protected string UmbracoOperationId(ApiDescription apiDescription)
|
||||
{
|
||||
if (apiDescription.ActionDescriptor is not ControllerActionDescriptor controllerActionDescriptor)
|
||||
{
|
||||
throw new ArgumentException($"This handler operates only on {nameof(ControllerActionDescriptor)}.");
|
||||
}
|
||||
|
||||
ApiVersion defaultVersion = _apiVersioningOptions.DefaultApiVersion;
|
||||
var httpMethod = apiDescription.HttpMethod?.ToLower().ToFirstUpper() ?? "Get";
|
||||
|
||||
// if the route info "Name" is supplied we'll use this explicitly as the operation ID
|
||||
// - usage example: [HttpGet("my-api/route}", Name = "MyCustomRoute")]
|
||||
if (string.IsNullOrWhiteSpace(apiDescription.ActionDescriptor.AttributeRouteInfo?.Name) == false)
|
||||
{
|
||||
var explicitOperationId = apiDescription.ActionDescriptor.AttributeRouteInfo!.Name;
|
||||
return explicitOperationId.InvariantStartsWith(httpMethod)
|
||||
? explicitOperationId
|
||||
: $"{httpMethod}{explicitOperationId}";
|
||||
}
|
||||
|
||||
var relativePath = apiDescription.RelativePath;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(relativePath))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"There is no relative path for controller action {apiDescription.ActionDescriptor.RouteValues["controller"]}");
|
||||
}
|
||||
|
||||
// Remove the prefixed base path with version, e.g. /umbraco/management/api/v1/tracked-reference/{id} => tracked-reference/{id}
|
||||
var unprefixedRelativePath = OperationIdRegexes
|
||||
.VersionPrefixRegex()
|
||||
.Replace(relativePath, string.Empty);
|
||||
|
||||
// Remove template placeholders, e.g. tracked-reference/{id} => tracked-reference/Id
|
||||
var formattedOperationId = OperationIdRegexes
|
||||
.TemplatePlaceholdersRegex()
|
||||
.Replace(unprefixedRelativePath, m => $"By{m.Groups[1].Value.ToFirstUpper()}");
|
||||
|
||||
// Remove dashes (-) and slashes (/) and convert the following letter to uppercase with
|
||||
// the word "By" in front, e.g. tracked-reference/Id => TrackedReferenceById
|
||||
formattedOperationId = OperationIdRegexes
|
||||
.ToCamelCaseRegex()
|
||||
.Replace(formattedOperationId, m => m.Groups[1].Value.ToUpper());
|
||||
|
||||
// Get map to version attribute
|
||||
string? version = null;
|
||||
|
||||
var versionAttributeValue = controllerActionDescriptor.MethodInfo.GetMapToApiVersionAttributeValue();
|
||||
|
||||
// We only want to add a version, if it is not the default one.
|
||||
if (string.Equals(versionAttributeValue, defaultVersion.ToString()) == false)
|
||||
{
|
||||
version = versionAttributeValue;
|
||||
}
|
||||
|
||||
// Return the operation ID with the formatted http method verb in front, e.g. GetTrackedReferenceById
|
||||
return $"{httpMethod}{formattedOperationId.ToFirstUpper()}{version}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using Asp.Versioning;
|
||||
using Microsoft.AspNetCore.Mvc.ApiExplorer;
|
||||
|
||||
namespace Umbraco.Cms.Api.Common.OpenApi;
|
||||
|
||||
/// <summary>
|
||||
/// Selects an operation ID for an API description using registered handlers.
|
||||
/// </summary>
|
||||
public class OperationIdSelector : IOperationIdSelector
|
||||
{
|
||||
private readonly IEnumerable<IOperationIdHandler> _operationIdHandlers;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="OperationIdSelector"/> class.
|
||||
/// </summary>
|
||||
[Obsolete("Use non-obsolete constructor. Scheduled for removal in Umbraco 18.")]
|
||||
public OperationIdSelector()
|
||||
: this(Enumerable.Empty<IOperationIdHandler>())
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="OperationIdSelector"/> class.
|
||||
/// </summary>
|
||||
/// <param name="operationIdHandlers">The registered operation ID handlers.</param>
|
||||
public OperationIdSelector(IEnumerable<IOperationIdHandler> operationIdHandlers)
|
||||
=> _operationIdHandlers = operationIdHandlers;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public virtual string? OperationId(ApiDescription apiDescription)
|
||||
{
|
||||
IOperationIdHandler? handler = _operationIdHandlers.FirstOrDefault(h => h.CanHandle(apiDescription));
|
||||
return handler?.Handle(apiDescription);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using Microsoft.OpenApi;
|
||||
using Swashbuckle.AspNetCore.SwaggerGen;
|
||||
|
||||
namespace Umbraco.Cms.Api.Common.OpenApi;
|
||||
|
||||
/// <summary>
|
||||
/// This filter explicitly removes all security schemes from a named OpenAPI document.
|
||||
/// </summary>
|
||||
public class RemoveSecuritySchemesDocumentFilter : IDocumentFilter
|
||||
{
|
||||
private readonly string _documentName;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="RemoveSecuritySchemesDocumentFilter"/> class.
|
||||
/// </summary>
|
||||
/// <param name="documentName">The name of the OpenAPI document to filter.</param>
|
||||
public RemoveSecuritySchemesDocumentFilter(string documentName)
|
||||
=> _documentName = documentName;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Apply(OpenApiDocument swaggerDoc, DocumentFilterContext context)
|
||||
{
|
||||
if (context.DocumentName != _documentName)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
swaggerDoc.Components?.SecuritySchemes?.Clear();
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
using System.Reflection;
|
||||
using System.Text.Json.Serialization.Metadata;
|
||||
using Microsoft.AspNetCore.OpenApi;
|
||||
using Microsoft.OpenApi;
|
||||
|
||||
namespace Umbraco.Cms.Api.Common.OpenApi;
|
||||
|
||||
/// <summary>
|
||||
/// Ensures that all non-nullable properties are marked as required in the OpenAPI schema.
|
||||
/// </summary>
|
||||
/// <remarks>By default, only properties marked with the required keyword will actually show as required.
|
||||
/// Non-nullable reference types were not taken into account.</remarks>
|
||||
internal class RequireNonNullablePropertiesSchemaTransformer : IOpenApiSchemaTransformer
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
IEnumerable<string> additionalRequiredProps = schema.Properties?
|
||||
.Where(p => schema.Required?.Contains(p.Key) != true) // If it's already required, skip
|
||||
.Where(x => IsRequiredProperty(schema, context.JsonTypeInfo, x.Key))
|
||||
.Select(x => x.Key)
|
||||
?? [];
|
||||
schema.Required ??= new HashSet<string>();
|
||||
foreach (var propKey in additionalRequiredProps)
|
||||
{
|
||||
schema.Required.Add(propKey);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static bool IsRequiredProperty(OpenApiSchema schema, JsonTypeInfo jsonTypeInfo, string propertyName)
|
||||
{
|
||||
if (jsonTypeInfo.Properties.FirstOrDefault(p => p.Name == propertyName) is { } property)
|
||||
{
|
||||
return property.IsGetNullable is false;
|
||||
}
|
||||
|
||||
// If we can't find the property in the type (e.g. discriminator '$type'), use the schema type information.
|
||||
if (schema.Properties?.TryGetValue(propertyName, out IOpenApiSchema? schemaProperty) is true
|
||||
&& schemaProperty?.Type is { } propertyType)
|
||||
{
|
||||
return propertyType.HasFlag(JsonSchemaType.Null) is false;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Api.Common.OpenApi;
|
||||
|
||||
/// <summary>
|
||||
/// Default handler for generating OpenAPI schema IDs for Umbraco types.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Left unsealed on purpose, so it is extendable by consuming APIs.
|
||||
/// Adds "Model" suffix to avoid TypeScript name clashes and removes invalid characters.
|
||||
/// </remarks>
|
||||
public class SchemaIdHandler : ISchemaIdHandler
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public virtual bool CanHandle(Type type)
|
||||
=> type.Namespace?.StartsWith("Umbraco.Cms") is true;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public virtual string Handle(Type type)
|
||||
=> UmbracoSchemaId(type);
|
||||
|
||||
/// <summary>
|
||||
/// Generates a sanitized and consistent schema identifier for a given type following Umbraco's schema id naming conventions.
|
||||
/// </summary>
|
||||
protected string UmbracoSchemaId(Type type)
|
||||
{
|
||||
var name = SanitizedTypeName(type);
|
||||
|
||||
name = HandleGenerics(name, type);
|
||||
|
||||
if (name.EndsWith("Model") == false)
|
||||
{
|
||||
// because some models names clash with common classes in TypeScript (i.e. Document),
|
||||
// we need to add a "Model" postfix to all models
|
||||
name = $"{name}Model";
|
||||
}
|
||||
|
||||
// make absolutely sure we don't pass any invalid named by removing all non-word chars
|
||||
return Regex.Replace(name, @"[^\w]", string.Empty);
|
||||
}
|
||||
|
||||
private string SanitizedTypeName(Type t) => t.Name
|
||||
// first grab the "non-generic" part of any generic type name (i.e. "PagedViewModel`1" becomes "PagedViewModel")
|
||||
.Split('`').First()
|
||||
// then remove the "ViewModel" postfix from type names
|
||||
.TrimEnd("ViewModel");
|
||||
|
||||
private string HandleGenerics(string name, Type type)
|
||||
{
|
||||
if (!type.IsGenericType)
|
||||
{
|
||||
return name;
|
||||
}
|
||||
|
||||
// use attribute custom name or append the generic type names, ultimately turning i.e. "PagedViewModel<RelationItemViewModel>" into "PagedRelationItem"
|
||||
return $"{name}{string.Join(string.Empty, type.GenericTypeArguments.Select(SanitizedTypeName))}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace Umbraco.Cms.Api.Common.OpenApi;
|
||||
|
||||
/// <summary>
|
||||
/// Selects a schema ID for a type using registered handlers.
|
||||
/// </summary>
|
||||
public class SchemaIdSelector : ISchemaIdSelector
|
||||
{
|
||||
private readonly IEnumerable<ISchemaIdHandler> _schemaIdHandlers;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SchemaIdSelector"/> class.
|
||||
/// </summary>
|
||||
/// <param name="schemaIdHandlers">The registered schema ID handlers.</param>
|
||||
public SchemaIdSelector(IEnumerable<ISchemaIdHandler> schemaIdHandlers)
|
||||
=> _schemaIdHandlers = schemaIdHandlers;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public virtual string SchemaId(Type type)
|
||||
{
|
||||
ISchemaIdHandler? handler = _schemaIdHandlers.FirstOrDefault(h => h.CanHandle(type));
|
||||
return handler?.Handle(type) ?? type.Name;
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
using Microsoft.AspNetCore.OpenApi;
|
||||
using Microsoft.OpenApi;
|
||||
|
||||
namespace Umbraco.Cms.Api.Common.OpenApi;
|
||||
|
||||
/// <summary>
|
||||
/// Transforms the OpenAPI document to sort tags and paths alphabetically.
|
||||
/// </summary>
|
||||
internal class SortTagsAndPathsTransformer : IOpenApiDocumentTransformer
|
||||
{
|
||||
/// <summary>
|
||||
/// Transforms the specified OpenAPI document to sort its tags and paths alphabetically.
|
||||
/// </summary>
|
||||
/// <param name="document">The <see cref="OpenApiDocument"/> to modify.</param>
|
||||
/// <param name="context">The <see cref="OpenApiDocumentTransformerContext"/> associated with the <paramref name="document"/>.</param>
|
||||
/// <param name="cancellationToken">The cancellation token to use.</param>
|
||||
/// <returns>The task object representing the asynchronous operation.</returns>
|
||||
public Task TransformAsync(
|
||||
OpenApiDocument document,
|
||||
OpenApiDocumentTransformerContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
document.Tags = new SortedSet<OpenApiTag>(
|
||||
document.Tags ?? Enumerable.Empty<OpenApiTag>(),
|
||||
Comparer<OpenApiTag>.Create((a, b) => string.Compare(a.Name, b.Name, StringComparison.Ordinal)));
|
||||
|
||||
var sortedPaths = new OpenApiPaths();
|
||||
foreach (KeyValuePair<string, IOpenApiPathItem> keyValuePair in document.Paths
|
||||
.OrderBy(x => x.Value.Operations?.Values
|
||||
.SelectMany(op => op.Tags ?? Enumerable.Empty<OpenApiTagReference>())
|
||||
.OrderBy(t => t.Name)
|
||||
.FirstOrDefault()?
|
||||
.Name)
|
||||
.ThenBy(x => x.Key))
|
||||
{
|
||||
sortedPaths.Add(keyValuePair.Key, keyValuePair.Value);
|
||||
}
|
||||
|
||||
document.Paths = sortedPaths;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using Umbraco.Cms.Api.Common.Serialization;
|
||||
|
||||
namespace Umbraco.Cms.Api.Common.OpenApi;
|
||||
|
||||
/// <summary>
|
||||
/// Default handler for discovering sub-types for polymorphic OpenAPI schemas.
|
||||
/// </summary>
|
||||
public class SubTypesHandler : ISubTypesHandler
|
||||
{
|
||||
private readonly IUmbracoJsonTypeInfoResolver _umbracoJsonTypeInfoResolver;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SubTypesHandler"/> class.
|
||||
/// </summary>
|
||||
/// <param name="umbracoJsonTypeInfoResolver">The JSON type info resolver for finding sub-types.</param>
|
||||
public SubTypesHandler(IUmbracoJsonTypeInfoResolver umbracoJsonTypeInfoResolver)
|
||||
=> _umbracoJsonTypeInfoResolver = umbracoJsonTypeInfoResolver;
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether this handler can process the specified type based on namespace.
|
||||
/// </summary>
|
||||
/// <param name="type">The type to check.</param>
|
||||
/// <returns><c>true</c> if the type is in an Umbraco.Cms namespace; otherwise, <c>false</c>.</returns>
|
||||
protected virtual bool CanHandle(Type type)
|
||||
=> type.Namespace?.StartsWith("Umbraco.Cms") is true;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public virtual bool CanHandle(Type type, string documentName)
|
||||
=> CanHandle(type);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public virtual IEnumerable<Type> Handle(Type type)
|
||||
=> _umbracoJsonTypeInfoResolver.FindSubTypes(type);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Umbraco.Cms.Api.Common.Serialization;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Configuration.Models;
|
||||
using Umbraco.Cms.Core.Hosting;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Api.Common.OpenApi;
|
||||
|
||||
/// <summary>
|
||||
/// Selects sub-types for polymorphic OpenAPI schemas using registered handlers.
|
||||
/// </summary>
|
||||
public class SubTypesSelector : ISubTypesSelector
|
||||
{
|
||||
private readonly IHostingEnvironment _hostingEnvironment;
|
||||
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||
private readonly IEnumerable<ISubTypesHandler> _subTypeHandlers;
|
||||
private readonly IUmbracoJsonTypeInfoResolver _umbracoJsonTypeInfoResolver;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SubTypesSelector"/> class.
|
||||
/// </summary>
|
||||
/// <param name="hostingEnvironment">The hosting environment.</param>
|
||||
/// <param name="httpContextAccessor">The HTTP context accessor.</param>
|
||||
/// <param name="subTypeHandlers">The registered sub-type handlers.</param>
|
||||
/// <param name="umbracoJsonTypeInfoResolver">The JSON type info resolver for finding sub-types.</param>
|
||||
public SubTypesSelector(
|
||||
IHostingEnvironment hostingEnvironment,
|
||||
IHttpContextAccessor httpContextAccessor,
|
||||
IEnumerable<ISubTypesHandler> subTypeHandlers,
|
||||
IUmbracoJsonTypeInfoResolver umbracoJsonTypeInfoResolver)
|
||||
{
|
||||
_hostingEnvironment = hostingEnvironment;
|
||||
_httpContextAccessor = httpContextAccessor;
|
||||
_subTypeHandlers = subTypeHandlers;
|
||||
_umbracoJsonTypeInfoResolver = umbracoJsonTypeInfoResolver;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IEnumerable<Type> SubTypes(Type type)
|
||||
{
|
||||
var backOfficePath = _hostingEnvironment.GetBackOfficePath();
|
||||
var swaggerPath = $"{backOfficePath}/swagger";
|
||||
|
||||
if (_httpContextAccessor.HttpContext?.Request.Path.StartsWithSegments(swaggerPath) ?? false)
|
||||
{
|
||||
// Split the path into segments
|
||||
var segments = _httpContextAccessor.HttpContext.Request.Path.Value![swaggerPath.Length..]
|
||||
.TrimStart(Constants.CharArrays.ForwardSlash)
|
||||
.Split(Constants.CharArrays.ForwardSlash);
|
||||
|
||||
// Extract the document name from the path
|
||||
var documentName = segments[0];
|
||||
|
||||
// Find the first handler that can handle the type / document name combination
|
||||
ISubTypesHandler? handler = _subTypeHandlers.FirstOrDefault(h => h.CanHandle(type, documentName));
|
||||
if (handler != null)
|
||||
{
|
||||
return handler.Handle(type);
|
||||
}
|
||||
}
|
||||
|
||||
// Default implementation to maintain backwards compatibility
|
||||
return _umbracoJsonTypeInfoResolver.FindSubTypes(type);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.OpenApi;
|
||||
using Swashbuckle.AspNetCore.SwaggerGen;
|
||||
using Swashbuckle.AspNetCore.SwaggerUI;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Hosting;
|
||||
using Umbraco.Cms.Web.Common.ApplicationBuilder;
|
||||
using IHostingEnvironment = Umbraco.Cms.Core.Hosting.IHostingEnvironment;
|
||||
|
||||
namespace Umbraco.Cms.Api.Common.OpenApi;
|
||||
|
||||
/// <summary>
|
||||
/// Pipeline filter that configures Swagger/OpenAPI endpoints for Umbraco APIs.
|
||||
/// </summary>
|
||||
public class SwaggerRouteTemplatePipelineFilter : UmbracoPipelineFilter
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SwaggerRouteTemplatePipelineFilter"/> class.
|
||||
/// </summary>
|
||||
/// <param name="name">The name of the pipeline filter.</param>
|
||||
public SwaggerRouteTemplatePipelineFilter(string name)
|
||||
: base(name)
|
||||
=> PostPipeline = PostPipelineAction;
|
||||
|
||||
private void PostPipelineAction(IApplicationBuilder applicationBuilder)
|
||||
{
|
||||
if (SwaggerIsEnabled(applicationBuilder) is false)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
IOptions<SwaggerGenOptions> swaggerGenOptions = applicationBuilder.ApplicationServices.GetRequiredService<IOptions<SwaggerGenOptions>>();
|
||||
|
||||
applicationBuilder.UseSwagger(swaggerOptions =>
|
||||
{
|
||||
swaggerOptions.RouteTemplate = SwaggerRouteTemplate(applicationBuilder);
|
||||
});
|
||||
|
||||
applicationBuilder.UseSwaggerUI(swaggerUiOptions => SwaggerUiConfiguration(swaggerUiOptions, swaggerGenOptions.Value, applicationBuilder));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether Swagger is enabled for the application.
|
||||
/// </summary>
|
||||
/// <param name="applicationBuilder">The application builder.</param>
|
||||
/// <returns><c>true</c> if Swagger is enabled; otherwise, <c>false</c>.</returns>
|
||||
protected virtual bool SwaggerIsEnabled(IApplicationBuilder applicationBuilder)
|
||||
=> applicationBuilder.ApplicationServices.GetRequiredService<IWebHostEnvironment>().IsProduction() is false;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the route template for Swagger JSON endpoints.
|
||||
/// </summary>
|
||||
/// <param name="applicationBuilder">The application builder.</param>
|
||||
/// <returns>The Swagger route template.</returns>
|
||||
protected virtual string SwaggerRouteTemplate(IApplicationBuilder applicationBuilder)
|
||||
=> $"{GetBackOfficePath(applicationBuilder).TrimStart(Constants.CharArrays.ForwardSlash)}/swagger/{{documentName}}/swagger.json";
|
||||
|
||||
/// <summary>
|
||||
/// Gets the route prefix for the Swagger UI.
|
||||
/// </summary>
|
||||
/// <param name="applicationBuilder">The application builder.</param>
|
||||
/// <returns>The Swagger UI route prefix.</returns>
|
||||
protected virtual string SwaggerUiRoutePrefix(IApplicationBuilder applicationBuilder)
|
||||
=> $"{GetBackOfficePath(applicationBuilder).TrimStart(Constants.CharArrays.ForwardSlash)}/swagger";
|
||||
|
||||
/// <summary>
|
||||
/// Configures the Swagger UI options.
|
||||
/// </summary>
|
||||
/// <param name="swaggerUiOptions">The Swagger UI options to configure.</param>
|
||||
/// <param name="swaggerGenOptions">The Swagger generation options.</param>
|
||||
/// <param name="applicationBuilder">The application builder.</param>
|
||||
protected virtual void SwaggerUiConfiguration(
|
||||
SwaggerUIOptions swaggerUiOptions,
|
||||
SwaggerGenOptions swaggerGenOptions,
|
||||
IApplicationBuilder applicationBuilder)
|
||||
{
|
||||
swaggerUiOptions.RoutePrefix = SwaggerUiRoutePrefix(applicationBuilder);
|
||||
|
||||
foreach ((var name, OpenApiInfo? apiInfo) in swaggerGenOptions.SwaggerGeneratorOptions.SwaggerDocs.OrderBy(x => x.Value.Title))
|
||||
{
|
||||
swaggerUiOptions.SwaggerEndpoint($"{name}/swagger.json", $"{apiInfo.Title}");
|
||||
}
|
||||
|
||||
// Add custom configuration from https://swagger.io/docs/open-source-tools/swagger-ui/usage/configuration/
|
||||
swaggerUiOptions.ConfigObject.PersistAuthorization = true; // persists authorization data so it would not be lost on browser close/refresh
|
||||
swaggerUiOptions.ConfigObject.Filter = string.Empty; // Enable the filter with an empty string as default filter.
|
||||
|
||||
swaggerUiOptions.OAuthClientId(Constants.OAuthClientIds.Swagger);
|
||||
swaggerUiOptions.OAuthUsePkce();
|
||||
}
|
||||
|
||||
private string GetBackOfficePath(IApplicationBuilder applicationBuilder)
|
||||
=> applicationBuilder.ApplicationServices.GetRequiredService<IHostingEnvironment>().GetBackOfficePath();
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
using Microsoft.AspNetCore.OpenApi;
|
||||
using Microsoft.OpenApi;
|
||||
|
||||
namespace Umbraco.Cms.Api.Common.OpenApi;
|
||||
|
||||
/// <summary>
|
||||
/// Transformer that tags operations based on their group name.
|
||||
/// </summary>
|
||||
internal class TagActionsByGroupNameTransformer : IOpenApiOperationTransformer, IOpenApiDocumentTransformer
|
||||
{
|
||||
/// <summary>
|
||||
/// Transforms the specified OpenAPI operation in order to tag it by its group name.
|
||||
/// </summary>
|
||||
/// <param name="operation">The <see cref="OpenApiOperation"/> to modify.</param>
|
||||
/// <param name="context">The <see cref="OpenApiOperationTransformerContext"/> associated with the <paramref name="operation"/>.</param>
|
||||
/// <param name="cancellationToken">The cancellation token to use.</param>
|
||||
/// <returns>The task object representing the asynchronous operation.</returns>
|
||||
public Task TransformAsync(
|
||||
OpenApiOperation operation,
|
||||
OpenApiOperationTransformerContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (context.Document is null || context.Description.GroupName is not { } groupName)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
operation.Tags = new HashSet<OpenApiTagReference> { new(groupName) };
|
||||
if (context.Document.Tags?.Any(t => t.Name == groupName) == true)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
context.Document.Tags ??= new HashSet<OpenApiTag>();
|
||||
context.Document.Tags.Add(new OpenApiTag { Name = groupName });
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Transforms the specified OpenAPI document in order to clean up unused tags.
|
||||
/// </summary>
|
||||
/// <param name="document">The <see cref="OpenApiDocument"/> to modify.</param>
|
||||
/// <param name="context">The <see cref="OpenApiDocumentTransformerContext"/> associated with the <paramref name="document"/>.</param>
|
||||
/// <param name="cancellationToken">The cancellation token to use.</param>
|
||||
/// <returns>The task object representing the asynchronous operation.</returns>
|
||||
public Task TransformAsync(
|
||||
OpenApiDocument document,
|
||||
OpenApiDocumentTransformerContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var usedTags = new HashSet<string?>(document.Paths
|
||||
.SelectMany(p => (p.Value.Operations ?? []).Values)
|
||||
.SelectMany(o => o.Tags ?? new HashSet<OpenApiTagReference>())
|
||||
.Select(t => t.Name));
|
||||
|
||||
var tagsToRemove = (document.Tags ?? Enumerable.Empty<OpenApiTag>())
|
||||
.Where(tag => usedTags.Contains(tag.Name) is false)
|
||||
.ToList();
|
||||
|
||||
foreach (OpenApiTag tag in tagsToRemove)
|
||||
{
|
||||
document.Tags?.Remove(tag);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
using Umbraco.Cms.Core.DependencyInjection;
|
||||
|
||||
namespace Umbraco.Cms.Api.Common.OpenApi;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for <see cref="IUmbracoBuilder"/> to register custom OpenAPI documents.
|
||||
/// </summary>
|
||||
public static class UmbracoBuilderOpenApiExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Registers a custom OpenAPI document with Umbraco's defaults applied.
|
||||
/// </summary>
|
||||
/// <param name="builder">The Umbraco builder.</param>
|
||||
/// <param name="documentName">The document name. Matches the <c>[MapToApi]</c> value on controllers to include.</param>
|
||||
/// <param name="configure">Optional callback to customize the document.</param>
|
||||
/// <returns>The same <see cref="IUmbracoBuilder"/> for chaining.</returns>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The following defaults are applied to the document and can be customized or overridden via the
|
||||
/// <paramref name="configure"/> callback:
|
||||
/// </para>
|
||||
/// <list type="bullet">
|
||||
/// <item>
|
||||
/// <description>
|
||||
/// Endpoints are filtered by <c>[MapToApi(documentName)]</c>; only matching endpoints appear in the document.
|
||||
/// </description>
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <description>
|
||||
/// Schema reference IDs are generated by <see cref="UmbracoSchemaIdGenerator.CreateSchemaReferenceId"/>, applying
|
||||
/// Umbraco naming conventions to types under the <c>Umbraco.Cms</c> namespace and falling back to the framework
|
||||
/// default for everything else. Register your own <c>CreateSchemaReferenceId</c> delegate via
|
||||
/// <see cref="BackOfficeOpenApiDocumentBuilder.ConfigureOpenApiOptions"/> to override.
|
||||
/// </description>
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <description>
|
||||
/// Operation IDs are generated by <see cref="UmbracoOperationIdTransformer"/>. Register your own
|
||||
/// <see cref="Microsoft.AspNetCore.OpenApi.IOpenApiOperationTransformer"/> via
|
||||
/// <see cref="BackOfficeOpenApiDocumentBuilder.ConfigureOpenApiOptions"/> to override.
|
||||
/// </description>
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <description>
|
||||
/// Operations are tagged by their controller's API group name, and the resulting tags and paths are sorted
|
||||
/// for stable, diffable document output.
|
||||
/// </description>
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <description>
|
||||
/// Redundant JSON-equivalent media types (such as <c>text/json</c>, <c>application/*+json</c>, and
|
||||
/// <c>text/plain</c>) are stripped from request and response content when <c>application/json</c> is present,
|
||||
/// so the document doesn't list spurious media types that ASP.NET Core adds by default.
|
||||
/// </description>
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <description>
|
||||
/// Non-nullable properties are marked as <c>required</c> in the schema so generated client SDKs reflect
|
||||
/// C# nullability. Override via <see cref="BackOfficeOpenApiDocumentBuilder.ConfigureOpenApiOptions"/>
|
||||
/// if your types don't follow this convention.
|
||||
/// </description>
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <description>
|
||||
/// The document is registered in the OpenAPI UI document selector dropdown. Call
|
||||
/// <see cref="BackOfficeOpenApiDocumentBuilder.ExcludeFromUi"/> to opt out.
|
||||
/// </description>
|
||||
/// </item>
|
||||
/// </list>
|
||||
/// </remarks>
|
||||
public static IUmbracoBuilder AddBackOfficeOpenApiDocument(
|
||||
this IUmbracoBuilder builder,
|
||||
string documentName,
|
||||
Action<BackOfficeOpenApiDocumentBuilder>? configure = null)
|
||||
{
|
||||
var documentBuilder = new BackOfficeOpenApiDocumentBuilder(documentName);
|
||||
configure?.Invoke(documentBuilder);
|
||||
documentBuilder.Build(builder);
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
namespace Umbraco.Cms.Api.Common.OpenApi;
|
||||
|
||||
/// <summary>
|
||||
/// Options for configuring OpenAPI documents and UI.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// These options are populated by <c>AddUmbracoOpenApi</c> during DI configuration, which resolves the back-office path
|
||||
/// from <see cref="Core.Hosting.IHostingEnvironment"/> and sets the default values for
|
||||
/// <see cref="RouteTemplate"/> and <see cref="UiRoutePrefix"/>. Consumers that read this options type before
|
||||
/// <c>AddUmbracoOpenApi</c> has run will observe the uninitialised defaults (empty strings for the route properties).
|
||||
/// </remarks>
|
||||
public class UmbracoOpenApiOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets whether OpenAPI documents are enabled.
|
||||
/// Configured to <c>true</c> in non-production environments by default; <c>false</c> until configured.
|
||||
/// This avoids exposing API structure on public-facing websites.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether the default OpenAPI UI is enabled.
|
||||
/// Only applies when <see cref="Enabled"/> is true.
|
||||
/// Set to false to disable the default UI while keeping OpenAPI documents available,
|
||||
/// allowing you to use an alternative UI.
|
||||
/// Default: true.
|
||||
/// </summary>
|
||||
public bool DefaultUiEnabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the route template for OpenAPI JSON documents.
|
||||
/// Use <c>{documentName}</c> as a placeholder for the document name.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Populated by <c>AddUmbracoOpenApi</c> to <c>"{backOfficePath}/openapi/{documentName}.json"</c>. The initial
|
||||
/// <see cref="string.Empty"/> default is a sentinel for "not yet configured" — it is not a usable route template.
|
||||
/// </remarks>
|
||||
public string RouteTemplate { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the route prefix for OpenAPI UI.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Populated by <c>AddUmbracoOpenApi</c> to <c>"{backOfficePath}/openapi"</c>. The initial <see cref="string.Empty"/>
|
||||
/// default is a sentinel for "not yet configured" — it is not a usable route prefix.
|
||||
/// </remarks>
|
||||
public string UiRoutePrefix { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
using Asp.Versioning;
|
||||
using Microsoft.AspNetCore.Mvc.ApiExplorer;
|
||||
using Microsoft.AspNetCore.Mvc.Controllers;
|
||||
using Microsoft.AspNetCore.OpenApi;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.OpenApi;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Api.Common.OpenApi;
|
||||
|
||||
/// <summary>
|
||||
/// Transforms OpenAPI operation IDs using Umbraco's naming conventions.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This transformer can be registered manually for custom OpenAPI configurations.
|
||||
/// </remarks>
|
||||
public class UmbracoOperationIdTransformer : IOpenApiOperationTransformer
|
||||
{
|
||||
/// <summary>
|
||||
/// Transforms the specified OpenAPI operation, setting its operation ID using a custom selector.
|
||||
/// </summary>
|
||||
/// <param name="operation">The <see cref="OpenApiOperation"/> to modify.</param>
|
||||
/// <param name="context">The <see cref="OpenApiOperationTransformerContext"/> associated with the <paramref name="operation"/>.</param>
|
||||
/// <param name="cancellationToken">The cancellation token to use.</param>
|
||||
/// <returns>The task object representing the asynchronous operation.</returns>
|
||||
public Task TransformAsync(
|
||||
OpenApiOperation operation,
|
||||
OpenApiOperationTransformerContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var operationId = GenerateOperationId(context);
|
||||
if (operationId is not null)
|
||||
{
|
||||
operation.OperationId = operationId;
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static string? GenerateOperationId(OpenApiOperationTransformerContext context)
|
||||
{
|
||||
ApiDescription apiDescription = context.Description;
|
||||
if (apiDescription.ActionDescriptor is not ControllerActionDescriptor controllerActionDescriptor)
|
||||
{
|
||||
// Minimal APIs and other non-MVC endpoints don't carry a ControllerActionDescriptor; leave their
|
||||
// operation ID untouched so the framework's default applies.
|
||||
return null;
|
||||
}
|
||||
|
||||
ApiVersion defaultVersion = context.ApplicationServices.GetRequiredService<IOptions<ApiVersioningOptions>>().Value.DefaultApiVersion;
|
||||
var httpMethod = apiDescription.HttpMethod?.ToLower().ToFirstUpper() ?? "Get";
|
||||
|
||||
// if the route info "Name" is supplied we'll use this explicitly as the operation ID
|
||||
// - usage example: [HttpGet("my-api/route}", Name = "MyCustomRoute")]
|
||||
if (string.IsNullOrWhiteSpace(apiDescription.ActionDescriptor.AttributeRouteInfo?.Name) == false)
|
||||
{
|
||||
var explicitOperationId = apiDescription.ActionDescriptor.AttributeRouteInfo!.Name;
|
||||
return explicitOperationId.InvariantStartsWith(httpMethod)
|
||||
? explicitOperationId
|
||||
: $"{httpMethod}{explicitOperationId}";
|
||||
}
|
||||
|
||||
var relativePath = apiDescription.RelativePath;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(relativePath))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"There is no relative path for controller action {apiDescription.ActionDescriptor.RouteValues["controller"]}");
|
||||
}
|
||||
|
||||
// Remove the prefixed base path with version, e.g. /umbraco/management/api/v1/tracked-reference/{id} => tracked-reference/{id}
|
||||
var unprefixedRelativePath = OperationIdRegexes
|
||||
.VersionPrefixRegex()
|
||||
.Replace(relativePath, string.Empty);
|
||||
|
||||
// Remove template placeholders, e.g. tracked-reference/{id} => tracked-reference/Id
|
||||
var formattedOperationId = OperationIdRegexes
|
||||
.TemplatePlaceholdersRegex()
|
||||
.Replace(unprefixedRelativePath, m => $"By{m.Groups[1].Value.ToFirstUpper()}");
|
||||
|
||||
// Remove dashes (-) and slashes (/) and convert the following letter to uppercase with
|
||||
// the word "By" in front, e.g. tracked-reference/Id => TrackedReferenceById
|
||||
formattedOperationId = OperationIdRegexes
|
||||
.ToCamelCaseRegex()
|
||||
.Replace(formattedOperationId, m => m.Groups[1].Value.ToUpper());
|
||||
|
||||
// Get map to version attribute
|
||||
string? version = null;
|
||||
|
||||
var versionAttributeValue = controllerActionDescriptor.MethodInfo.GetMapToApiVersionAttributeValue();
|
||||
|
||||
// We only want to add a version, if it is not the default one.
|
||||
if (string.Equals(versionAttributeValue, defaultVersion.ToString()) == false)
|
||||
{
|
||||
version = versionAttributeValue;
|
||||
}
|
||||
|
||||
// Return the operation ID with the formatted http method verb in front, e.g. GetTrackedReferenceById
|
||||
return $"{httpMethod}{formattedOperationId.ToFirstUpper()}{version}";
|
||||
}
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
using System.Text.Json.Serialization.Metadata;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.AspNetCore.OpenApi;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Api.Common.OpenApi;
|
||||
|
||||
/// <summary>
|
||||
/// Static utility for generating OpenAPI schema IDs following Umbraco's naming conventions.
|
||||
/// </summary>
|
||||
public static class UmbracoSchemaIdGenerator
|
||||
{
|
||||
/// <summary>
|
||||
/// Generates a schema ID for the specified type following Umbraco's naming conventions.
|
||||
/// </summary>
|
||||
/// <param name="type">The type to generate a schema ID for.</param>
|
||||
/// <returns>The generated schema ID.</returns>
|
||||
public static string Generate(Type type)
|
||||
{
|
||||
var name = SanitizedTypeName(type);
|
||||
name = HandleGenerics(name, type);
|
||||
|
||||
if (name.EndsWith("Model") == false)
|
||||
{
|
||||
// because some models names clash with common classes in TypeScript (i.e. Document),
|
||||
// we need to add a "Model" postfix to all models
|
||||
name = $"{name}Model";
|
||||
}
|
||||
|
||||
// make absolutely sure we don't pass any invalid named by removing all non-word chars
|
||||
return Regex.Replace(name, @"[^\w]", string.Empty);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a schema reference ID for the given JSON type info, applying Umbraco's naming conventions to
|
||||
/// types in the <c>Umbraco.Cms</c> namespace and falling back to the framework default for other types.
|
||||
/// </summary>
|
||||
/// <param name="jsonTypeInfo">The JSON type info to create a schema reference ID for.</param>
|
||||
/// <returns>The schema reference ID, or <c>null</c> if the type should be inlined.</returns>
|
||||
internal static string? CreateSchemaReferenceId(JsonTypeInfo jsonTypeInfo)
|
||||
{
|
||||
// Ensure that only types that would normally be included in the schema generation are given a schema reference ID.
|
||||
// Otherwise, we should return null to inline them.
|
||||
var defaultSchemaReferenceId = OpenApiOptions.CreateDefaultSchemaReferenceId(jsonTypeInfo);
|
||||
if (defaultSchemaReferenceId is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
Type targetType = Nullable.GetUnderlyingType(jsonTypeInfo.Type) ?? jsonTypeInfo.Type;
|
||||
|
||||
if (targetType.Namespace?.StartsWith("Umbraco.Cms") is not true)
|
||||
{
|
||||
return defaultSchemaReferenceId;
|
||||
}
|
||||
|
||||
return Generate(targetType);
|
||||
}
|
||||
|
||||
private static string SanitizedTypeName(Type t) => t.Name
|
||||
// first grab the "non-generic" part of any generic type name (i.e. "PagedViewModel`1" becomes "PagedViewModel")
|
||||
.Split('`').First()
|
||||
// then remove the "ViewModel" postfix from type names
|
||||
.TrimEnd("ViewModel");
|
||||
|
||||
private static string HandleGenerics(string name, Type type)
|
||||
{
|
||||
if (!type.IsGenericType)
|
||||
{
|
||||
return name;
|
||||
}
|
||||
|
||||
// use attribute custom name or append the generic type names, ultimately turning i.e. "PagedViewModel<RelationItemViewModel>" into "PagedRelationItem"
|
||||
return $"{name}{string.Join(string.Empty, type.GenericTypeArguments.Select(SanitizedTypeName))}";
|
||||
}
|
||||
}
|
||||
+2
-3
@@ -3,7 +3,6 @@ using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Options;
|
||||
using OpenIddict.Server;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Configuration.Models;
|
||||
using Umbraco.Cms.Core.Security;
|
||||
using Umbraco.Extensions;
|
||||
@@ -33,12 +32,12 @@ public class ExposeBackOfficeAuthenticationOpenIddictServerEventsHandler : IOpen
|
||||
|
||||
// These are the type identifiers for the claims required by the principal
|
||||
// for the custom authentication scheme.
|
||||
// We make available the ID and user name claims, plus the claim necessary for parsing the user key.
|
||||
// We make available the ID, user name and allowed applications (sections) claims.
|
||||
_claimTypes =
|
||||
[
|
||||
backOfficeIdentityOptions.Value.ClaimsIdentity.UserIdClaimType,
|
||||
backOfficeIdentityOptions.Value.ClaimsIdentity.UserNameClaimType,
|
||||
Constants.Security.OpenIdDictSubClaimType
|
||||
Core.Constants.Security.AllowedApplicationsClaimType,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -8,12 +8,6 @@
|
||||
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
|
||||
<_Parameter1>Umbraco.Tests.UnitTests</_Parameter1>
|
||||
</AssemblyAttribute>
|
||||
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
|
||||
<_Parameter1>Umbraco.Cms.Api.Management</_Parameter1>
|
||||
</AssemblyAttribute>
|
||||
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
|
||||
<_Parameter1>Umbraco.Cms.Api.Delivery</_Parameter1>
|
||||
</AssemblyAttribute>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -21,12 +15,11 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Asp.Versioning.Mvc" />
|
||||
<PackageReference Include="Asp.Versioning.Mvc "/>
|
||||
<PackageReference Include="Asp.Versioning.Mvc.ApiExplorer" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" />
|
||||
<PackageReference Include="OpenIddict.Abstractions" />
|
||||
<PackageReference Include="OpenIddict.AspNetCore" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -39,7 +39,7 @@ Umbraco.Cms.Api.Delivery/
|
||||
├── Services/ # Business logic and query building
|
||||
├── Caching/ # Output cache policies
|
||||
├── Rendering/ # Output expansion strategies
|
||||
├── Configuration/ # OpenAPI configuration
|
||||
├── Configuration/ # Swagger configuration
|
||||
└── Filters/ # Action filters (access, validation)
|
||||
```
|
||||
|
||||
@@ -200,9 +200,10 @@ context.EnableOutputCaching = requestPreviewService.IsPreview() is false
|
||||
|
||||
### Technical Debt (TODOs in codebase)
|
||||
|
||||
1. **V1 Removal Pending** (2 locations):
|
||||
1. **V1 Removal Pending** (4 locations):
|
||||
- `DependencyInjection/UmbracoBuilderExtensions.cs:98` - FIXME: remove matcher policy
|
||||
- `Routing/DeliveryApiItemsEndpointsMatcherPolicy.cs:11` - FIXME: remove class
|
||||
- `Filters/SwaggerDocumentationFilterBase.cs:79,83` - FIXME: remove V1 swagger docs
|
||||
|
||||
2. **Obsolete Reference Warnings** (csproj:9-13):
|
||||
- `ASP0019` - IHeaderDictionary.Append usage
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Umbraco.Cms.Core.DeliveryApi;
|
||||
using Umbraco.Cms.Core.Models.PublishedContent;
|
||||
|
||||
namespace Umbraco.Cms.Api.Delivery.Caching;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of <see cref="IDeliveryApiOutputCacheRequestFilter"/> that prevents caching
|
||||
/// for preview mode requests and requests without public access.
|
||||
/// </summary>
|
||||
public class DefaultDeliveryApiOutputCacheRequestFilter : IDeliveryApiOutputCacheRequestFilter
|
||||
{
|
||||
private readonly IRequestPreviewService _requestPreviewService;
|
||||
private readonly IApiAccessService _apiAccessService;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DefaultDeliveryApiOutputCacheRequestFilter"/> class.
|
||||
/// </summary>
|
||||
/// <param name="requestPreviewService">The preview service.</param>
|
||||
/// <param name="apiAccessService">The API access service.</param>
|
||||
public DefaultDeliveryApiOutputCacheRequestFilter(IRequestPreviewService requestPreviewService, IApiAccessService apiAccessService)
|
||||
{
|
||||
_requestPreviewService = requestPreviewService;
|
||||
_apiAccessService = apiAccessService;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public virtual bool IsCacheable(HttpContext context)
|
||||
=> IsPreview() is false && HasPublicAccess();
|
||||
|
||||
/// <inheritdoc />
|
||||
public virtual bool IsCacheable(HttpContext context, IPublishedContent content) => true;
|
||||
|
||||
/// <summary>
|
||||
/// Returns <c>true</c> if the current request is a preview request; <c>false</c> if the request
|
||||
/// is not a preview and may be cached.
|
||||
/// </summary>
|
||||
protected virtual bool IsPreview()
|
||||
=> _requestPreviewService.IsPreview();
|
||||
|
||||
/// <summary>
|
||||
/// Returns <c>true</c> if the current request has public access; <c>false</c> if the request
|
||||
/// is not publicly accessible and should not be cached.
|
||||
/// </summary>
|
||||
protected virtual bool HasPublicAccess()
|
||||
=> _apiAccessService.HasPublicAccess();
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Cache;
|
||||
using Umbraco.Cms.Core.Models.PublishedContent;
|
||||
|
||||
namespace Umbraco.Cms.Api.Delivery.Caching;
|
||||
|
||||
/// <summary>
|
||||
/// Tags cached pages for delivery API output caching with their content type alias, enabling eviction by content type.
|
||||
/// </summary>
|
||||
internal sealed class DeliveryApiContentTypeOutputCacheTagProvider : IDeliveryApiOutputCacheTagProvider
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<string> GetTags(IPublishedContent content)
|
||||
{
|
||||
yield return Constants.DeliveryApi.OutputCache.ContentTypeTagPrefix + content.ContentType.Alias;
|
||||
}
|
||||
}
|
||||
-135
@@ -1,135 +0,0 @@
|
||||
using Microsoft.AspNetCore.OutputCaching;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Cache;
|
||||
using Umbraco.Cms.Core.Events;
|
||||
using Umbraco.Cms.Core.Notifications;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Core.Services.Changes;
|
||||
using Umbraco.Cms.Core.Sync;
|
||||
using Umbraco.Cms.Web.Common.Caching;
|
||||
|
||||
namespace Umbraco.Cms.Api.Delivery.Caching;
|
||||
|
||||
/// <summary>
|
||||
/// Handles <see cref="ContentCacheRefresherNotification"/> to evict Delivery API output cache entries
|
||||
/// when content is published, unpublished, moved, or deleted. Also evicts responses for content
|
||||
/// that references the changed content via picker properties (umbDocument relations).
|
||||
/// </summary>
|
||||
internal sealed class DeliveryApiDocumentOutputCacheEvictionHandler
|
||||
: RelationOutputCacheEvictionHandlerBase, INotificationAsyncHandler<ContentCacheRefresherNotification>
|
||||
{
|
||||
private readonly IEnumerable<IDeliveryApiOutputCacheEvictionProvider> _evictionProviders;
|
||||
private readonly ILogger<DeliveryApiDocumentOutputCacheEvictionHandler> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DeliveryApiDocumentOutputCacheEvictionHandler"/> class.
|
||||
/// </summary>
|
||||
/// <param name="outputCacheStore">The output cache store for evicting cached responses.</param>
|
||||
/// <param name="relationService">The relation service for querying entity references.</param>
|
||||
/// <param name="idKeyMap">The ID/key mapping service for converting between integer IDs and GUIDs.</param>
|
||||
/// <param name="evictionProviders">Custom eviction providers for additional tag-based eviction.</param>
|
||||
/// <param name="logger">The logger.</param>
|
||||
public DeliveryApiDocumentOutputCacheEvictionHandler(
|
||||
IOutputCacheStore outputCacheStore,
|
||||
IRelationService relationService,
|
||||
IIdKeyMap idKeyMap,
|
||||
IEnumerable<IDeliveryApiOutputCacheEvictionProvider> evictionProviders,
|
||||
ILogger<DeliveryApiDocumentOutputCacheEvictionHandler> logger)
|
||||
: base(outputCacheStore, relationService, idKeyMap)
|
||||
{
|
||||
_evictionProviders = evictionProviders;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task HandleAsync(ContentCacheRefresherNotification notification, CancellationToken cancellationToken)
|
||||
{
|
||||
if (notification.MessageType != MessageType.RefreshByPayload
|
||||
|| notification.MessageObject is not ContentCacheRefresher.JsonPayload[] payloads)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var changedEntityIds = new List<int>();
|
||||
|
||||
foreach (ContentCacheRefresher.JsonPayload payload in payloads)
|
||||
{
|
||||
if (payload.Blueprint)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
await EvictForPayloadAsync(payload, cancellationToken);
|
||||
changedEntityIds.Add(payload.Id);
|
||||
}
|
||||
|
||||
// Evict content that references the changed content via picker properties.
|
||||
await EvictRelatedContentAsync(
|
||||
changedEntityIds,
|
||||
Constants.Conventions.RelationTypes.RelatedDocumentAlias,
|
||||
Constants.DeliveryApi.OutputCache.ContentTagPrefix,
|
||||
_logger,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private async Task EvictForPayloadAsync(ContentCacheRefresher.JsonPayload payload, CancellationToken cancellationToken)
|
||||
{
|
||||
if (payload.ChangeTypes.HasFlag(TreeChangeTypes.RefreshAll))
|
||||
{
|
||||
// Evict all Delivery API responses — media responses may reference content via picker properties.
|
||||
_logger.LogDebug("Content refresh all — evicting all Delivery API output cache entries.");
|
||||
await OutputCacheStore.EvictByTagAsync(Constants.DeliveryApi.OutputCache.AllTag, cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.Key.HasValue is false)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Guid contentKey = payload.Key.Value;
|
||||
|
||||
if (_logger.IsEnabled(LogLevel.Debug))
|
||||
{
|
||||
_logger.LogDebug("Evicting Delivery API output cache for content {ContentKey}.", contentKey);
|
||||
}
|
||||
|
||||
await OutputCacheStore.EvictByTagAsync(Constants.DeliveryApi.OutputCache.ContentTagPrefix + contentKey, cancellationToken);
|
||||
|
||||
if (payload.ChangeTypes.HasFlag(TreeChangeTypes.RefreshBranch))
|
||||
{
|
||||
if (_logger.IsEnabled(LogLevel.Debug))
|
||||
{
|
||||
_logger.LogDebug("Evicting Delivery API output cache for descendants of {ContentKey}.", contentKey);
|
||||
}
|
||||
|
||||
await OutputCacheStore.EvictByTagAsync(Constants.DeliveryApi.OutputCache.AncestorTagPrefix + contentKey, cancellationToken);
|
||||
}
|
||||
|
||||
await InvokeCustomEvictionProvidersAsync(payload, contentKey, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task InvokeCustomEvictionProvidersAsync(ContentCacheRefresher.JsonPayload payload, Guid contentKey, CancellationToken cancellationToken)
|
||||
{
|
||||
var context = new OutputCacheContentChangedContext(
|
||||
payload.Id,
|
||||
contentKey,
|
||||
payload.PublishedCultures ?? [],
|
||||
payload.UnpublishedCultures ?? []);
|
||||
|
||||
foreach (IDeliveryApiOutputCacheEvictionProvider provider in _evictionProviders)
|
||||
{
|
||||
IEnumerable<string> additionalTags = await provider.GetAdditionalEvictionTagsAsync(context, cancellationToken);
|
||||
foreach (var tag in additionalTags)
|
||||
{
|
||||
if (_logger.IsEnabled(LogLevel.Debug))
|
||||
{
|
||||
_logger.LogDebug("Evicting Delivery API output cache tag {Tag} via custom provider.", tag);
|
||||
}
|
||||
|
||||
await OutputCacheStore.EvictByTagAsync(tag, cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
using Microsoft.AspNetCore.OutputCaching;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Cache;
|
||||
using Umbraco.Cms.Core.Events;
|
||||
using Umbraco.Cms.Core.Notifications;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Core.Services.Changes;
|
||||
using Umbraco.Cms.Core.Sync;
|
||||
using Umbraco.Cms.Web.Common.Caching;
|
||||
|
||||
namespace Umbraco.Cms.Api.Delivery.Caching;
|
||||
|
||||
/// <summary>
|
||||
/// Handles <see cref="ElementCacheRefresherNotification"/> to evict Delivery API output cache entries
|
||||
/// for content that references the changed element via picker properties (umbElement relations).
|
||||
/// </summary>
|
||||
internal sealed class DeliveryApiElementOutputCacheEvictionHandler
|
||||
: RelationOutputCacheEvictionHandlerBase, INotificationAsyncHandler<ElementCacheRefresherNotification>
|
||||
{
|
||||
private readonly ILogger<DeliveryApiElementOutputCacheEvictionHandler> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DeliveryApiElementOutputCacheEvictionHandler"/> class.
|
||||
/// </summary>
|
||||
/// <param name="outputCacheStore">The output cache store for evicting cached responses.</param>
|
||||
/// <param name="relationService">The relation service for querying entity references.</param>
|
||||
/// <param name="idKeyMap">The ID/key mapping service for converting between integer IDs and GUIDs.</param>
|
||||
/// <param name="logger">The logger.</param>
|
||||
public DeliveryApiElementOutputCacheEvictionHandler(
|
||||
IOutputCacheStore outputCacheStore,
|
||||
IRelationService relationService,
|
||||
IIdKeyMap idKeyMap,
|
||||
ILogger<DeliveryApiElementOutputCacheEvictionHandler> logger)
|
||||
: base(outputCacheStore, relationService, idKeyMap)
|
||||
=> _logger = logger;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task HandleAsync(ElementCacheRefresherNotification notification, CancellationToken cancellationToken)
|
||||
{
|
||||
if (notification.MessageType != MessageType.RefreshByPayload
|
||||
|| notification.MessageObject is not ElementCacheRefresher.JsonPayload[] payloads)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (ElementCacheRefresher.JsonPayload payload in payloads)
|
||||
{
|
||||
if (payload.ChangeTypes.HasFlag(TreeChangeTypes.RefreshAll))
|
||||
{
|
||||
// Evict all Delivery API responses — content responses may include referenced elements,
|
||||
// so evicting only element-related entries would leave stale element references in content responses.
|
||||
_logger.LogDebug("Element refresh all — evicting all Delivery API output cache entries.");
|
||||
await OutputCacheStore.EvictByTagAsync(Constants.DeliveryApi.OutputCache.AllTag, cancellationToken);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Evict content that references the changed elements via picker properties.
|
||||
await EvictRelatedContentAsync(
|
||||
payloads.Select(p => p.Id),
|
||||
Constants.Conventions.RelationTypes.RelatedElementAlias,
|
||||
Constants.DeliveryApi.OutputCache.ContentTagPrefix,
|
||||
_logger,
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
using Microsoft.AspNetCore.OutputCaching;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Cache;
|
||||
using Umbraco.Cms.Core.Events;
|
||||
using Umbraco.Cms.Core.Notifications;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Core.Services.Changes;
|
||||
using Umbraco.Cms.Core.Sync;
|
||||
using Umbraco.Cms.Web.Common.Caching;
|
||||
|
||||
namespace Umbraco.Cms.Api.Delivery.Caching;
|
||||
|
||||
/// <summary>
|
||||
/// Handles <see cref="MediaCacheRefresherNotification"/> to evict Delivery API output cache entries
|
||||
/// when media is created, updated, or deleted. Also evicts content responses that reference
|
||||
/// the changed media via picker properties (umbMedia relations).
|
||||
/// </summary>
|
||||
internal sealed class DeliveryApiMediaOutputCacheEvictionHandler
|
||||
: RelationOutputCacheEvictionHandlerBase, INotificationAsyncHandler<MediaCacheRefresherNotification>
|
||||
{
|
||||
private readonly ILogger<DeliveryApiMediaOutputCacheEvictionHandler> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DeliveryApiMediaOutputCacheEvictionHandler"/> class.
|
||||
/// </summary>
|
||||
/// <param name="outputCacheStore">The output cache store for evicting cached responses.</param>
|
||||
/// <param name="relationService">The relation service for querying entity references.</param>
|
||||
/// <param name="idKeyMap">The ID/key mapping service for converting between integer IDs and GUIDs.</param>
|
||||
/// <param name="logger">The logger.</param>
|
||||
public DeliveryApiMediaOutputCacheEvictionHandler(
|
||||
IOutputCacheStore outputCacheStore,
|
||||
IRelationService relationService,
|
||||
IIdKeyMap idKeyMap,
|
||||
ILogger<DeliveryApiMediaOutputCacheEvictionHandler> logger)
|
||||
: base(outputCacheStore, relationService, idKeyMap)
|
||||
=> _logger = logger;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task HandleAsync(MediaCacheRefresherNotification notification, CancellationToken cancellationToken)
|
||||
{
|
||||
if (notification.MessageType != MessageType.RefreshByPayload
|
||||
|| notification.MessageObject is not MediaCacheRefresher.JsonPayload[] payloads)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (MediaCacheRefresher.JsonPayload payload in payloads)
|
||||
{
|
||||
if (payload.ChangeTypes.HasFlag(TreeChangeTypes.RefreshAll))
|
||||
{
|
||||
// Evict all Delivery API responses — content responses may include referenced media,
|
||||
// so evicting only media entries would leave stale media references in content responses.
|
||||
_logger.LogDebug("Media refresh all — evicting all Delivery API output cache entries.");
|
||||
await OutputCacheStore.EvictByTagAsync(Constants.DeliveryApi.OutputCache.AllTag, cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.Key.HasValue is false)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (_logger.IsEnabled(LogLevel.Debug))
|
||||
{
|
||||
_logger.LogDebug("Evicting Delivery API output cache for media {MediaKey}.", payload.Key.Value);
|
||||
}
|
||||
|
||||
await OutputCacheStore.EvictByTagAsync(Constants.DeliveryApi.OutputCache.MediaTagPrefix + payload.Key.Value, cancellationToken);
|
||||
}
|
||||
|
||||
// Evict content that references the changed media via picker properties.
|
||||
await EvictRelatedContentAsync(
|
||||
payloads.Select(p => p.Id),
|
||||
Constants.Conventions.RelationTypes.RelatedMediaAlias,
|
||||
Constants.DeliveryApi.OutputCache.ContentTagPrefix,
|
||||
_logger,
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
using Microsoft.AspNetCore.OutputCaching;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Cache;
|
||||
using Umbraco.Cms.Core.Events;
|
||||
using Umbraco.Cms.Core.Notifications;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Core.Sync;
|
||||
using Umbraco.Cms.Web.Common.Caching;
|
||||
|
||||
namespace Umbraco.Cms.Api.Delivery.Caching;
|
||||
|
||||
/// <summary>
|
||||
/// Handles <see cref="MemberCacheRefresherNotification"/> to evict Delivery API output cache entries
|
||||
/// for content that references the changed member via picker properties (umbMember relations).
|
||||
/// </summary>
|
||||
internal sealed class DeliveryApiMemberOutputCacheEvictionHandler
|
||||
: RelationOutputCacheEvictionHandlerBase, INotificationAsyncHandler<MemberCacheRefresherNotification>
|
||||
{
|
||||
private readonly ILogger<DeliveryApiMemberOutputCacheEvictionHandler> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DeliveryApiMemberOutputCacheEvictionHandler"/> class.
|
||||
/// </summary>
|
||||
/// <param name="outputCacheStore">The output cache store for evicting cached responses.</param>
|
||||
/// <param name="relationService">The relation service for querying entity references.</param>
|
||||
/// <param name="idKeyMap">The ID/key mapping service for converting between integer IDs and GUIDs.</param>
|
||||
/// <param name="logger">The logger.</param>
|
||||
public DeliveryApiMemberOutputCacheEvictionHandler(
|
||||
IOutputCacheStore outputCacheStore,
|
||||
IRelationService relationService,
|
||||
IIdKeyMap idKeyMap,
|
||||
ILogger<DeliveryApiMemberOutputCacheEvictionHandler> logger)
|
||||
: base(outputCacheStore, relationService, idKeyMap)
|
||||
=> _logger = logger;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task HandleAsync(MemberCacheRefresherNotification notification, CancellationToken cancellationToken)
|
||||
{
|
||||
if (notification.MessageType != MessageType.RefreshByPayload
|
||||
|| notification.MessageObject is not MemberCacheRefresher.JsonPayload[] payloads)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Evict content that references the changed members via picker properties.
|
||||
await EvictRelatedContentAsync(
|
||||
payloads.Select(p => p.Id),
|
||||
Constants.Conventions.RelationTypes.RelatedMemberAlias,
|
||||
Constants.DeliveryApi.OutputCache.ContentTagPrefix,
|
||||
_logger,
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
using Microsoft.AspNetCore.OutputCaching;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Primitives;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Models.PublishedContent;
|
||||
using Umbraco.Cms.Core.Services.Navigation;
|
||||
|
||||
namespace Umbraco.Cms.Api.Delivery.Caching;
|
||||
|
||||
/// <summary>
|
||||
/// Output cache policy for Delivery API content endpoints.
|
||||
/// </summary>
|
||||
internal sealed class DeliveryApiOutputCacheContentPolicy : DeliveryApiOutputCachePolicyBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DeliveryApiOutputCacheContentPolicy"/> class.
|
||||
/// </summary>
|
||||
/// <param name="defaultDuration">The default cache duration from configuration.</param>
|
||||
/// <param name="defaultVaryByHeaders">The default vary-by headers for content requests.</param>
|
||||
public DeliveryApiOutputCacheContentPolicy(TimeSpan defaultDuration, StringValues defaultVaryByHeaders)
|
||||
: base(defaultDuration, defaultVaryByHeaders)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override string ResolvedItemsKey => DeliveryApiOutputCacheKeys.ResolvedContentItemsKey;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override string ItemTagPrefix => Constants.DeliveryApi.OutputCache.ContentTagPrefix;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override string AllItemsTag => Constants.DeliveryApi.OutputCache.AllContentTag;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void AddItemTags(OutputCacheContext context, IPublishedContent item, IServiceProvider services)
|
||||
{
|
||||
// Tag with ancestor keys for branch eviction.
|
||||
IDocumentNavigationQueryService navigationService = services.GetRequiredService<IDocumentNavigationQueryService>();
|
||||
if (navigationService.TryGetAncestorsKeys(item.Key, out IEnumerable<Guid> ancestorKeys))
|
||||
{
|
||||
foreach (Guid ancestorKey in ancestorKeys)
|
||||
{
|
||||
context.Tags.Add(Constants.DeliveryApi.OutputCache.AncestorTagPrefix + ancestorKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
namespace Umbraco.Cms.Api.Delivery.Caching;
|
||||
|
||||
/// <summary>
|
||||
/// Keys used to pass resolved content and media items from controllers to the output cache policy
|
||||
/// via <see cref="Microsoft.AspNetCore.Http.HttpContext.Items"/>.
|
||||
/// </summary>
|
||||
internal static class DeliveryApiOutputCacheKeys
|
||||
{
|
||||
/// <summary>
|
||||
/// Key for storing resolved content items in <see cref="Microsoft.AspNetCore.Http.HttpContext.Items"/>.
|
||||
/// </summary>
|
||||
public const string ResolvedContentItemsKey = "Umbraco.DeliveryApi.OutputCache.ResolvedContentItems";
|
||||
|
||||
/// <summary>
|
||||
/// Key for storing resolved media items in <see cref="Microsoft.AspNetCore.Http.HttpContext.Items"/>.
|
||||
/// </summary>
|
||||
public const string ResolvedMediaItemsKey = "Umbraco.DeliveryApi.OutputCache.ResolvedMediaItems";
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
using Microsoft.AspNetCore.OutputCaching;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Cache;
|
||||
|
||||
namespace Umbraco.Cms.Api.Delivery.Caching;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of <see cref="IDeliveryApiOutputCacheManager"/> that delegates
|
||||
/// to the ASP.NET Core <see cref="IOutputCacheStore"/>.
|
||||
/// </summary>
|
||||
internal sealed class DeliveryApiOutputCacheManager : IDeliveryApiOutputCacheManager
|
||||
{
|
||||
private readonly IOutputCacheStore _outputCacheStore;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DeliveryApiOutputCacheManager"/> class.
|
||||
/// </summary>
|
||||
/// <param name="outputCacheStore">The ASP.NET Core output cache store.</param>
|
||||
public DeliveryApiOutputCacheManager(IOutputCacheStore outputCacheStore)
|
||||
=> _outputCacheStore = outputCacheStore;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task EvictContentAsync(Guid contentKey, CancellationToken cancellationToken = default)
|
||||
=> await _outputCacheStore.EvictByTagAsync(Constants.DeliveryApi.OutputCache.ContentTagPrefix + contentKey, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task EvictMediaAsync(Guid mediaKey, CancellationToken cancellationToken = default)
|
||||
=> await _outputCacheStore.EvictByTagAsync(Constants.DeliveryApi.OutputCache.MediaTagPrefix + mediaKey, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task EvictByTagAsync(string tag, CancellationToken cancellationToken = default)
|
||||
=> await _outputCacheStore.EvictByTagAsync(tag, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task EvictAllContentAsync(CancellationToken cancellationToken = default)
|
||||
=> await _outputCacheStore.EvictByTagAsync(Constants.DeliveryApi.OutputCache.AllContentTag, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task EvictAllMediaAsync(CancellationToken cancellationToken = default)
|
||||
=> await _outputCacheStore.EvictByTagAsync(Constants.DeliveryApi.OutputCache.AllMediaTag, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task EvictAllAsync(CancellationToken cancellationToken = default)
|
||||
=> await _outputCacheStore.EvictByTagAsync(Constants.DeliveryApi.OutputCache.AllTag, cancellationToken);
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
using Microsoft.Extensions.Primitives;
|
||||
using Umbraco.Cms.Core;
|
||||
|
||||
namespace Umbraco.Cms.Api.Delivery.Caching;
|
||||
|
||||
/// <summary>
|
||||
/// Output cache policy for Delivery API media endpoints.
|
||||
/// </summary>
|
||||
internal sealed class DeliveryApiOutputCacheMediaPolicy : DeliveryApiOutputCachePolicyBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DeliveryApiOutputCacheMediaPolicy"/> class.
|
||||
/// </summary>
|
||||
/// <param name="defaultDuration">The default cache duration from configuration.</param>
|
||||
/// <param name="defaultVaryByHeaders">The default vary-by headers for media requests.</param>
|
||||
public DeliveryApiOutputCacheMediaPolicy(TimeSpan defaultDuration, StringValues defaultVaryByHeaders)
|
||||
: base(defaultDuration, defaultVaryByHeaders)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override string ResolvedItemsKey => DeliveryApiOutputCacheKeys.ResolvedMediaItemsKey;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override string ItemTagPrefix => Constants.DeliveryApi.OutputCache.MediaTagPrefix;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override string AllItemsTag => Constants.DeliveryApi.OutputCache.AllMediaTag;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using Microsoft.AspNetCore.OutputCaching;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Primitives;
|
||||
using Umbraco.Cms.Core.DeliveryApi;
|
||||
|
||||
namespace Umbraco.Cms.Api.Delivery.Caching;
|
||||
|
||||
internal sealed class DeliveryApiOutputCachePolicy : IOutputCachePolicy
|
||||
{
|
||||
private readonly TimeSpan _duration;
|
||||
private readonly StringValues _varyByHeaderNames;
|
||||
|
||||
public DeliveryApiOutputCachePolicy(TimeSpan duration, StringValues varyByHeaderNames)
|
||||
{
|
||||
_duration = duration;
|
||||
_varyByHeaderNames = varyByHeaderNames;
|
||||
}
|
||||
|
||||
ValueTask IOutputCachePolicy.CacheRequestAsync(OutputCacheContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
IRequestPreviewService requestPreviewService = context
|
||||
.HttpContext
|
||||
.RequestServices
|
||||
.GetRequiredService<IRequestPreviewService>();
|
||||
|
||||
IApiAccessService apiAccessService = context
|
||||
.HttpContext
|
||||
.RequestServices
|
||||
.GetRequiredService<IApiAccessService>();
|
||||
|
||||
context.EnableOutputCaching = requestPreviewService.IsPreview() is false && apiAccessService.HasPublicAccess();
|
||||
context.ResponseExpirationTimeSpan = _duration;
|
||||
context.CacheVaryByRules.HeaderNames = _varyByHeaderNames;
|
||||
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
ValueTask IOutputCachePolicy.ServeFromCacheAsync(OutputCacheContext context, CancellationToken cancellationToken)
|
||||
=> ValueTask.CompletedTask;
|
||||
|
||||
ValueTask IOutputCachePolicy.ServeResponseAsync(OutputCacheContext context, CancellationToken cancellationToken)
|
||||
=> ValueTask.CompletedTask;
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
using Microsoft.AspNetCore.OutputCaching;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Primitives;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Cache;
|
||||
using Umbraco.Cms.Core.Models.PublishedContent;
|
||||
|
||||
namespace Umbraco.Cms.Api.Delivery.Caching;
|
||||
|
||||
/// <summary>
|
||||
/// Base output cache policy for Delivery API endpoints. Handles request filtering, vary-by rules,
|
||||
/// and tagging. Subclasses specify the resolved-items key, tag prefix, and "all" tag that
|
||||
/// distinguish content from media.
|
||||
/// </summary>
|
||||
internal abstract class DeliveryApiOutputCachePolicyBase : IOutputCachePolicy
|
||||
{
|
||||
private readonly TimeSpan _defaultDuration;
|
||||
private readonly StringValues _defaultVaryByHeaders;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DeliveryApiOutputCachePolicyBase"/> class.
|
||||
/// </summary>
|
||||
/// <param name="defaultDuration">The default cache duration from configuration.</param>
|
||||
/// <param name="defaultVaryByHeaders">The default vary-by headers for this endpoint type.</param>
|
||||
protected DeliveryApiOutputCachePolicyBase(TimeSpan defaultDuration, StringValues defaultVaryByHeaders)
|
||||
{
|
||||
_defaultDuration = defaultDuration;
|
||||
_defaultVaryByHeaders = defaultVaryByHeaders;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the <see cref="Microsoft.AspNetCore.Http.HttpContext.Items"/> key used to retrieve
|
||||
/// resolved <see cref="IPublishedContent"/> items stashed by the controller.
|
||||
/// </summary>
|
||||
protected abstract string ResolvedItemsKey { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the tag prefix for individual item eviction (e.g. <c>umb-dapi-content-</c>).
|
||||
/// </summary>
|
||||
protected abstract string ItemTagPrefix { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the "all items" tag for bulk eviction (e.g. <c>umb-dapi-content-all</c>).
|
||||
/// </summary>
|
||||
protected abstract string AllItemsTag { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Adds additional per-item tags to the output cache context. Called once per resolved item
|
||||
/// during <c>ServeResponseAsync</c>. The default implementation does nothing.
|
||||
/// </summary>
|
||||
/// <param name="context">The output cache context.</param>
|
||||
/// <param name="item">The published content or media item.</param>
|
||||
/// <param name="services">The request service provider.</param>
|
||||
protected virtual void AddItemTags(OutputCacheContext context, IPublishedContent item, IServiceProvider services)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
ValueTask IOutputCachePolicy.CacheRequestAsync(OutputCacheContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
IServiceProvider services = context.HttpContext.RequestServices;
|
||||
ILogger logger = services.GetRequiredService<ILoggerFactory>().CreateLogger(GetType());
|
||||
|
||||
IDeliveryApiOutputCacheRequestFilter requestFilter = services.GetRequiredService<IDeliveryApiOutputCacheRequestFilter>();
|
||||
if (requestFilter.IsCacheable(context.HttpContext) is false)
|
||||
{
|
||||
context.EnableOutputCaching = false;
|
||||
logger.LogDebug("Request filter returned not cacheable — skipping output cache.");
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
context.EnableOutputCaching = true;
|
||||
context.AllowCacheLookup = true;
|
||||
context.AllowCacheStorage = true;
|
||||
context.AllowLocking = true;
|
||||
context.ResponseExpirationTimeSpan = _defaultDuration;
|
||||
|
||||
// Set default vary-by headers.
|
||||
context.CacheVaryByRules.HeaderNames = _defaultVaryByHeaders;
|
||||
|
||||
// Invoke custom vary-by providers (additive, runs after defaults).
|
||||
IEnumerable<IDeliveryApiOutputCacheVaryByProvider> varyByProviders = services.GetServices<IDeliveryApiOutputCacheVaryByProvider>();
|
||||
foreach (IDeliveryApiOutputCacheVaryByProvider varyByProvider in varyByProviders)
|
||||
{
|
||||
varyByProvider.ConfigureVaryBy(context.HttpContext, context.CacheVaryByRules);
|
||||
}
|
||||
|
||||
// Add base tags for bulk eviction.
|
||||
context.Tags.Add(AllItemsTag);
|
||||
context.Tags.Add(Constants.DeliveryApi.OutputCache.AllTag);
|
||||
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
ValueTask IOutputCachePolicy.ServeFromCacheAsync(OutputCacheContext context, CancellationToken cancellationToken)
|
||||
=> ValueTask.CompletedTask;
|
||||
|
||||
/// <inheritdoc />
|
||||
ValueTask IOutputCachePolicy.ServeResponseAsync(OutputCacheContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
if (context.HttpContext.Items[ResolvedItemsKey]
|
||||
is not IPublishedContent[] items || items.Length == 0)
|
||||
{
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
IServiceProvider services = context.HttpContext.RequestServices;
|
||||
ILogger logger = services.GetRequiredService<ILoggerFactory>().CreateLogger(GetType());
|
||||
IDeliveryApiOutputCacheRequestFilter requestFilter = services.GetRequiredService<IDeliveryApiOutputCacheRequestFilter>();
|
||||
IEnumerable<IDeliveryApiOutputCacheTagProvider> tagProviders = services.GetServices<IDeliveryApiOutputCacheTagProvider>();
|
||||
|
||||
foreach (IPublishedContent item in items)
|
||||
{
|
||||
// Check content-aware cacheability.
|
||||
if (requestFilter.IsCacheable(context.HttpContext, item) is false)
|
||||
{
|
||||
context.AllowCacheStorage = false;
|
||||
if (logger.IsEnabled(LogLevel.Debug))
|
||||
{
|
||||
logger.LogDebug("Request filter returned not cacheable for item {ItemKey} — disabling cache storage.", item.Key);
|
||||
}
|
||||
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
// Tag with specific item key for targeted eviction.
|
||||
context.Tags.Add(ItemTagPrefix + item.Key);
|
||||
|
||||
// Allow subclasses to add additional per-item tags (e.g. ancestor tags for content).
|
||||
AddItemTags(context, item, services);
|
||||
|
||||
// Invoke custom tag providers.
|
||||
foreach (IDeliveryApiOutputCacheTagProvider tagProvider in tagProviders)
|
||||
{
|
||||
foreach (var tag in tagProvider.GetTags(item))
|
||||
{
|
||||
context.Tags.Add(tag);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (logger.IsEnabled(LogLevel.Debug))
|
||||
{
|
||||
logger.LogDebug(
|
||||
"Caching Delivery API response with {TagCount} tags, duration {Duration}",
|
||||
context.Tags.Count,
|
||||
context.ResponseExpirationTimeSpan);
|
||||
}
|
||||
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Umbraco.Cms.Core.Models.PublishedContent;
|
||||
|
||||
namespace Umbraco.Cms.Api.Delivery.Caching;
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether a Delivery API request is eligible for output caching.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This interface provides two levels of cacheability checks:
|
||||
/// </para>
|
||||
/// <list type="bullet">
|
||||
/// <item><see cref="IsCacheable(HttpContext)"/> — called before the controller runs, for
|
||||
/// request-level decisions (e.g. preview mode, access control).</item>
|
||||
/// <item><see cref="IsCacheable(HttpContext, IPublishedContent)"/> — called after the controller
|
||||
/// resolves content, for content-aware decisions (e.g. exclude specific content types).</item>
|
||||
/// </list>
|
||||
/// </remarks>
|
||||
public interface IDeliveryApiOutputCacheRequestFilter
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the request is eligible for output caching.
|
||||
/// Called before the controller runs.
|
||||
/// </summary>
|
||||
/// <param name="context">The HTTP context for the current request.</param>
|
||||
/// <returns><c>true</c> if the response may be cached; <c>false</c> to skip caching.</returns>
|
||||
bool IsCacheable(HttpContext context);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the response for the given content or media item is eligible
|
||||
/// for output caching. Called after the controller resolves content.
|
||||
/// </summary>
|
||||
/// <param name="context">The HTTP context for the current request.</param>
|
||||
/// <param name="content">The resolved published content or media item.</param>
|
||||
/// <returns><c>true</c> if the response may be cached; <c>false</c> to skip caching.</returns>
|
||||
bool IsCacheable(HttpContext context, IPublishedContent content);
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.OutputCaching;
|
||||
|
||||
namespace Umbraco.Cms.Api.Delivery.Caching;
|
||||
|
||||
/// <summary>
|
||||
/// Configures additional vary-by rules for Delivery API output caching.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Multiple implementations can be registered; the output cache policy invokes all of them
|
||||
/// to configure vary-by rules at cache-write time, after the default vary-by headers have been set.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Providers have direct access to <see cref="CacheVaryByRules"/> and can configure any aspect
|
||||
/// including <see cref="CacheVaryByRules.QueryKeys"/>, <see cref="CacheVaryByRules.HeaderNames"/>,
|
||||
/// and <see cref="CacheVaryByRules.VaryByValues"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public interface IDeliveryApiOutputCacheVaryByProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Configures vary-by rules for the given request.
|
||||
/// </summary>
|
||||
/// <param name="context">The HTTP context for the current request.</param>
|
||||
/// <param name="rules">The vary-by rules to configure.</param>
|
||||
void ConfigureVaryBy(HttpContext context, CacheVaryByRules rules);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Umbraco.Cms.Web.Common.ApplicationBuilder;
|
||||
|
||||
namespace Umbraco.Cms.Api.Delivery.Caching;
|
||||
|
||||
internal sealed class OutputCachePipelineFilter : UmbracoPipelineFilter
|
||||
{
|
||||
public OutputCachePipelineFilter(string name)
|
||||
: base(name)
|
||||
=> PostPipeline = PostPipelineAction;
|
||||
|
||||
private void PostPipelineAction(IApplicationBuilder applicationBuilder)
|
||||
=> applicationBuilder.UseOutputCache();
|
||||
}
|
||||
-62
@@ -1,62 +0,0 @@
|
||||
using Microsoft.AspNetCore.OpenApi;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Umbraco.Cms.Api.Common.Configuration;
|
||||
using Umbraco.Cms.Api.Common.OpenApi;
|
||||
using Umbraco.Cms.Api.Delivery.OpenApi.Transformers;
|
||||
using Umbraco.Cms.Core.Configuration.Models;
|
||||
|
||||
namespace Umbraco.Cms.Api.Delivery.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Configures the OpenAPI options for the Umbraco Delivery API.
|
||||
/// </summary>
|
||||
internal class ConfigureUmbracoDeliveryApiOpenApiOptions : ConfigureUmbracoOpenApiOptionsBase
|
||||
{
|
||||
private readonly DeliveryApiSettings _deliveryApiSettings;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ConfigureUmbracoDeliveryApiOpenApiOptions"/> class.
|
||||
/// </summary>
|
||||
/// <param name="deliveryApiSettings">The Delivery API settings.</param>
|
||||
public ConfigureUmbracoDeliveryApiOpenApiOptions(IOptions<DeliveryApiSettings> deliveryApiSettings)
|
||||
{
|
||||
_deliveryApiSettings = deliveryApiSettings.Value;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override string ApiName => DeliveryApiConfiguration.ApiName;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override string ApiTitle => DeliveryApiConfiguration.ApiTitle;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override string ApiVersion => "Latest";
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override string ApiDescription =>
|
||||
$"You can find out more about the {DeliveryApiConfiguration.ApiTitle} in [the documentation]({DeliveryApiConfiguration.ApiDocumentationContentArticleLink}).";
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void ConfigureOpenApi(OpenApiOptions options)
|
||||
{
|
||||
base.ConfigureOpenApi(options);
|
||||
|
||||
// Add API key security scheme and configure it for all operations
|
||||
options
|
||||
.AddDocumentTransformer<ApiKeyTransformer>()
|
||||
.AddOperationTransformer<ApiKeyTransformer>();
|
||||
|
||||
options.AddSchemaTransformer<RequireNonNullablePropertiesSchemaTransformer>();
|
||||
options.AddSchemaTransformer<FixFileReturnTypesTransformer>();
|
||||
options.AddOperationTransformer<MimeTypesTransformer>();
|
||||
options.AddOperationTransformer<ContentApiTransformer>();
|
||||
options.AddOperationTransformer<MediaApiTransformer>();
|
||||
|
||||
if (_deliveryApiSettings.OpenApi.GenerateContentTypeSchemas)
|
||||
{
|
||||
options
|
||||
.AddSchemaTransformer<ContentTypeSchemaTransformer>()
|
||||
.AddDocumentTransformer<ContentTypeSchemaTransformer>();
|
||||
}
|
||||
}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.OpenApi;
|
||||
using Swashbuckle.AspNetCore.SwaggerGen;
|
||||
using Umbraco.Cms.Api.Common.OpenApi;
|
||||
using Umbraco.Cms.Api.Delivery.Filters;
|
||||
|
||||
namespace Umbraco.Cms.Api.Delivery.Configuration;
|
||||
|
||||
public class ConfigureUmbracoDeliveryApiSwaggerGenOptions: IConfigureOptions<SwaggerGenOptions>
|
||||
{
|
||||
public void Configure(SwaggerGenOptions swaggerGenOptions)
|
||||
{
|
||||
swaggerGenOptions.SwaggerDoc(
|
||||
DeliveryApiConfiguration.ApiName,
|
||||
new OpenApiInfo
|
||||
{
|
||||
Title = DeliveryApiConfiguration.ApiTitle,
|
||||
Version = "Latest",
|
||||
Description = $"You can find out more about the {DeliveryApiConfiguration.ApiTitle} in [the documentation]({DeliveryApiConfiguration.ApiDocumentationContentArticleLink})."
|
||||
});
|
||||
|
||||
swaggerGenOptions.DocumentFilter<MimeTypeDocumentFilter>(DeliveryApiConfiguration.ApiName);
|
||||
swaggerGenOptions.DocumentFilter<RemoveSecuritySchemesDocumentFilter>(DeliveryApiConfiguration.ApiName);
|
||||
|
||||
swaggerGenOptions.OperationFilter<SwaggerContentDocumentationFilter>();
|
||||
swaggerGenOptions.OperationFilter<SwaggerMediaDocumentationFilter>();
|
||||
swaggerGenOptions.ParameterFilter<SwaggerContentDocumentationFilter>();
|
||||
swaggerGenOptions.ParameterFilter<SwaggerMediaDocumentationFilter>();
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.AspNetCore.Http.Json;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Umbraco.Cms.Core;
|
||||
|
||||
namespace Umbraco.Cms.Api.Delivery.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Configures the Http JSON options for the Umbraco Delivery API.
|
||||
/// </summary>
|
||||
internal class ConfigureUmbracoDeliveryHttpJsonOptions : IConfigureNamedOptions<JsonOptions>
|
||||
{
|
||||
private readonly IOptionsMonitor<Microsoft.AspNetCore.Mvc.JsonOptions> _mvcJsonOptions;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ConfigureUmbracoDeliveryHttpJsonOptions"/> class.
|
||||
/// </summary>
|
||||
/// <param name="mvcJsonOptions">The configured MVC json options.</param>
|
||||
public ConfigureUmbracoDeliveryHttpJsonOptions(IOptionsMonitor<Microsoft.AspNetCore.Mvc.JsonOptions> mvcJsonOptions)
|
||||
=> _mvcJsonOptions = mvcJsonOptions;
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Configure(JsonOptions options) => Configure(Options.DefaultName, options);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Configure(string? name, JsonOptions options)
|
||||
{
|
||||
if (name != Constants.JsonOptionsNames.DeliveryApi)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Copy all converters from the Delivery API MVC JSON options
|
||||
Microsoft.AspNetCore.Mvc.JsonOptions backofficeMvcJsonOptions = _mvcJsonOptions.Get(Constants.JsonOptionsNames.DeliveryApi);
|
||||
foreach (JsonConverter jsonConverter in backofficeMvcJsonOptions.JsonSerializerOptions.Converters)
|
||||
{
|
||||
options.SerializerOptions.Converters.Add(jsonConverter);
|
||||
}
|
||||
|
||||
options.SerializerOptions.PropertyNamingPolicy = backofficeMvcJsonOptions.JsonSerializerOptions.PropertyNamingPolicy;
|
||||
options.SerializerOptions.TypeInfoResolver = backofficeMvcJsonOptions.JsonSerializerOptions.TypeInfoResolver;
|
||||
options.SerializerOptions.MaxDepth = backofficeMvcJsonOptions.JsonSerializerOptions.MaxDepth;
|
||||
|
||||
// Open API specific settings
|
||||
options.SerializerOptions.NumberHandling = JsonNumberHandling.Strict;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user