Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9505011d71 | ||
|
|
2579aaf2db | ||
|
|
2b7784a226 | ||
|
|
8555a97b39 | ||
|
|
c2dd685a4b | ||
|
|
66fc819379 | ||
|
|
4f1f7e15c4 | ||
|
|
a826c52e2e | ||
|
|
8b2c22aaf1 | ||
|
|
aecfee4469 | ||
|
|
9c785a9c5b | ||
|
|
2fe10387ee |
+1
-1
@@ -102,7 +102,7 @@ dotnet_style_predefined_type_for_locals_parameters_members = true:warning
|
||||
dotnet_style_predefined_type_for_member_access = true:warning
|
||||
# Modifier preferences
|
||||
# https://docs.microsoft.com/visualstudio/ide/editorconfig-language-conventions#normalize-modifiers
|
||||
dotnet_style_require_accessibility_modifiers = for_non_interface_members:warning
|
||||
dotnet_style_require_accessibility_modifiers = always:warning
|
||||
csharp_preferred_modifier_order = public,private,protected,internal,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,volatile,async:warning
|
||||
visual_basic_preferred_modifier_order = Partial,Default,Private,Protected,Public,Friend,NotOverridable,Overridable,MustOverride,Overloads,Overrides,MustInherit,NotInheritable,Static,Shared,Shadows,ReadOnly,WriteOnly,Dim,Const,WithEvents,Widening,Narrowing,Custom,Async:warning
|
||||
dotnet_style_readonly_field = true:warning
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
UMBRACO_CLIENT_ID=umbraco-back-office-mcp
|
||||
UMBRACO_CLIENT_SECRET=1234567890
|
||||
UMBRACO_BASE_URL=https://localhost:44339
|
||||
NODE_TLS_REJECT_UNAUTHORIZED=0
|
||||
UMBRACO_INCLUDE_TOOL_COLLECTIONS=data-type,document-type,document,media-type,media
|
||||
@@ -55,8 +55,3 @@
|
||||
*.sln text=auto eol=crlf merge=union
|
||||
|
||||
*.gitattributes text=auto
|
||||
|
||||
# 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
|
||||
src/Umbraco.Cms.Api.Management/OpenApi.json linguist-generated
|
||||
|
||||
+3
-2
@@ -9,7 +9,7 @@ In order to use Umbraco as a CMS and build your website with it, you should not
|
||||
- Are you about to [create a pull request for Umbraco][contribution guidelines]?
|
||||
- Are you trying to get to the bottom of a problem in your existing Umbraco installation?
|
||||
|
||||
If the answer is yes, please read on. Otherwise, make sure to head on over [to the releases page](https://releases.umbraco.com) and start using Umbraco CMS as intended.
|
||||
If the answer is yes, please read on. Otherwise, make sure to head on over [to the download page](https://our.umbraco.com/download) and start using Umbraco CMS as intended.
|
||||
|
||||
## Table of contents
|
||||
|
||||
@@ -79,12 +79,13 @@ Conversely, if you are working on front-end only, you want to build the back-end
|
||||
"AuthorizeCallbackLogoutPathName": "/logout",
|
||||
"AuthorizeCallbackErrorPathName": "/error",
|
||||
"BackOfficeTokenCookie": {
|
||||
"Enabled": true,
|
||||
"SameSite": "None"
|
||||
}
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> If you get stuck in a login loop, try clearing your browser cookies for localhost, and make sure that the `Umbraco:Cms:Security:BackOfficeTokenCookie:SameSite` setting is set to `None`.
|
||||
> If you get stuck in a login loop, try clearing your browser cookies for localhost, and make sure that the `BackOfficeTokenCookie` settings are correct. Namely, that `SameSite` should be set to `None` when running the front-end server separately.
|
||||
|
||||
Then run Umbraco from the command line.
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ This guide describes each step to make your first contribution:
|
||||
|
||||
Create a new branch based on `main` and name it after the issue you're fixing. For example: `v15/bugfix/18132-rte-tinymce-onchange-value-check`.
|
||||
|
||||
Please follow this format for branches: `v{major}/{feature|bugfix|task|qa|improvement}/{issue}-{description}`.
|
||||
Please follow this format for branches: `v{major}/{feature|bugfix|task}/{issue}-{description}`.
|
||||
|
||||
This is a development branch for the particular issue you're working on, in this case, a bug-fix for issue number `18132` that affects Umbraco v.15.
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ Great question! The short version goes like this:
|
||||
|
||||
1. **Switch to the correct branch**
|
||||
|
||||
Switch to the `main` branch
|
||||
Switch to the `contrib` branch
|
||||
|
||||
1. **Build**
|
||||
|
||||
@@ -32,7 +32,7 @@ Great question! The short version goes like this:
|
||||
|
||||
1. **Branch**
|
||||
|
||||
Create a new branch now and name it after the issue you're fixing, we usually follow the format: `temp-12345`. This means it's a temporary branch for the particular issue you're working on, in this case issue number `12345`. Don't commit to `main`, create a new branch first.
|
||||
Create a new branch now and name it after the issue you're fixing, we usually follow the format: `temp-12345`. This means it's a temporary branch for the particular issue you're working on, in this case issue number `12345`. Don't commit to `contrib`, create a new branch first.
|
||||
|
||||
1. **Change**
|
||||
|
||||
@@ -42,7 +42,7 @@ Great question! The short version goes like this:
|
||||
|
||||
Done? Yay! 🎉
|
||||
|
||||
Remember to commit to your new `temp` branch, and don't commit to `main`. Then you can push the changes up to your fork on GitHub.
|
||||
Remember to commit to your new `temp` branch, and don't commit to `contrib`. Then you can push the changes up to your fork on GitHub.
|
||||
|
||||
#### Keeping your Umbraco fork in sync with the main repository
|
||||
[sync fork]: #keeping-your-umbraco-fork-in-sync-with-the-main-repository
|
||||
@@ -59,10 +59,10 @@ Then when you want to get the changes from the main repository:
|
||||
|
||||
```
|
||||
git fetch upstream
|
||||
git rebase upstream/main
|
||||
git rebase upstream/contrib
|
||||
```
|
||||
|
||||
In this command we're syncing with the `main` branch, but you can of course choose another one if needed.
|
||||
In this command we're syncing with the `contrib` branch, but you can of course choose another one if needed.
|
||||
|
||||
[More information on how this works can be found on the thoughtbot blog.][sync fork ext]
|
||||
|
||||
@@ -79,7 +79,7 @@ You can get in touch with [the core contributors team][core collabs] in multiple
|
||||
|
||||
- If there's an existing issue on the issue tracker then that's a good place to leave questions and discuss how to start or move forward.
|
||||
- If you want to ask questions on some code you've already written you can create a draft pull request, [detailed in a GitHub blog post][draft prs].
|
||||
- Unsure where to start? Did something not work as expected? Try leaving a note in the [forum][forum]. The team monitors that one closely, so one of us will be on hand and ready to point you in the right direction.
|
||||
- Unsure where to start? Did something not work as expected? Try leaving a note in the ["Contributing to Umbraco"][contrib forum] forum. The team monitors that one closely, so one of us will be on hand and ready to point you in the right direction.
|
||||
|
||||
|
||||
<!-- Local -->
|
||||
@@ -90,7 +90,7 @@ You can get in touch with [the core contributors team][core collabs] in multiple
|
||||
|
||||
[sync fork ext]: http://robots.thoughtbot.com/post/5133345960/keeping-a-git-fork-updated "Details on keeping a git fork updated"
|
||||
[draft prs]: https://github.blog/2019-02-14-introducing-draft-pull-requests/ "Github's blog post providing details on draft pull requests"
|
||||
[forum]: https://forum.umbraco.com/
|
||||
[contrib forum]: https://our.umbraco.com/forum/contributing-to-umbraco-cms/
|
||||
[Umbraco CMS repo]: https://github.com/umbraco/Umbraco-CMS
|
||||
[up for grabs issues]: https://github.com/umbraco/Umbraco-CMS/issues?q=is%3Aissue+is%3Aopen+label%3Acommunity%2Fup-for-grabs
|
||||
[issue tracker]: https://github.com/umbraco/Umbraco-CMS/issues
|
||||
@@ -115,7 +115,7 @@ Save the changes and return to the Backoffice to see the update.
|
||||
|
||||
|
||||
|
||||
1. Commit your changes to a new temporary branch (avoid committing directly to `main`).
|
||||
1. Commit your changes to a new temporary branch (avoid committing directly to `contrib`).
|
||||
|
||||
2. Push the changes to your fork on GitHub.
|
||||
|
||||
|
||||
@@ -1 +1,198 @@
|
||||
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
|
||||
|
||||
## 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": {
|
||||
"Enabled": true,
|
||||
"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.
|
||||
@@ -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:
|
||||
|
||||
jobs:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -12,7 +12,6 @@ permissions:
|
||||
|
||||
jobs:
|
||||
reconcile:
|
||||
if: github.repository == 'umbraco/Umbraco-CMS'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Reconcile release/* labels → discussions
|
||||
|
||||
+2
-10
@@ -51,10 +51,6 @@ tools/docfx/
|
||||
/build/csharp-docs/api/
|
||||
/build/csharp-docs/_site/
|
||||
|
||||
# Local config
|
||||
.claude/settings.local.json
|
||||
.env.local
|
||||
|
||||
# Build
|
||||
/build.out/
|
||||
/build.tmp/
|
||||
@@ -99,11 +95,6 @@ tools/docfx/
|
||||
/tests/Umbraco.Tests.Integration/[Uu]mbraco/[Ll]ogs/
|
||||
/tests/Umbraco.Tests.Integration/Views/
|
||||
/tests/Umbraco.Tests.UnitTests/[Uu]mbraco/[Dd]ata/TEMP/
|
||||
/BenchmarkDotNet.Artifacts/
|
||||
playwright-report
|
||||
trace.zip
|
||||
/tests/Umbraco.Tests.AcceptanceTest/results
|
||||
/tests/Umbraco.Tests.AcceptanceTest/dist
|
||||
|
||||
# Ignore auto-generated schema
|
||||
/src/Umbraco.Cms.Targets/tasks/
|
||||
@@ -112,8 +103,9 @@ trace.zip
|
||||
/src/Umbraco.Web.UI/appsettings-schema.json
|
||||
/src/Umbraco.Web.UI/appsettings-schema.*.json
|
||||
/src/Umbraco.Web.UI/umbraco-package-schema.json
|
||||
/src/Umbraco.Web.UI.Client/umbraco-package-schema.json
|
||||
/tests/Umbraco.Tests.Integration/appsettings-schema.json
|
||||
/tests/Umbraco.Tests.Integration/appsettings-schema.*.json
|
||||
/tests/Umbraco.Tests.Integration/umbraco-package-schema.json
|
||||
/src/Umbraco.Cms/appsettings-schema.json
|
||||
playwright-report
|
||||
trace.zip
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"umbraco-cms": {
|
||||
"command": "npx",
|
||||
"args": ["@umbraco-cms/mcp-dev@17"]
|
||||
},
|
||||
"playwright": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"@playwright/mcp@latest"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+1
@@ -107,6 +107,7 @@
|
||||
"UMBRACO__CMS__SECURITY__AUTHORIZECALLBACKLOGOUTPATHNAME": "/logout",
|
||||
"UMBRACO__CMS__SECURITY__AUTHORIZECALLBACKERRORPATHNAME": "/error",
|
||||
"UMBRACO__CMS__SECURITY__KEEPUSERLOGGEDIN": "true",
|
||||
"UMBRACO__CMS__SECURITY__BACKOFFICETOKENCOOKIE__ENABLED": "true",
|
||||
"UMBRACO__CMS__SECURITY__BACKOFFICETOKENCOOKIE__SAMESITE": "None"
|
||||
},
|
||||
"sourceFileMap": {
|
||||
|
||||
Vendored
-4
@@ -1,9 +1,5 @@
|
||||
{
|
||||
"cSpell.words": [
|
||||
"backoffice",
|
||||
"pickable",
|
||||
"Pickable",
|
||||
"Umbraco",
|
||||
"unprovide",
|
||||
"Unproviding"
|
||||
],
|
||||
|
||||
@@ -1,547 +0,0 @@
|
||||
# Umbraco CMS - Multi-Project Repository
|
||||
|
||||
Enterprise-grade CMS built on .NET 10.0. This repository contains 21 production projects organized in a layered architecture with clear separation of concerns.
|
||||
|
||||
**Repository**: https://github.com/umbraco/Umbraco-CMS
|
||||
**License**: MIT
|
||||
**Main Branch**: `main`
|
||||
|
||||
---
|
||||
|
||||
## 1. Overview
|
||||
|
||||
### What This Repository Contains
|
||||
|
||||
**21 Production Projects** organized in 3 main categories:
|
||||
|
||||
1. **Core Architecture** (Domain & Infrastructure)
|
||||
- `Umbraco.Core` - Interface contracts, domain models, notifications
|
||||
- `Umbraco.Infrastructure` - Service implementations, data access, caching
|
||||
|
||||
2. **Web & APIs** (Presentation Layer)
|
||||
- `Umbraco.Web.UI` - Main ASP.NET Core web application
|
||||
- `Umbraco.Web.Common` - Shared web functionality, controllers, middleware
|
||||
- `Umbraco.Cms.Api.Management` - Backoffice Management API (REST)
|
||||
- `Umbraco.Cms.Api.Delivery` - Content Delivery API (headless)
|
||||
- `Umbraco.Cms.Api.Common` - Shared API infrastructure
|
||||
|
||||
3. **Specialized Features** (Pluggable Modules)
|
||||
- Persistence: EF Core (modern), NPoco (legacy) for SQL Server & SQLite
|
||||
- Caching: `PublishedCache.HybridCache` (in-memory + distributed)
|
||||
- Search: `Examine.Lucene` (full-text search)
|
||||
- Imaging: `Imaging.ImageSharp` v1 & v2 (image processing)
|
||||
- Other: Static assets, targets, development tools
|
||||
|
||||
**6 Test Projects**:
|
||||
- `Umbraco.Tests.Common` - Shared test utilities
|
||||
- `Umbraco.Tests.UnitTests` - Unit tests
|
||||
- `Umbraco.Tests.Integration` - Integration tests
|
||||
- `Umbraco.Tests.Benchmarks` - Performance benchmarks
|
||||
- `Umbraco.Tests.AcceptanceTest` - E2E tests
|
||||
- `Umbraco.Tests.AcceptanceTest.UmbracoProject` - Test instance
|
||||
|
||||
### Key Technologies
|
||||
|
||||
- **.NET 10.0** - Target framework for all projects
|
||||
- **ASP.NET Core** - Web framework
|
||||
- **Entity Framework Core** - Modern ORM
|
||||
- **OpenIddict** - OAuth 2.0/OpenID Connect authentication
|
||||
- **Swashbuckle** - OpenAPI/Swagger documentation
|
||||
- **Lucene.NET** - Full-text search via Examine
|
||||
- **ImageSharp** - Image processing
|
||||
|
||||
---
|
||||
|
||||
## 2. Repository Structure
|
||||
|
||||
```
|
||||
Umbraco-CMS/
|
||||
├── src/ # 21 production projects
|
||||
│ ├── Umbraco.Core/ # Domain contracts (interfaces only)
|
||||
│ │ └── CLAUDE.md # ⭐ Core architecture guide
|
||||
│ ├── Umbraco.Infrastructure/ # Service implementations
|
||||
│ ├── Umbraco.Web.Common/ # Web utilities
|
||||
│ ├── Umbraco.Web.UI/ # Main web application
|
||||
│ ├── Umbraco.Cms.Api.Management/ # Management API
|
||||
│ ├── Umbraco.Cms.Api.Delivery/ # Delivery API (headless)
|
||||
│ ├── Umbraco.Cms.Api.Common/ # Shared API infrastructure
|
||||
│ │ └── CLAUDE.md # ⭐ API patterns guide
|
||||
│ ├── Umbraco.PublishedCache.HybridCache/ # Content caching
|
||||
│ ├── Umbraco.Examine.Lucene/ # Search indexing
|
||||
│ ├── Umbraco.Cms.Persistence.EFCore/ # EF Core data access
|
||||
│ ├── Umbraco.Cms.Persistence.EFCore.Sqlite/
|
||||
│ ├── Umbraco.Cms.Persistence.EFCore.SqlServer/
|
||||
│ ├── Umbraco.Cms.Persistence.Sqlite/ # Legacy SQLite
|
||||
│ ├── Umbraco.Cms.Persistence.SqlServer/ # Legacy SQL Server
|
||||
│ ├── Umbraco.Cms.Imaging.ImageSharp/ # Image processing v1
|
||||
│ ├── Umbraco.Cms.Imaging.ImageSharp2/ # Image processing v2
|
||||
│ ├── Umbraco.Cms.StaticAssets/ # Embedded assets
|
||||
│ ├── Umbraco.Cms.DevelopmentMode.Backoffice/
|
||||
│ ├── Umbraco.Cms.Targets/ # NuGet targets
|
||||
│ └── Umbraco.Cms/ # Meta-package
|
||||
│
|
||||
├── tests/ # 6 test projects
|
||||
│ ├── Umbraco.Tests.Common/
|
||||
│ ├── Umbraco.Tests.UnitTests/
|
||||
│ ├── Umbraco.Tests.Integration/
|
||||
│ ├── Umbraco.Tests.Benchmarks/
|
||||
│ ├── Umbraco.Tests.AcceptanceTest/
|
||||
│ └── Umbraco.Tests.AcceptanceTest.UmbracoProject/
|
||||
│
|
||||
├── templates/ # Project templates
|
||||
│ └── Umbraco.Templates/
|
||||
│
|
||||
├── tools/ # Build tools
|
||||
│ └── Umbraco.JsonSchema/
|
||||
│
|
||||
├── umbraco.sln # Main solution file
|
||||
├── Directory.Build.props # Shared build configuration
|
||||
├── Directory.Packages.props # Centralized package versions
|
||||
├── .editorconfig # Code style
|
||||
└── .globalconfig # Roslyn analyzers
|
||||
```
|
||||
|
||||
### Architecture Layers
|
||||
|
||||
**Dependency Flow** (unidirectional, always flows inward):
|
||||
|
||||
```
|
||||
Web.UI → Web.Common → Infrastructure → Core
|
||||
↓
|
||||
Api.Management → Api.Common → Infrastructure → Core
|
||||
↓
|
||||
Api.Delivery → Api.Common → Infrastructure → Core
|
||||
```
|
||||
|
||||
**Key Principle**: Core has NO dependencies (pure contracts). Infrastructure implements Core. Web/APIs depend on Infrastructure.
|
||||
|
||||
### Project Dependencies
|
||||
|
||||
**Core Layer**:
|
||||
- `Umbraco.Core` → No dependencies (only Microsoft.Extensions.*)
|
||||
|
||||
**Infrastructure Layer**:
|
||||
- `Umbraco.Infrastructure` → `Umbraco.Core`
|
||||
- `Umbraco.PublishedCache.*` → `Umbraco.Infrastructure`
|
||||
- `Umbraco.Examine.Lucene` → `Umbraco.Infrastructure`
|
||||
- `Umbraco.Cms.Persistence.*` → `Umbraco.Infrastructure`
|
||||
|
||||
**Web Layer**:
|
||||
- `Umbraco.Web.Common` → `Umbraco.Infrastructure` + caching + search
|
||||
- `Umbraco.Web.UI` → `Umbraco.Web.Common` + all features
|
||||
|
||||
**API Layer**:
|
||||
- `Umbraco.Cms.Api.Common` → `Umbraco.Web.Common`
|
||||
- `Umbraco.Cms.Api.Management` → `Umbraco.Cms.Api.Common`
|
||||
- `Umbraco.Cms.Api.Delivery` → `Umbraco.Cms.Api.Common`
|
||||
|
||||
---
|
||||
|
||||
## 3. Teamwork & Collaboration
|
||||
|
||||
### Branching Strategy
|
||||
|
||||
- **Main branch**: `main` (protected)
|
||||
- **Branch naming convention**: `v<version>/<type>/<description>`
|
||||
|
||||
**Format**: `v{major-version}/{type}/{kebab-case-description}`
|
||||
|
||||
**Version**: Read from `version.json` in the repository root. Use the major version number (e.g., `v17` for version 17.x.x).
|
||||
|
||||
**Types**:
|
||||
| Type | Use Case |
|
||||
|------|----------|
|
||||
| `feature` | New feature being introduced to the product |
|
||||
| `bugfix` | Fix to an existing issue with the product |
|
||||
| `qa` | Adding or updating unit, integration, or end-to-end tests |
|
||||
| `improvement` | Update to something that already exists but isn't broken (UI finessing, refactoring) |
|
||||
| `task` | Update that doesn't directly impact product behavior (dependency updates, build pipeline) |
|
||||
|
||||
**Description**: A short, kebab-case description (a few words). This should be prefixed with the GitHub issue number if the update is related to resolving a tracked issue.
|
||||
|
||||
**Examples**:
|
||||
```
|
||||
v17/bugfix/12345-correct-display-of-pending-migrations
|
||||
v17/feature/add-webhook-support
|
||||
v17/improvement/optimize-content-cache
|
||||
v17/qa/add-media-service-tests
|
||||
v17/task/update-ef-core-dependency
|
||||
```
|
||||
|
||||
See `.github/CONTRIBUTING.md` for full guidelines.
|
||||
|
||||
### Pull Request Process
|
||||
|
||||
- **PR Template**: `.github/pull_request_template.md`
|
||||
- **Required CI Checks**:
|
||||
- All tests pass
|
||||
- Code formatting (dotnet format)
|
||||
- No build warnings
|
||||
- **Merge Strategy**: Squash and merge (via GitHub UI)
|
||||
- **Reviews**: Required from code owners
|
||||
|
||||
#### PR Naming Convention
|
||||
|
||||
Use the format: `Area: Description (closes #IssueID)`
|
||||
|
||||
**Examples**:
|
||||
| Area | Description | Issue |
|
||||
|------|-------------|-------|
|
||||
| Relations: | Move persistence of relations from repository into notification handlers | (closes #00000) |
|
||||
| Management API: | Correct the population of the parent for sibling items when retrieved under a folder | |
|
||||
| Docs: | Updated contributing guidelines to welcome contributions on bugfixes | |
|
||||
|
||||
**Area**: The feature or aspect affected (e.g., UFM, TipTap, Docs, Segmentation, Migrations). Helps readers quickly understand what is being changed.
|
||||
|
||||
**Description Best Practices**:
|
||||
- Include the area of change (Relations, Management API, etc.)
|
||||
- Describe the change and its impact
|
||||
- Be specific, not vague (describe "a golden retriever" not just "a dog")
|
||||
|
||||
**Issue Linking**: Add `(closes #IssueID)` to auto-close linked issues on merge.
|
||||
|
||||
### Commit Messages
|
||||
|
||||
Follow Conventional Commits format:
|
||||
```
|
||||
<type>(<scope>): <description>
|
||||
|
||||
Types: feat, fix, docs, style, refactor, test, chore
|
||||
Scope: project name (core, web, api, etc.)
|
||||
|
||||
Examples:
|
||||
feat(core): add IContentService.GetByIds method
|
||||
fix(api): resolve null reference in schema handler
|
||||
docs(web): update routing documentation
|
||||
```
|
||||
|
||||
### Code Owners
|
||||
|
||||
Project ownership is distributed across teams. Check individual project directories for ownership.
|
||||
|
||||
---
|
||||
|
||||
## 4. Architecture Patterns
|
||||
|
||||
### Core Architectural Decisions
|
||||
|
||||
1. **Layered Architecture with Dependency Inversion**
|
||||
- Core defines contracts (interfaces)
|
||||
- Infrastructure implements contracts
|
||||
- Web/APIs consume implementations via DI
|
||||
|
||||
2. **Interface-First Design**
|
||||
- All services defined as interfaces in Core
|
||||
- Enables testing, polymorphism, extensibility
|
||||
|
||||
3. **Notification Pattern** (not C# events)
|
||||
- See `/src/Umbraco.Core/CLAUDE.md` → "2. Notification System (Event Handling)"
|
||||
|
||||
4. **Composer Pattern** (DI registration)
|
||||
- See `/src/Umbraco.Core/CLAUDE.md` → "3. Composer Pattern (DI Registration)"
|
||||
|
||||
5. **Scoping Pattern** (Unit of Work)
|
||||
- See `/src/Umbraco.Core/CLAUDE.md` → "5. Scoping Pattern (Unit of Work)"
|
||||
|
||||
6. **Attempt Pattern** (operation results)
|
||||
- `Attempt<TResult, TStatus>` instead of exceptions
|
||||
- Strongly-typed operation status enums
|
||||
|
||||
### Key Design Patterns Used
|
||||
|
||||
- **Repository Pattern** - Data access abstraction
|
||||
- **Unit of Work** - Scoping for transactions
|
||||
- **Builder Pattern** - `ProblemDetailsBuilder` for API errors
|
||||
- **Strategy Pattern** - OpenAPI handlers (schema ID, operation ID)
|
||||
- **Options Pattern** - All configuration via `IOptions<T>`
|
||||
- **Factory Pattern** - Content type factories
|
||||
- **Mediator Pattern** - Notification aggregator
|
||||
|
||||
---
|
||||
|
||||
## 5. Avoiding Breaking Changes
|
||||
|
||||
No binary breaking changes are allowed within a major version. Three patterns are used:
|
||||
|
||||
### 5.1 Obsolete Constructor + StaticServiceProvider
|
||||
|
||||
When a public class needs new dependencies, obsolete the existing constructor and add a new one. The old constructor delegates to the new one, resolving missing deps via `StaticServiceProvider`.
|
||||
|
||||
```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;
|
||||
}
|
||||
```
|
||||
|
||||
**Examples**:
|
||||
- `ContentCollectionPresentationFactory` - added `FlagProviderCollection`
|
||||
- `CacheInstructionService` - added `ILastSyncedManager`, `IRepositoryCacheVersionService`
|
||||
- `DocumentPresentationFactory` - added `FlagProviderCollection`
|
||||
|
||||
**Rules**:
|
||||
- Old constructor marked `[Obsolete("... Scheduled for removal in Umbraco {current-major+2}.")]`
|
||||
- Old constructor calls new constructor via `: this(...)`
|
||||
- Uses `StaticServiceProvider.Instance.GetRequiredService<T>()` for new params only
|
||||
- DI registration must use the NEW constructor (old is for external consumers only)
|
||||
|
||||
### 5.2 Obsolete Method + New Overload
|
||||
|
||||
When a public method signature needs to change, add the new method/overload and obsolete the old. The obsolete method should call the new one with suitable defaults.
|
||||
|
||||
```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
|
||||
}
|
||||
```
|
||||
|
||||
**Rules**:
|
||||
- Old method marked `[Obsolete]` with removal schedule
|
||||
- DRY: old method calls new method, providing defaults for new parameters
|
||||
- All internal callers must be updated to use the new method
|
||||
- No callers should remain on the obsolete method within the codebase
|
||||
|
||||
### 5.3 Default Interface Implementation
|
||||
|
||||
When adding methods to a public interface, provide a default implementation so existing external implementations don't break.
|
||||
|
||||
```csharp
|
||||
public interface IMyService
|
||||
{
|
||||
// Existing method
|
||||
void ExistingMethod();
|
||||
|
||||
// New method with default implementation
|
||||
void NewMethod(string param)
|
||||
=> ExistingMethod(); // delegate to existing if possible
|
||||
}
|
||||
```
|
||||
|
||||
**Strategies for the default** (in order of preference):
|
||||
1. **Use existing interface methods** to satisfy the contract (even if not optimal)
|
||||
2. **Return a sensible default** like empty collection, null, etc.
|
||||
3. **Throw `NotImplementedException`** if no reasonable default exists
|
||||
|
||||
**Example**: `IContentService.SaveBlueprint` - new overload with `IContent? createdFromContent` has a default impl that calls the old method (ignoring the new param).
|
||||
|
||||
**Example**: `IDocumentPresentationFactory.CreateCulturePublishScheduleModels` - full default implementation with logic, uses `StaticServiceProvider` for dependency resolution within the interface.
|
||||
|
||||
**Rules**:
|
||||
- Add `// TODO (V{next-major}): Remove the default implementation when {obsolete method} is removed.` comment
|
||||
- Default impl should be functionally correct even if not optimal
|
||||
- If using `StaticServiceProvider` in a default impl, note this is temporary
|
||||
|
||||
### 5.4 General Rules
|
||||
|
||||
- **Removal policy**: Obsoleted members must remain for at least one full major version before removal. If obsoleted in version N, the earliest removal is version N+2. For example, something obsoleted in v17 is scheduled for removal in v19 (giving the whole of v18 as a deprecation period).
|
||||
- All `[Obsolete]` attributes must include **"Scheduled for removal in Umbraco {current+2}"**
|
||||
- Read `version.json` to determine the current major version
|
||||
- Suppress `CS0618` warnings where obsolete members must call each other:
|
||||
```csharp
|
||||
#pragma warning disable CS0618 // Type or member is obsolete
|
||||
=> OldMethod(param);
|
||||
#pragma warning restore CS0618 // Type or member is obsolete
|
||||
```
|
||||
- Update ALL internal callers to use the new API - no internal code should use obsolete members
|
||||
|
||||
---
|
||||
|
||||
## 6. Project-Specific Notes
|
||||
|
||||
### Centralized Package Management
|
||||
|
||||
**All NuGet package versions** are centralized in `Directory.Packages.props`. Individual projects do NOT specify versions.
|
||||
|
||||
```xml
|
||||
<!-- Individual projects reference WITHOUT version -->
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" />
|
||||
|
||||
<!-- Versions defined in Directory.Packages.props -->
|
||||
<PackageVersion Include="Swashbuckle.AspNetCore" Version="6.5.0" />
|
||||
```
|
||||
|
||||
### Build Configuration
|
||||
|
||||
- `Directory.Build.props` - Shared properties (target framework, company, copyright)
|
||||
- `.editorconfig` - Code style rules
|
||||
- `.globalconfig` - Roslyn analyzer rules
|
||||
|
||||
### Persistence Layer - NPoco and EF Core
|
||||
|
||||
The repository contains BOTH (actively supported):
|
||||
- **Current**: NPoco-based persistence (`Umbraco.Cms.Persistence.Sqlite`, `Umbraco.Cms.Persistence.SqlServer`) - widely used and fully supported
|
||||
- **Future**: EF Core-based persistence (`Umbraco.Cms.Persistence.EFCore.*`) - migration in progress
|
||||
|
||||
**Note**: The codebase is actively migrating to EF Core, but NPoco remains the primary persistence layer and is not deprecated. Both are fully supported.
|
||||
|
||||
### Authentication: OpenIddict
|
||||
|
||||
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)
|
||||
- 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`):
|
||||
- In-memory cache + distributed cache support
|
||||
- Published content only (not draft)
|
||||
- Invalidated via notifications and cache refreshers
|
||||
|
||||
### API Versioning
|
||||
|
||||
APIs use `Asp.Versioning.Mvc`:
|
||||
- Management API: `/umbraco/management/api/v{version}/*`
|
||||
- Delivery API: `/umbraco/delivery/api/v{version}/*`
|
||||
- OpenAPI/Swagger docs per version
|
||||
|
||||
### Backoffice npm Package Structure
|
||||
|
||||
The backoffice (`Umbraco.Web.UI.Client`) is published to npm as **`@umbraco-cms/backoffice`** with a plugin architecture:
|
||||
|
||||
#### Architecture Overview
|
||||
|
||||
- **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
|
||||
|
||||
#### Dependency Hoisting Strategy
|
||||
|
||||
When building for npm (`npm pack`), the `cleanse-pkg.js` script hoists subproject dependencies to root `peerDependencies` with intelligent version range conversion:
|
||||
|
||||
**Version Range Logic** (uses `semver` package):
|
||||
|
||||
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`
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
1. **Circular Dependencies**: Avoided via `Lazy<T>` or event notifications
|
||||
2. **Multi-Server**: Requires shared Data Protection key ring and synchronized clocks (NTP)
|
||||
3. **Database Support**: SQL Server, SQLite
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### Essential Commands
|
||||
|
||||
```bash
|
||||
# Build solution
|
||||
dotnet build
|
||||
|
||||
# Run all tests
|
||||
dotnet test
|
||||
|
||||
# Run specific test category
|
||||
dotnet test --filter "Category=Integration"
|
||||
|
||||
# Format code
|
||||
dotnet format
|
||||
|
||||
# Pack all projects
|
||||
dotnet pack -c Release
|
||||
```
|
||||
|
||||
### Key Projects
|
||||
|
||||
| Project | Type | Description |
|
||||
|---------|------|-------------|
|
||||
| **Umbraco.Core** | Library | Interface contracts and domain models |
|
||||
| **Umbraco.Infrastructure** | Library | Service implementations and data access |
|
||||
| **Umbraco.Web.UI** | Application | Main web application (Razor/MVC) |
|
||||
| **Umbraco.Cms.Api.Management** | Library | Management API (backoffice) |
|
||||
| **Umbraco.Cms.Api.Delivery** | Library | Delivery API (headless CMS) |
|
||||
| **Umbraco.Cms.Api.Common** | Library | Shared API infrastructure |
|
||||
| **Umbraco.PublishedCache.HybridCache** | Library | Published content caching |
|
||||
| **Umbraco.Examine.Lucene** | Library | Full-text search indexing |
|
||||
|
||||
### Important Files
|
||||
|
||||
- **Solution**: `umbraco.sln`
|
||||
- **Build Config**: `Directory.Build.props`, `Directory.Packages.props`
|
||||
- **Code Style**: `.editorconfig`, `.globalconfig`
|
||||
- **Documentation**: `/CLAUDE.md`, `/src/Umbraco.Core/CLAUDE.md`, `/src/Umbraco.Cms.Api.Common/CLAUDE.md`
|
||||
|
||||
### Project-Specific Documentation
|
||||
|
||||
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
|
||||
|
||||
### Getting Help
|
||||
|
||||
- **Official Docs**: https://docs.umbraco.com/
|
||||
- **Contributing Guide**: `.github/CONTRIBUTING.md`
|
||||
- **Issues**: https://github.com/umbraco/Umbraco-CMS/issues
|
||||
- **Community**: https://forum.umbraco.com/
|
||||
- **Releases**: https://releases.umbraco.com/
|
||||
|
||||
---
|
||||
|
||||
**This repository follows a layered architecture with strict dependency rules. The Core defines contracts, Infrastructure implements them, and Web/APIs consume them. Each layer can be understood independently, but dependencies always flow inward toward Core.**
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Company>Umbraco HQ</Company>
|
||||
<Authors>Umbraco</Authors>
|
||||
<Copyright>Copyright © Umbraco $([System.DateTime]::Today.ToString('yyyy'))</Copyright>
|
||||
@@ -41,7 +41,7 @@
|
||||
<PropertyGroup>
|
||||
<GenerateCompatibilitySuppressionFile>false</GenerateCompatibilitySuppressionFile>
|
||||
<EnablePackageValidation>true</EnablePackageValidation>
|
||||
<PackageValidationBaselineVersion>17.0.0</PackageValidationBaselineVersion>
|
||||
<PackageValidationBaselineVersion>16.0.0</PackageValidationBaselineVersion>
|
||||
<EnableStrictModeForCompatibleFrameworksInPackage>true</EnableStrictModeForCompatibleFrameworksInPackage>
|
||||
<EnableStrictModeForCompatibleTfms>true</EnableStrictModeForCompatibleTfms>
|
||||
</PropertyGroup>
|
||||
|
||||
+59
-48
@@ -2,39 +2,39 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||
<CentralPackageTransitivePinningEnabled>true</CentralPackageTransitivePinningEnabled>
|
||||
</PropertyGroup>
|
||||
<!-- Global packages (private, build-time packages for all projects) -->
|
||||
<ItemGroup>
|
||||
<GlobalPackageReference Include="Nerdbank.GitVersioning" Version="3.9.50" />
|
||||
<GlobalPackageReference Include="Nerdbank.GitVersioning" Version="3.7.115" />
|
||||
<GlobalPackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.556" />
|
||||
<GlobalPackageReference Include="Umbraco.Code" Version="2.4.0" />
|
||||
<GlobalPackageReference Include="Umbraco.Code" Version="2.3.0" />
|
||||
<GlobalPackageReference Include="Umbraco.GitVersioning.Extensions" Version="0.2.0" />
|
||||
</ItemGroup>
|
||||
<!-- Microsoft packages -->
|
||||
<ItemGroup>
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation" Version="10.0.4" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="4.14.0" />
|
||||
<PackageVersion Include="Microsoft.Data.Sqlite" Version="10.0.4" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.4" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.4" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.4" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.4" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.4" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="10.0.4" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.4" />
|
||||
<PackageVersion Include="Microsoft.Extensions.FileProviders.Embedded" Version="10.0.4" />
|
||||
<PackageVersion Include="Microsoft.Extensions.FileProviders.Physical" Version="10.0.4" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.4" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.4" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Identity.Core" Version="10.0.4" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Identity.Stores" Version="10.0.4" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.4" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.4" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.4" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Options.DataAnnotations" Version="10.0.4" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Caching.Hybrid" Version="10.4.0" />
|
||||
<PackageVersion Include="System.Linq.Async" Version="7.0.0" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation" Version="9.0.4" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="4.13.0" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="4.13.0" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.Workspaces.MSBuild" Version="4.13.0" />
|
||||
<PackageVersion Include="Microsoft.Data.Sqlite" Version="9.0.4" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.4" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.SqlServer" Version="9.0.4" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="9.0.4" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="9.0.4" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.4" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="9.0.4" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="9.0.4" />
|
||||
<PackageVersion Include="Microsoft.Extensions.FileProviders.Embedded" Version="9.0.4" />
|
||||
<PackageVersion Include="Microsoft.Extensions.FileProviders.Physical" Version="9.0.4" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="9.0.4" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Http" Version="9.0.4" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Identity.Core" Version="9.0.4" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Identity.Stores" Version="9.0.4" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging" Version="9.0.4" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Options" Version="9.0.4" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="9.0.4" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Options.DataAnnotations" Version="9.0.4" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Caching.Hybrid" Version="9.4.0" />
|
||||
</ItemGroup>
|
||||
<!-- Umbraco packages -->
|
||||
<ItemGroup>
|
||||
@@ -42,27 +42,26 @@
|
||||
</ItemGroup>
|
||||
<!-- Third-party packages -->
|
||||
<ItemGroup>
|
||||
<PackageVersion Include="Asp.Versioning.Mvc" Version="8.1.1" />
|
||||
<PackageVersion Include="Asp.Versioning.Mvc.ApiExplorer" Version="8.1.1" />
|
||||
<PackageVersion Include="Asp.Versioning.Mvc" Version="8.1.0" />
|
||||
<PackageVersion Include="Asp.Versioning.Mvc.ApiExplorer" Version="8.1.0" />
|
||||
<PackageVersion Include="Dazinator.Extensions.FileProviders" Version="2.0.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="HtmlAgilityPack" Version="1.12.1" />
|
||||
<PackageVersion Include="JsonPatch.Net" Version="3.3.0" />
|
||||
<PackageVersion Include="K4os.Compression.LZ4" Version="1.3.8" />
|
||||
<PackageVersion Include="MailKit" Version="4.15.1" />
|
||||
<PackageVersion Include="Markdig" Version="0.45.0" />
|
||||
<PackageVersion Include="MailKit" Version="4.11.0" />
|
||||
<PackageVersion Include="Markdown" Version="2.2.1" />
|
||||
<PackageVersion Include="MessagePack" Version="3.1.4" />
|
||||
<PackageVersion Include="MessagePack" Version="3.1.3" />
|
||||
<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.2.0" />
|
||||
<PackageVersion Include="OpenIddict.AspNetCore" Version="7.2.0" />
|
||||
<PackageVersion Include="OpenIddict.EntityFrameworkCore" Version="7.2.0" />
|
||||
<PackageVersion Include="Serilog" Version="4.3.1" />
|
||||
<PackageVersion Include="ncrontab" Version="3.3.3" />
|
||||
<PackageVersion Include="NPoco" Version="5.7.1" />
|
||||
<PackageVersion Include="NPoco.SqlServer" Version="5.7.1" />
|
||||
<PackageVersion Include="OpenIddict.Abstractions" Version="6.2.1" />
|
||||
<PackageVersion Include="OpenIddict.AspNetCore" Version="6.2.1" />
|
||||
<PackageVersion Include="OpenIddict.EntityFrameworkCore" Version="6.2.1" />
|
||||
<PackageVersion Include="Serilog" Version="4.2.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" />
|
||||
@@ -72,21 +71,33 @@
|
||||
<PackageVersion Include="Serilog.Formatting.Compact.Reader" Version="4.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.File" Version="6.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" />
|
||||
<!-- When updating this version, also update templates/UmbracoExtension/Umbraco.Extension.csproj -->
|
||||
<PackageVersion Include="Swashbuckle.AspNetCore" Version="10.1.4" />
|
||||
<PackageVersion Include="SixLabors.ImageSharp" Version="3.1.11" />
|
||||
<PackageVersion Include="SixLabors.ImageSharp.Web" Version="3.1.5" />
|
||||
<PackageVersion Include="Swashbuckle.AspNetCore" Version="8.1.1" />
|
||||
</ItemGroup>
|
||||
<!-- Transitive pinned versions (only required because our direct dependencies have vulnerable versions of transitive dependencies) -->
|
||||
<ItemGroup>
|
||||
<!-- Dazinator.Extensions.FileProviders references vulnerable versions of the following: -->
|
||||
<!-- TODO (V18): Remove these pinned dependencies when the Dazinator.Extensions.FileProviders dependency is removed. -->
|
||||
<!-- Microsoft.EntityFrameworkCore.SqlServer and NPoco.SqlServer brings in a vulnerable version of Azure.Identity -->
|
||||
<!-- Take top-level depedendency on Azure.Identity, because Microsoft.EntityFrameworkCore.SqlServer depends on a vulnerable version -->
|
||||
<PackageVersion Include="Azure.Identity" Version="1.13.2" />
|
||||
<!-- Microsoft.EntityFrameworkCore.SqlServer brings in a vulnerable version of System.Runtime.Caching -->
|
||||
<PackageVersion Include="System.Runtime.Caching" Version="9.0.4" />
|
||||
<!-- Dazinator.Extensions.FileProviders brings in a vulnerable version of System.Net.Http -->
|
||||
<PackageVersion Include="System.Net.Http" Version="4.3.4" />
|
||||
<PackageVersion Include="System.Private.Uri" Version="4.3.2" />
|
||||
<!-- Markdown references vulnerable version of the following: -->
|
||||
<!-- TODO (V19): Remove these pinned dependencies when the Markdown dependency is removed. -->
|
||||
<!-- Examine brings in a vulnerable version of System.Security.Cryptography.Xml -->
|
||||
<PackageVersion Include="System.Security.Cryptography.Xml" Version="9.0.4" />
|
||||
<!-- Dazinator.Extensions.FileProviders and MiniProfiler.AspNetCore.Mvc brings in a vulnerable version of System.Text.RegularExpressions -->
|
||||
<PackageVersion Include="System.Text.RegularExpressions" Version="4.3.1" />
|
||||
<!-- OpenIddict.AspNetCore, Npoco.SqlServer and Microsoft.EntityFrameworkCore.SqlServer brings in a vulnerable version of Microsoft.IdentityModel.JsonWebTokens -->
|
||||
<!-- Take top-level depedendency on Microsoft.IdentityModel.JsonWebTokens, because OpenIddict.AspNetCore, Npoco.SqlServer and Microsoft.EntityFrameworkCore.SqlServer depends on a vulnerable version -->
|
||||
<PackageVersion Include="Microsoft.IdentityModel.JsonWebTokens" Version="8.8.0" />
|
||||
<!-- Azure.Identity, Microsoft.EntityFrameworkCore.SqlServer and Dazinator.Extensions.FileProviders brings in a legacy version of System.Text.Encodings.Web -->
|
||||
<PackageVersion Include="System.Text.Encodings.Web" Version="9.0.4" />
|
||||
<!-- NPoco.SqlServer brings in a vulnerable version of Microsoft.Data.SqlClient -->
|
||||
<PackageVersion Include="Microsoft.Data.SqlClient" Version="6.0.1" />
|
||||
<!-- Examine.Lucene brings in a vulnerable version of Lucene.Net.Replicator -->
|
||||
<PackageVersion Include="Lucene.Net.Replicator" Version="4.8.0-beta00017" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
# MCP (Model Context Protocol) Setup
|
||||
|
||||
This repository includes configuration for [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) servers, enabling AI tooling integration for Umbraco CMS development workflows.
|
||||
|
||||
## Overview
|
||||
|
||||
MCP allows AI assistants (like Claude) to interact with external tools and services. This repository configures two MCP servers:
|
||||
|
||||
| Server | Purpose | Package |
|
||||
|--------|---------|---------|
|
||||
| **umbraco-cms** | Manage Umbraco content types, documents, and media | `@umbraco-cms/mcp-dev@17` |
|
||||
| **playwright** | Browser automation for testing and debugging | `@playwright/mcp@latest` |
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Start Umbraco Locally
|
||||
|
||||
Ensure your local Umbraco instance is running at `https://localhost:44339` (or update the URL in your `.env.local`).
|
||||
|
||||
### 2. Configure Environment Variables
|
||||
|
||||
Copy the example environment file and customize it:
|
||||
|
||||
```bash
|
||||
cp .env.example .env.local
|
||||
```
|
||||
|
||||
Edit `.env.local` with your local settings:
|
||||
|
||||
```env
|
||||
UMBRACO_CLIENT_ID=umbraco-back-office-mcp
|
||||
UMBRACO_CLIENT_SECRET=<your-client-secret>
|
||||
UMBRACO_BASE_URL=https://localhost:44339
|
||||
NODE_TLS_REJECT_UNAUTHORIZED=0
|
||||
UMBRACO_INCLUDE_TOOL_COLLECTIONS=data-type,document-type,document,media-type,media
|
||||
```
|
||||
|
||||
### 3. Configure the OAuth Client in Umbraco
|
||||
|
||||
Create an OAuth client in your Umbraco instance with:
|
||||
- **Client ID**: `umbraco-back-office-mcp`
|
||||
- **Client Secret**: The value you set in `.env.local`
|
||||
- **Grant Type**: Client Credentials
|
||||
|
||||
## Environment Variables Reference
|
||||
|
||||
| Variable | Description | Example |
|
||||
|----------|-------------|---------|
|
||||
| `UMBRACO_CLIENT_ID` | OAuth client ID configured in Umbraco | `umbraco-back-office-mcp` |
|
||||
| `UMBRACO_CLIENT_SECRET` | OAuth client secret (keep secure!) | `your-secure-secret` |
|
||||
| `UMBRACO_BASE_URL` | URL of your local Umbraco instance | `https://localhost:44339` |
|
||||
| `NODE_TLS_REJECT_UNAUTHORIZED` | Set to `0` for self-signed certificates (local dev only) | `0` |
|
||||
| `UMBRACO_INCLUDE_TOOL_COLLECTIONS` | Comma-separated list of tool collections to enable | `data-type,document-type,document` |
|
||||
|
||||
### Tool Collections
|
||||
|
||||
The `UMBRACO_INCLUDE_TOOL_COLLECTIONS` variable controls which Umbraco MCP tools are available:
|
||||
|
||||
- `data-type` - Manage data types (property editors)
|
||||
- `document-type` - Manage document types (content types)
|
||||
- `document` - Manage content/documents
|
||||
- `media-type` - Manage media types
|
||||
- `media` - Manage media items
|
||||
|
||||
## Security Considerations
|
||||
|
||||
> **Warning**: This configuration is for **local development only**.
|
||||
|
||||
### Self-Signed Certificates
|
||||
|
||||
`NODE_TLS_REJECT_UNAUTHORIZED=0` disables SSL certificate validation. This is necessary for self-signed certificates in local development but:
|
||||
|
||||
- **Never use in production**
|
||||
- Affects all HTTPS connections made by Node.js processes
|
||||
- Consider trusting your local development certificate instead
|
||||
|
||||
### Client Secrets
|
||||
|
||||
- Never commit real secrets to source control
|
||||
- The `.env.local` file is gitignored for this reason
|
||||
- Use strong, unique secrets even in development
|
||||
- The example value `1234567890` in `.env.example` is a placeholder only
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
Umbraco-CMS/
|
||||
├── .mcp.json # MCP server configuration
|
||||
├── .env.example # Example environment variables (committed)
|
||||
├── .env.local # Your local environment variables (gitignored)
|
||||
├── .claude/
|
||||
│ ├── settings.json # Shared Claude AI permissions (committed)
|
||||
│ └── settings.local.json # Local Claude overrides (gitignored)
|
||||
├── .gitignore # Ignores .env.local and settings.local.json
|
||||
└── MCP.md # This documentation (you are here)
|
||||
```
|
||||
|
||||
## Claude AI Permissions
|
||||
|
||||
The `.claude/settings.json` file configures which MCP tools Claude can use automatically without prompting. This is shared across the team for consistent developer experience.
|
||||
|
||||
### Customizing Permissions Locally
|
||||
|
||||
Create `.claude/settings.local.json` to override permissions for your environment:
|
||||
|
||||
```json
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"mcp__umbraco__get-all-document-types"
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Connection refused" errors
|
||||
|
||||
- Ensure Umbraco is running at the configured `UMBRACO_BASE_URL`
|
||||
- Check that the port matches your local setup
|
||||
|
||||
### "Unauthorized" errors
|
||||
|
||||
- Verify the OAuth client is configured in Umbraco
|
||||
- Check that `UMBRACO_CLIENT_ID` and `UMBRACO_CLIENT_SECRET` match
|
||||
- Ensure the client has appropriate permissions
|
||||
|
||||
### "Certificate" errors
|
||||
|
||||
- For local development, set `NODE_TLS_REJECT_UNAUTHORIZED=0` in `.env.local`
|
||||
- Alternatively, trust your local development certificate
|
||||
|
||||
### MCP server not starting
|
||||
|
||||
- Ensure Node.js is installed (v22+ recommended, matching .nvmrc)
|
||||
- Run `npx @umbraco-cms/mcp-dev@17 --help` to verify the package works
|
||||
|
||||
## Further Reading
|
||||
|
||||
- [Model Context Protocol Documentation](https://modelcontextprotocol.io/)
|
||||
- [Umbraco MCP Package](https://www.npmjs.com/package/@umbraco-cms/mcp-dev)
|
||||
- [Playwright MCP](https://www.npmjs.com/package/@playwright/mcp)
|
||||
- [Claude Code Documentation](https://docs.anthropic.com/claude-code)
|
||||
@@ -196,14 +196,6 @@ Copyright: 2013-2024 .NET Foundation and Contributors
|
||||
|
||||
---
|
||||
|
||||
Markdig: A fast, powerful, CommonMark compliant, extensible Markdown processor for .NET
|
||||
|
||||
URL: https://github.com/xoofx/markdig
|
||||
License: BSD-2-Clause license
|
||||
Copyright: 2018+, Alexandre Mutel. All rights reserved.
|
||||
|
||||
---
|
||||
|
||||
Markdown: A library for parsing and compiling Markdown
|
||||
|
||||
URL: https://github.com/hey-red/Markdown
|
||||
|
||||
+14
-123
@@ -107,17 +107,9 @@ stages:
|
||||
command: build
|
||||
projects: $(solution)
|
||||
arguments: "--configuration $(buildConfiguration) --no-restore --property:ContinuousIntegrationBuild=true --property:GeneratePackageOnBuild=true --property:PackageOutputPath=$(Build.ArtifactStagingDirectory)/nupkg"
|
||||
# Publish compiled DLLs for C# API documentation generation
|
||||
# Separate artifact to avoid increasing build_output size for all builds
|
||||
- task: PublishPipelineArtifact@1
|
||||
displayName: Publish DocFX DLLs
|
||||
condition: and(succeeded(), or(eq(variables['build.NBGV_PublicRelease'], 'True'), eq('${{ parameters.buildApiDocs }}', 'True')))
|
||||
inputs:
|
||||
targetPath: $(Build.SourcesDirectory)/src/Umbraco.Cms/bin/Release
|
||||
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,41 +167,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 }}
|
||||
- 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 tests/Umbraco.Tests.AcceptanceTest
|
||||
npm version $PACKAGE_VERSION --allow-same-version --no-git-tag-version
|
||||
displayName: Set NPM Version
|
||||
- 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: []
|
||||
@@ -243,22 +200,12 @@ stages:
|
||||
variables:
|
||||
umbracoMajorVersion: $[ stageDependencies.Build.A.outputs['build.NBGV_VersionMajor'] ]
|
||||
jobs:
|
||||
# C# API Reference - uses pre-compiled DLLs for faster generation (csproj approach caused timeouts)
|
||||
# C# API Reference
|
||||
- job:
|
||||
displayName: Build C# API Reference
|
||||
pool:
|
||||
vmImage: "windows-latest"
|
||||
steps:
|
||||
- checkout: self
|
||||
submodules: false
|
||||
lfs: false
|
||||
fetchDepth: 1
|
||||
fetchFilter: tree:0
|
||||
- task: DownloadPipelineArtifact@2
|
||||
displayName: Download DocFX DLLs
|
||||
inputs:
|
||||
artifact: csharp-docs-dlls
|
||||
path: $(Build.SourcesDirectory)/src/Umbraco.Cms/bin/Release
|
||||
- task: UseDotNet@2
|
||||
displayName: Use .NET SDK from global.json
|
||||
inputs:
|
||||
@@ -268,7 +215,7 @@ stages:
|
||||
inputs:
|
||||
targetType: inline
|
||||
script: |
|
||||
dotnet tool install -g docfx --version 2.78.4
|
||||
choco install docfx --version=2.59.4 -y
|
||||
if ($lastexitcode -ne 0){
|
||||
throw ("Error installing DocFX")
|
||||
}
|
||||
@@ -418,8 +365,8 @@ stages:
|
||||
testFilter: "(FullyQualifiedName~Umbraco.Infrastructure.Service)"
|
||||
LinuxPart3Of3:
|
||||
vmImage: "ubuntu-latest"
|
||||
# Filter tests that are not part of the Umbraco.Infrastructure and ManagementApi namespace. So this will run all tests that are not part of the Umbraco.Infrastructure and ManagementApi namespace
|
||||
testFilter: "(FullyQualifiedName!~Umbraco.Infrastructure) & (FullyQualifiedName!~ManagementApi)"
|
||||
# Filter tests that are not part of the Umbraco.Infrastructure namespace. So this will run all tests that are not part of the Umbraco.Infrastructure namespace
|
||||
testFilter: "(FullyQualifiedName!~Umbraco.Infrastructure)"
|
||||
macOSPart1Of3:
|
||||
vmImage: "macOS-latest"
|
||||
# Filter tests that are part of the Umbraco.Infrastructure namespace but not part of the Umbraco.Infrastructure.Service namespace
|
||||
@@ -430,8 +377,8 @@ stages:
|
||||
testFilter: "(FullyQualifiedName~Umbraco.Infrastructure.Service)"
|
||||
macOSPart3Of3:
|
||||
vmImage: "macOS-latest"
|
||||
# Filter tests that are not part of the Umbraco.Infrastructure and ManagementApi namespace. So this will run all tests that are not part of the Umbraco.Infrastructure and the ManagementApi namespace
|
||||
testFilter: "(FullyQualifiedName!~Umbraco.Infrastructure) & (FullyQualifiedName!~ManagementApi)"
|
||||
# Filter tests that are not part of the Umbraco.Infrastructure namespace.
|
||||
testFilter: "(FullyQualifiedName!~Umbraco.Infrastructure)"
|
||||
pool:
|
||||
vmImage: $(vmImage)
|
||||
variables:
|
||||
@@ -494,8 +441,8 @@ stages:
|
||||
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. So this will run all tests that are not part of the Umbraco.Infrastructure and ManagementApi namespace
|
||||
testFilter: "(FullyQualifiedName!~Umbraco.Infrastructure) & (FullyQualifiedName!~ManagementApi)"
|
||||
# Filter tests that are not part of the Umbraco.Infrastructure namespace. So this will run all tests that are not part of the Umbraco.Infrastructure namespace
|
||||
testFilter: "(FullyQualifiedName!~Umbraco.Infrastructure)"
|
||||
LinuxPart1Of3:
|
||||
vmImage: "ubuntu-latest"
|
||||
SA_PASSWORD: UmbracoIntegration123!
|
||||
@@ -515,8 +462,8 @@ stages:
|
||||
SA_PASSWORD: UmbracoIntegration123!
|
||||
Tests__Database__DatabaseType: SqlServer
|
||||
Tests__Database__SQLServerMasterConnectionString: "Server=(local);User Id=sa;Password=$(SA_PASSWORD);TrustServerCertificate=True"
|
||||
# Filter tests that are not part of the Umbraco.Infrastructure and ManagementApi namespace. So this will run all tests that are not part of the Umbraco.Infrastructure and ManagementApi namespace
|
||||
testFilter: "(FullyQualifiedName!~Umbraco.Infrastructure) & (FullyQualifiedName!~ManagementApi)"
|
||||
# Filter tests that are not part of the Umbraco.Infrastructure namespace. So this will run all tests that are not part of the Umbraco.Infrastructure namespace
|
||||
testFilter: "(FullyQualifiedName!~Umbraco.Infrastructure)"
|
||||
pool:
|
||||
vmImage: $(vmImage)
|
||||
steps:
|
||||
@@ -867,44 +814,12 @@ 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
|
||||
dependsOn:
|
||||
- Deploy_MyGet
|
||||
- Build_Docs
|
||||
condition: and(succeeded(), or(eq(dependencies.Build.outputs['A.build.NBGV_PublicRelease'], 'True'), ${{parameters.nuGetDeploy}}))
|
||||
jobs:
|
||||
- job:
|
||||
@@ -955,29 +870,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:
|
||||
@@ -987,7 +879,6 @@ stages:
|
||||
displayName: Upload API Documentation
|
||||
dependsOn:
|
||||
- Build
|
||||
- Build_Docs
|
||||
- Deploy_NuGet
|
||||
condition: and(succeeded(), or(eq(dependencies.Build.outputs['A.build.NBGV_PublicRelease'], 'True'), ${{parameters.uploadApiDocs}}))
|
||||
jobs:
|
||||
|
||||
@@ -3,13 +3,17 @@
|
||||
{
|
||||
"src": [
|
||||
{
|
||||
"src": "../../src/Umbraco.Cms/bin/Release",
|
||||
"src": "../../src",
|
||||
"files": [
|
||||
"**/Umbraco.*.dll"
|
||||
"**/*.csproj"
|
||||
],
|
||||
"exclude": [
|
||||
"**/Umbraco.Cms.StaticAssets.dll",
|
||||
"**/Umbraco.Cms.Targets.dll"
|
||||
"**/obj/**",
|
||||
"**/bin/**",
|
||||
"**/Umbraco.Web.csproj",
|
||||
"**/Umbraco.Web.UI.csproj",
|
||||
"**/Umbraco.Cms.StaticAssets.csproj",
|
||||
"**/JsonSchema.csproj"
|
||||
]
|
||||
}
|
||||
],
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<meta name="generator" content="docfx {{_docfxVersion}}">
|
||||
{{#_description}}<meta name="description" content="{{_description}}">{{/_description}}
|
||||
<link rel="icon" type="image/png" href="https://our.umbraco.com/assets/images/app-icons/favicon.png">
|
||||
<link rel="stylesheet" href="{{_rel}}styles/docfx.vendor.min.css">
|
||||
<link rel="stylesheet" href="{{_rel}}styles/docfx.vendor.css">
|
||||
<link rel="stylesheet" href="{{_rel}}styles/docfx.css">
|
||||
<link rel="stylesheet" href="{{_rel}}styles/main.css">
|
||||
<meta property="docfx:navrel" content="{{_navRel}}">
|
||||
|
||||
@@ -54,17 +54,11 @@ steps:
|
||||
- pwsh: |
|
||||
$sourcePath = "$(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest/tests/${{ parameters.testFolder }}/AdditionalSetup"
|
||||
$destinationPath = "UmbracoProject"
|
||||
$csharpFiles = Get-ChildItem -Path $sourcePath -Filter "*.cs" -Recurse
|
||||
$csharpFiles = Get-ChildItem -Path $sourcePath -Filter "*.cs"
|
||||
if ($csharpFiles) {
|
||||
$csharpFiles | ForEach-Object {
|
||||
$relativePath = $_.FullName.Substring($sourcePath.Length + 1)
|
||||
$targetPath = Join-Path -Path $destinationPath -ChildPath $relativePath
|
||||
$targetDir = Split-Path -Path $targetPath -Parent
|
||||
if (-not (Test-Path -Path $targetDir)) {
|
||||
New-Item -ItemType Directory -Path $targetDir -Force | Out-Null
|
||||
}
|
||||
Write-Host "Copying: $($_.FullName) -> $targetPath"
|
||||
Copy-Item -Path $_.FullName -Destination $targetPath -Force
|
||||
Write-Host "Copying: $($_.FullName)"
|
||||
Copy-Item -Path $_.FullName -Destination $destinationPath -Force
|
||||
}
|
||||
} else {
|
||||
Write-Host "No C# files found."
|
||||
|
||||
@@ -44,7 +44,7 @@ steps:
|
||||
$cmsVersion = "$(Build.BuildNumber)" -replace "\+",".g"
|
||||
dotnet new nugetconfig
|
||||
dotnet nuget add source ./nupkg --name Local
|
||||
dotnet new install Umbraco.Templates@$cmsVersion
|
||||
dotnet new umbraco --name UmbracoProject --exclude-gitignore --no-restore --no-update-check
|
||||
dotnet new install Umbraco.Templates::$cmsVersion
|
||||
dotnet new umbraco --name UmbracoProject --version $cmsVersion --exclude-gitignore --no-restore --no-update-check
|
||||
displayName: Install Template
|
||||
workingDirectory: $(Agent.BuildDirectory)/app
|
||||
|
||||
@@ -4,13 +4,11 @@ pr: none
|
||||
trigger: none
|
||||
|
||||
schedules:
|
||||
- cron: '0 0 * * *'
|
||||
displayName: Daily midnight build
|
||||
- cron: '0 6 * * *'
|
||||
displayName: Daily 6 AM build (v16/dev)
|
||||
branches:
|
||||
include:
|
||||
- v15/dev
|
||||
- v16/dev
|
||||
- main
|
||||
|
||||
parameters:
|
||||
- name: skipIntegrationTests
|
||||
@@ -18,8 +16,8 @@ parameters:
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
- name: skipDifferentAppSettingsAcceptanceTests
|
||||
displayName: Skip acceptance tests with different app settings
|
||||
- name: differentAppSettingsAcceptanceTests
|
||||
displayName: Run acceptance tests with different app settings
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
@@ -28,12 +26,6 @@ parameters:
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
# Can we slow our tests down when running on SQLite? That way we might be able to avoid DB locks
|
||||
- name: skipSqliteAcceptanceTests
|
||||
displayName: Skip SQLite acceptance tests
|
||||
type: boolean
|
||||
default: true
|
||||
|
||||
variables:
|
||||
nodeVersion: 20
|
||||
solution: umbraco.sln
|
||||
@@ -129,39 +121,31 @@ stages:
|
||||
matrix:
|
||||
# Windows:
|
||||
# vmImage: 'windows-latest'
|
||||
# We split the tests into 4 parts for each OS to reduce the time it takes to run them on the pipeline
|
||||
LinuxPart1Of4:
|
||||
# We split the tests into 3 parts for each OS to reduce the time it takes to run them on the pipeline
|
||||
LinuxPart1Of3:
|
||||
vmImage: "ubuntu-latest"
|
||||
# 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)"
|
||||
LinuxPart2Of4:
|
||||
LinuxPart2Of3:
|
||||
vmImage: "ubuntu-latest"
|
||||
# Filter tests that are part of the Umbraco.Infrastructure.Service namespace
|
||||
testFilter: "(FullyQualifiedName~Umbraco.Infrastructure.Service)"
|
||||
LinuxPart3Of4:
|
||||
LinuxPart3Of3:
|
||||
vmImage: "ubuntu-latest"
|
||||
# Filter tests that are not part of the Umbraco.Infrastructure and ManagementApi namespace. So this will run all tests that are not part of the Umbraco.Infrastructure namespace
|
||||
testFilter: "(FullyQualifiedName!~Umbraco.Infrastructure) & (FullyQualifiedName!~ManagementApi)"
|
||||
LinuxPart4Of4:
|
||||
vmImage: "ubuntu-latest"
|
||||
# Filter tests that are part of the ManagementApi namespace
|
||||
testFilter: "(FullyQualifiedName~ManagementApi)"
|
||||
macOSPart1Of4:
|
||||
# Filter tests that are not part of the Umbraco.Infrastructure namespace. So this will run all tests that are not part of the Umbraco.Infrastructure namespace
|
||||
testFilter: "(FullyQualifiedName!~Umbraco.Infrastructure)"
|
||||
macOSPart1Of3:
|
||||
vmImage: "macOS-latest"
|
||||
# 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)"
|
||||
macOSPart2Of4:
|
||||
macOSPart2Of3:
|
||||
vmImage: "macOS-latest"
|
||||
# Filter tests that are part of the Umbraco.Infrastructure.Service namespace
|
||||
testFilter: "(FullyQualifiedName~Umbraco.Infrastructure.Service)"
|
||||
macOSPart3Of4:
|
||||
macOSPart3Of3:
|
||||
vmImage: "macOS-latest"
|
||||
# Filter tests that are not part of the Umbraco.Infrastructure and ManagementApi namespace.
|
||||
testFilter: "(FullyQualifiedName!~Umbraco.Infrastructure) & (FullyQualifiedName!~ManagementApi)"
|
||||
macOSPart4Of4:
|
||||
vmImage: "macOS-latest"
|
||||
# Filter tests that are part of the ManagementApi namespace.
|
||||
testFilter: "(FullyQualifiedName~ManagementApi)"
|
||||
# Filter tests that are not part of the Umbraco.Infrastructure namespace.
|
||||
testFilter: "(FullyQualifiedName!~Umbraco.Infrastructure)"
|
||||
pool:
|
||||
vmImage: $(vmImage)
|
||||
variables:
|
||||
@@ -201,55 +185,43 @@ stages:
|
||||
SA_PASSWORD: UmbracoAcceptance123!
|
||||
strategy:
|
||||
matrix:
|
||||
# We split the tests into 4 parts for each OS to reduce the time it takes to run them on the pipeline
|
||||
WindowsPart1Of4:
|
||||
# We split the tests into 3 parts for each OS to reduce the time it takes to run them on the pipeline
|
||||
WindowsPart1Of3:
|
||||
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)"
|
||||
WindowsPart2Of4:
|
||||
WindowsPart2Of3:
|
||||
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)"
|
||||
WindowsPart3Of4:
|
||||
WindowsPart3Of3:
|
||||
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)"
|
||||
WindowsPart4Of4:
|
||||
vmImage: "windows-latest"
|
||||
Tests__Database__DatabaseType: LocalDb
|
||||
Tests__Database__SQLServerMasterConnectionString: N/A
|
||||
# Filter tests that are part of the ManagementApi namespace.
|
||||
testFilter: "(FullyQualifiedName~ManagementApi)"
|
||||
LinuxPart1Of4:
|
||||
# Filter tests that are not part of the Umbraco.Infrastructure namespace. So this will run all tests that are not part of the Umbraco.Infrastructure namespace
|
||||
testFilter: "(FullyQualifiedName!~Umbraco.Infrastructure)"
|
||||
LinuxPart1Of3:
|
||||
vmImage: "ubuntu-latest"
|
||||
Tests__Database__DatabaseType: SqlServer
|
||||
Tests__Database__SQLServerMasterConnectionString: "Server=(local);User Id=sa;Password=$(SA_PASSWORD);Encrypt=True;TrustServerCertificate=True"
|
||||
# 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)"
|
||||
LinuxPart2Of4:
|
||||
LinuxPart2Of3:
|
||||
vmImage: "ubuntu-latest"
|
||||
Tests__Database__DatabaseType: SqlServer
|
||||
Tests__Database__SQLServerMasterConnectionString: "Server=(local);User Id=sa;Password=$(SA_PASSWORD);Encrypt=True;TrustServerCertificate=True"
|
||||
# Filter tests that are part of the Umbraco.Infrastructure.Service namespace
|
||||
testFilter: "(FullyQualifiedName~Umbraco.Infrastructure.Service)"
|
||||
LinuxPart3Of4:
|
||||
LinuxPart3Of3:
|
||||
vmImage: "ubuntu-latest"
|
||||
Tests__Database__DatabaseType: SqlServer
|
||||
Tests__Database__SQLServerMasterConnectionString: "Server=(local);User Id=sa;Password=$(SA_PASSWORD);Encrypt=True;TrustServerCertificate=True"
|
||||
# Filter tests that are not part of the Umbraco.Infrastructure and ManagementApi namespace.
|
||||
testFilter: "(FullyQualifiedName!~Umbraco.Infrastructure) & (FullyQualifiedName!~ManagementApi)"
|
||||
LinuxPart4Of4:
|
||||
vmImage: "ubuntu-latest"
|
||||
Tests__Database__DatabaseType: SqlServer
|
||||
Tests__Database__SQLServerMasterConnectionString: "Server=(local);User Id=sa;Password=$(SA_PASSWORD);Encrypt=True;TrustServerCertificate=True"
|
||||
# Filter tests that are part of the ManagementApi namespace.
|
||||
testFilter: "(FullyQualifiedName~ManagementApi)"
|
||||
# Filter tests that are not part of the Umbraco.Infrastructure namespace. So this will run all tests that are not part of the Umbraco.Infrastructure namespace
|
||||
testFilter: "(FullyQualifiedName!~Umbraco.Infrastructure)"
|
||||
pool:
|
||||
vmImage: $(vmImage)
|
||||
steps:
|
||||
@@ -321,7 +293,8 @@ stages:
|
||||
|
||||
- stage: DefaultConfigE2E
|
||||
displayName: Default Config E2E Tests
|
||||
dependsOn: Build
|
||||
dependsOn: Integration
|
||||
condition: always()
|
||||
variables:
|
||||
npm_config_cache: $(Pipeline.Workspace)/.npm_e2e
|
||||
# Enable console logging in Release mode
|
||||
@@ -348,7 +321,7 @@ stages:
|
||||
- job:
|
||||
displayName: E2E Tests (SQLite)
|
||||
timeoutInMinutes: 180
|
||||
condition: ${{ and(eq(parameters.skipDefaultConfigAcceptanceTests, false), eq(parameters.skipSqliteAcceptanceTests, false)) }}
|
||||
condition: ${{ eq(parameters.skipDefaultConfigAcceptanceTests, false) }}
|
||||
variables:
|
||||
# Connection string
|
||||
CONNECTIONSTRINGS__UMBRACODBDSN: Data Source=Umbraco;Mode=Memory;Cache=Shared;Foreign Keys=True;Pooling=True
|
||||
@@ -449,15 +422,15 @@ stages:
|
||||
vmImage: "ubuntu-latest"
|
||||
CONNECTIONSTRINGS__UMBRACODBDSN: "Server=(local);Database=Umbraco;User Id=sa;Password=$(SA_PASSWORD);Encrypt=True;TrustServerCertificate=True"
|
||||
WindowsPart1Of3:
|
||||
testCommand: "npm run testWindows -- --shard=1/3"
|
||||
testCommand: "npm run test -- --shard=1/3"
|
||||
testFolder: "DefaultConfig"
|
||||
vmImage: "windows-latest"
|
||||
WindowsPart2Of3:
|
||||
testCommand: "npm run testWindows -- --shard=2/3"
|
||||
testCommand: "npm run test -- --shard=2/3"
|
||||
testFolder: "DefaultConfig"
|
||||
vmImage: "windows-latest"
|
||||
WindowsPart3Of3:
|
||||
testCommand: "npm run testWindows -- --shard=3/3"
|
||||
testCommand: "npm run test -- --shard=3/3"
|
||||
testFolder: "DefaultConfig"
|
||||
vmImage: "windows-latest"
|
||||
pool:
|
||||
@@ -502,7 +475,8 @@ stages:
|
||||
|
||||
- stage: AdditionalConfigE2E
|
||||
displayName: Additional Config E2E Tests
|
||||
dependsOn: Build
|
||||
dependsOn: DefaultConfigE2E
|
||||
condition: always()
|
||||
variables:
|
||||
npm_config_cache: $(Pipeline.Workspace)/.npm_e2e
|
||||
ASPNETCORE_URLS: https://localhost:44331
|
||||
@@ -511,7 +485,7 @@ stages:
|
||||
jobs:
|
||||
- job:
|
||||
displayName: E2E Tests with Different App settings (SQL Server)
|
||||
condition: ${{ eq(parameters.skipDifferentAppSettingsAcceptanceTests, false) }}
|
||||
condition: ${{ or(eq(parameters.differentAppSettingsAcceptanceTests, true), eq(parameters.skipDefaultConfigAcceptanceTests, true)) }}
|
||||
timeoutInMinutes: 180
|
||||
variables:
|
||||
SA_PASSWORD: UmbracoAcceptance123!
|
||||
@@ -570,49 +544,6 @@ stages:
|
||||
CONNECTIONSTRINGS__UMBRACODBDSN: Server=(local);Database=Umbraco;User Id=sa;Password=$(SA_PASSWORD);Encrypt=True;TrustServerCertificate=True
|
||||
CONNECTIONSTRINGS__UMBRACODBDSN_PROVIDERNAME: Microsoft.Data.SqlClient
|
||||
additionalEnvironmentVariables: false
|
||||
# EntityDataPicker
|
||||
WindowsEntityDataPicker:
|
||||
vmImage: "windows-latest"
|
||||
testFolder: "EntityDataPicker"
|
||||
port: ''
|
||||
testCommand: "npx playwright test --project=entityDataPicker"
|
||||
CONNECTIONSTRINGS__UMBRACODBDSN: Data Source=(localdb)\MSSQLLocalDB;AttachDbFilename=|DataDirectory|\Umbraco.mdf;Integrated Security=True
|
||||
CONNECTIONSTRINGS__UMBRACODBDSN_PROVIDERNAME: Microsoft.Data.SqlClient
|
||||
additionalEnvironmentVariables: false
|
||||
LinuxEntityDataPicker:
|
||||
vmImage: "ubuntu-latest"
|
||||
testFolder: "EntityDataPicker"
|
||||
port: ''
|
||||
testCommand: "npx playwright test --project=entityDataPicker"
|
||||
CONNECTIONSTRINGS__UMBRACODBDSN: Server=(local);Database=Umbraco;User Id=sa;Password=$(SA_PASSWORD);Encrypt=True;TrustServerCertificate=True
|
||||
CONNECTIONSTRINGS__UMBRACODBDSN_PROVIDERNAME: Microsoft.Data.SqlClient
|
||||
additionalEnvironmentVariables: false
|
||||
# ContentSettingConfig
|
||||
WindowsContentSettingsConfig:
|
||||
vmImage: "windows-latest"
|
||||
testFolder: "ContentSettingConfig"
|
||||
port: ''
|
||||
testCommand: "npx playwright test --project=contentSettingConfig"
|
||||
CONNECTIONSTRINGS__UMBRACODBDSN: Data Source=(localdb)\MSSQLLocalDB;AttachDbFilename=|DataDirectory|\Umbraco.mdf;Integrated Security=True
|
||||
CONNECTIONSTRINGS__UMBRACODBDSN_PROVIDERNAME: Microsoft.Data.SqlClient
|
||||
additionalEnvironmentVariables: false
|
||||
LinuxContentSettingsConfig:
|
||||
vmImage: "ubuntu-latest"
|
||||
testFolder: "ContentSettingConfig"
|
||||
port: ''
|
||||
testCommand: "npx playwright test --project=contentSettingConfig"
|
||||
CONNECTIONSTRINGS__UMBRACODBDSN: Server=(local);Database=Umbraco;User Id=sa;Password=$(SA_PASSWORD);Encrypt=True;TrustServerCertificate=True
|
||||
CONNECTIONSTRINGS__UMBRACODBDSN_PROVIDERNAME: Microsoft.Data.SqlClient
|
||||
additionalEnvironmentVariables: false
|
||||
# SMTP
|
||||
LinuxSMTP:
|
||||
vmImage: "ubuntu-latest"
|
||||
testFolder: "SMTP"
|
||||
port: ''
|
||||
testCommand: "npx playwright test --project=smtp"
|
||||
CONNECTIONSTRINGS__UMBRACODBDSN: Server=(local);Database=Umbraco;User Id=sa;Password=$(SA_PASSWORD);Encrypt=True;TrustServerCertificate=True
|
||||
CONNECTIONSTRINGS__UMBRACODBDSN_PROVIDERNAME: Microsoft.Data.SqlClient
|
||||
additionalEnvironmentVariables: false
|
||||
pool:
|
||||
vmImage: $(vmImage)
|
||||
steps:
|
||||
@@ -690,23 +621,6 @@ stages:
|
||||
AZUREADB2CCLIENTID: $(AZUREB2CCLIENTID)
|
||||
AZUREADB2CCLIENTSECRET: $(AZUREB2CCLIENTSECRET)
|
||||
|
||||
# Start SMTP4dev via Docker for SMTP tests
|
||||
- bash: |
|
||||
echo "Starting SMTP4dev container..."
|
||||
docker run -d --name smtp4dev -p 5000:80 -p 25:25 rnwood/smtp4dev
|
||||
|
||||
echo "Waiting for SMTP4dev to be ready..."
|
||||
for i in {1..30}; do
|
||||
if curl -s http://localhost:5000/api/messages > /dev/null; then
|
||||
echo "SMTP4dev is ready"
|
||||
break
|
||||
fi
|
||||
echo "Attempt $i: Waiting for SMTP4dev..."
|
||||
sleep 2
|
||||
done
|
||||
displayName: Start SMTP4dev Docker container (Linux)
|
||||
condition: and(succeeded(), eq(variables['Agent.OS'], 'Linux'), contains(variables['testFolder'], 'SMTP'))
|
||||
|
||||
# Run tests Template
|
||||
- template: nightly-E2E-run-tests-template.yml
|
||||
parameters:
|
||||
@@ -717,14 +631,6 @@ stages:
|
||||
AZUREB2CTESTUSERPASSWORD: $(AZUREB2CTESTUSERPASSWORD)
|
||||
DatabaseType: ${{ variables.DatabaseType }}
|
||||
|
||||
# Stop SMTP4dev container
|
||||
- bash: |
|
||||
echo "Stopping SMTP4dev container..."
|
||||
docker stop smtp4dev
|
||||
docker rm smtp4dev
|
||||
displayName: Stop SMTP4dev Docker container
|
||||
condition: and(succeeded(), eq(variables['Agent.OS'], 'Linux'), contains(variables['testFolder'], 'SMTP'))
|
||||
|
||||
- stage: NotifySlackBot
|
||||
displayName: Notify Slack on Failure
|
||||
dependsOn: DefaultConfigE2E
|
||||
|
||||
@@ -9,7 +9,7 @@ schedules:
|
||||
branches:
|
||||
include:
|
||||
- v13/dev
|
||||
- v16/dev
|
||||
- v17/dev
|
||||
- main
|
||||
|
||||
steps:
|
||||
|
||||
@@ -13,12 +13,12 @@ jobs:
|
||||
- checkout: none
|
||||
|
||||
- bash: |
|
||||
project_id=$(curl --no-progress-meter -H "X-Api-Key: $(DT_API_KEY)" "$(DT_API_URI)/api/v1/project/lookup?name=${{ parameters.projectName }}&version=${{ parameters.umbracoVersion }}" | jq -r '.uuid')
|
||||
project_id=$(curl --no-progress-meter -H "X-Api-Key: $(DT_API_KEY)" "$(DT_API_URL)/v1/project/lookup?name=${{ parameters.projectName }}&version=${{ parameters.umbracoVersion }}" | jq -r '.uuid')
|
||||
if [ "$project_id" != "null" ] && [ -n "$project_id" ]; then
|
||||
echo "Project '${{ parameters.projectName }}' with version '${{ parameters.umbracoVersion }}' already exists (ID: $project_id)."
|
||||
else
|
||||
project_id=$(curl --no-progress-meter \
|
||||
-X PUT "$(DT_API_URI)/api/v1/project" \
|
||||
-X PUT "$(DT_API_URL)/v1/project" \
|
||||
-H "X-Api-Key: $(DT_API_KEY)" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name": "${{ parameters.projectName }}", "version": "${{ parameters.umbracoVersion }}", "collectionLogic": "AGGREGATE_DIRECT_CHILDREN"}' \
|
||||
@@ -42,14 +42,15 @@ jobs:
|
||||
artifact: ${{ project.artifact }}
|
||||
displayName: Download ${{ project.artifact }} artifact
|
||||
|
||||
- task: upload-bom-dtrack@1
|
||||
inputs:
|
||||
dtrackURI: $(DT_API_URI)
|
||||
dtrackAPIKey: $(DT_API_KEY)
|
||||
dtrackProjAutoCreate: true
|
||||
dtrackProjName: '${{ parameters.projectName }}-${{ project.name }}'
|
||||
dtrackProjVersion: ${{ parameters.umbracoVersion }}
|
||||
dtrackParentProjName: ${{ parameters.projectName }}
|
||||
dtrackParentProjVersion: ${{ parameters.umbracoVersion }}
|
||||
bomFilePath: '$(Pipeline.Workspace)/${{ project.artifact }}/${{ project.bomFilePath }}'
|
||||
- script: |
|
||||
curl --no-progress-meter --fail-with-body \
|
||||
-X POST "$(DT_API_URL)/v1/bom" \
|
||||
-H "X-Api-Key: $(DT_API_KEY)" \
|
||||
-H "Content-Type: multipart/form-data" \
|
||||
-F "autoCreate=true" \
|
||||
-F "projectName=${{ parameters.projectName }}-${{ project.name }}" \
|
||||
-F "projectVersion=${{ parameters.umbracoVersion }}" \
|
||||
-F "parentName=${{ parameters.projectName }}" \
|
||||
-F "parentVersion=${{ parameters.umbracoVersion }}" \
|
||||
-F "bom=@$(Pipeline.Workspace)/${{ project.artifact }}/${{ project.bomFilePath }}"
|
||||
displayName: Upload ${{ project.name }} BOM to Dependency Track
|
||||
|
||||
@@ -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,4 +0,0 @@
|
||||
{
|
||||
"url": "https://context7.com/umbraco/umbraco-cms",
|
||||
"public_key": "pk_GTIgsrGAQiHNxCirZBDIM"
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"sdk": {
|
||||
"version": "10.0.100",
|
||||
"version": "9.0.306",
|
||||
"rollForward": "latestFeature",
|
||||
"allowPrerelease": false
|
||||
}
|
||||
|
||||
+1
-8
@@ -1,17 +1,10 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Umbraco.Cms.Core.DeliveryApi;
|
||||
|
||||
namespace Umbraco.Cms.Api.Common.Accessors;
|
||||
|
||||
/// <summary>
|
||||
/// Provides access to the <see cref="IOutputExpansionStrategy"/> for the current HTTP request context.
|
||||
/// </summary>
|
||||
public sealed class RequestContextOutputExpansionStrategyAccessor : RequestContextServiceAccessorBase<IOutputExpansionStrategy>, IOutputExpansionStrategyAccessor
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="RequestContextOutputExpansionStrategyAccessor"/> class.
|
||||
/// </summary>
|
||||
/// <param name="httpContextAccessor">The HTTP context accessor.</param>
|
||||
public RequestContextOutputExpansionStrategyAccessor(IHttpContextAccessor httpContextAccessor)
|
||||
: base(httpContextAccessor)
|
||||
{
|
||||
|
||||
@@ -1,30 +1,17 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Umbraco.Cms.Api.Common.Accessors;
|
||||
|
||||
/// <summary>
|
||||
/// Base class for accessing request-scoped services from the current HTTP context.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of service to access.</typeparam>
|
||||
public abstract class RequestContextServiceAccessorBase<T>
|
||||
where T : class
|
||||
{
|
||||
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="RequestContextServiceAccessorBase{T}"/> class.
|
||||
/// </summary>
|
||||
/// <param name="httpContextAccessor">The HTTP context accessor.</param>
|
||||
protected RequestContextServiceAccessorBase(IHttpContextAccessor httpContextAccessor)
|
||||
=> _httpContextAccessor = httpContextAccessor;
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to retrieve the service from the current HTTP context's request services.
|
||||
/// </summary>
|
||||
/// <param name="requestStartNodeService">When this method returns, contains the service instance if found; otherwise, <c>null</c>.</param>
|
||||
/// <returns><c>true</c> if the service was found; otherwise, <c>false</c>.</returns>
|
||||
public bool TryGetValue([NotNullWhen(true)] out T? requestStartNodeService)
|
||||
{
|
||||
requestStartNodeService = _httpContextAccessor.HttpContext?.RequestServices.GetService<T>();
|
||||
|
||||
@@ -1,19 +1,9 @@
|
||||
namespace Umbraco.Cms.Api.Common.Attributes;
|
||||
|
||||
/// <summary>
|
||||
/// Attribute used to map a class to a specific API for OpenAPI documentation generation.
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false)]
|
||||
public class MapToApiAttribute : Attribute
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="MapToApiAttribute"/> class.
|
||||
/// </summary>
|
||||
/// <param name="apiName">The name of the API to map to.</param>
|
||||
public MapToApiAttribute(string apiName) => ApiName = apiName;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the name of the API this class is mapped to.
|
||||
/// </summary>
|
||||
public string ApiName { get; }
|
||||
}
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Api.Common.Builders;
|
||||
|
||||
/// <summary>
|
||||
/// A fluent builder for creating RFC 7807 <see cref="ProblemDetails"/> responses.
|
||||
/// </summary>
|
||||
public class ProblemDetailsBuilder
|
||||
{
|
||||
private string? _title;
|
||||
@@ -15,45 +12,24 @@ public class ProblemDetailsBuilder
|
||||
private string? _operationStatus;
|
||||
private IDictionary<string, object>? _extensions;
|
||||
|
||||
/// <summary>
|
||||
/// Sets the title of the problem details.
|
||||
/// </summary>
|
||||
/// <param name="title">A short, human-readable summary of the problem type.</param>
|
||||
/// <returns>The current builder instance for method chaining.</returns>
|
||||
public ProblemDetailsBuilder WithTitle(string title)
|
||||
{
|
||||
_title = title;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the detail of the problem details.
|
||||
/// </summary>
|
||||
/// <param name="detail">A human-readable explanation specific to this occurrence of the problem.</param>
|
||||
/// <returns>The current builder instance for method chaining.</returns>
|
||||
public ProblemDetailsBuilder WithDetail(string detail)
|
||||
{
|
||||
_detail = detail;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the type of the problem details.
|
||||
/// </summary>
|
||||
/// <param name="type">A URI reference that identifies the problem type.</param>
|
||||
/// <returns>The current builder instance for method chaining.</returns>
|
||||
public ProblemDetailsBuilder WithType(string type)
|
||||
{
|
||||
_type = type;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the operation status from an enum value.
|
||||
/// </summary>
|
||||
/// <typeparam name="TEnum">The enum type representing operation statuses.</typeparam>
|
||||
/// <param name="operationStatus">The operation status enum value.</param>
|
||||
/// <returns>The current builder instance for method chaining.</returns>
|
||||
public ProblemDetailsBuilder WithOperationStatus<TEnum>(TEnum operationStatus)
|
||||
where TEnum : Enum
|
||||
{
|
||||
@@ -61,20 +37,9 @@ public class ProblemDetailsBuilder
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds request model validation errors to the problem details.
|
||||
/// </summary>
|
||||
/// <param name="errors">A dictionary of field names to error messages.</param>
|
||||
/// <returns>The current builder instance for method chaining.</returns>
|
||||
public ProblemDetailsBuilder WithRequestModelErrors(IDictionary<string, string[]> errors)
|
||||
=> WithExtension(nameof(HttpValidationProblemDetails.Errors).ToFirstLowerInvariant(), errors);
|
||||
|
||||
/// <summary>
|
||||
/// Adds a custom extension to the problem details.
|
||||
/// </summary>
|
||||
/// <param name="key">The extension key.</param>
|
||||
/// <param name="value">The extension value.</param>
|
||||
/// <returns>The current builder instance for method chaining.</returns>
|
||||
public ProblemDetailsBuilder WithExtension(string key, object value)
|
||||
{
|
||||
_extensions ??= new Dictionary<string, object>();
|
||||
@@ -82,10 +47,6 @@ public class ProblemDetailsBuilder
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the <see cref="ProblemDetails"/> instance with all configured values.
|
||||
/// </summary>
|
||||
/// <returns>A new <see cref="ProblemDetails"/> instance.</returns>
|
||||
public ProblemDetails Build()
|
||||
{
|
||||
var problemDetails = new ProblemDetails
|
||||
|
||||
@@ -1,371 +0,0 @@
|
||||
# Umbraco.Cms.Api.Common
|
||||
|
||||
Shared infrastructure for Umbraco CMS REST APIs (Management and Delivery).
|
||||
|
||||
---
|
||||
|
||||
## 1. Architecture
|
||||
|
||||
**Type**: Class Library (NuGet Package)
|
||||
**Target Framework**: .NET 10.0
|
||||
**Purpose**: Common API infrastructure - OpenAPI/Swagger, JSON serialization, OpenIddict authentication, problem details
|
||||
|
||||
### Key Technologies
|
||||
|
||||
- **ASP.NET Core** - Web framework
|
||||
- **Swashbuckle** - OpenAPI/Swagger documentation generation
|
||||
- **OpenIddict** - OAuth 2.0/OpenID Connect authentication
|
||||
- **Asp.Versioning** - API versioning
|
||||
- **System.Text.Json** - Polymorphic JSON serialization
|
||||
|
||||
### Dependencies
|
||||
|
||||
- `Umbraco.Core` - Domain models and service contracts
|
||||
- `Umbraco.Web.Common` - Web functionality
|
||||
|
||||
### Project Structure (46 files)
|
||||
|
||||
```
|
||||
Umbraco.Cms.Api.Common/
|
||||
├── 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
|
||||
│ ├── ConfigureUmbracoSwaggerGenOptions.cs
|
||||
│ └── ConfigureOpenIddict.cs
|
||||
├── DependencyInjection/ # Service registration
|
||||
│ ├── UmbracoBuilderApiExtensions.cs
|
||||
│ └── UmbracoBuilderAuthExtensions.cs
|
||||
├── Builders/ # RFC 7807 problem details
|
||||
│ └── ProblemDetailsBuilder.cs
|
||||
├── ViewModels/Pagination/ # Common DTOs
|
||||
└── Security/ # Auth paths and handlers
|
||||
```
|
||||
|
||||
### Design Patterns
|
||||
|
||||
1. **Strategy Pattern** - `ISchemaIdHandler`, `IOperationIdHandler` (extensible via inheritance)
|
||||
2. **Builder Pattern** - `ProblemDetailsBuilder` for fluent error responses
|
||||
3. **Options Pattern** - All configuration via `IConfigureOptions<T>`
|
||||
|
||||
---
|
||||
|
||||
## 2. Commands
|
||||
|
||||
See "Quick Reference" section at bottom for common commands.
|
||||
|
||||
---
|
||||
|
||||
## 3. Key Patterns
|
||||
|
||||
### Virtual Handlers for Extensibility
|
||||
|
||||
Handlers are intentionally virtual to allow consuming APIs to override:
|
||||
|
||||
```csharp
|
||||
// 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),
|
||||
// we need to add a "Model" postfix to all models
|
||||
name = $"{name}Model";
|
||||
}
|
||||
|
||||
// Remove invalid characters to prevent OpenAPI generation errors (line 32)
|
||||
return Regex.Replace(name, @"[^\w]", string.Empty);
|
||||
```
|
||||
|
||||
### Polymorphic Deserialization (Serialization/UmbracoJsonTypeInfoResolver.cs:29-35)
|
||||
|
||||
```csharp
|
||||
// IMPORTANT: do NOT return an empty enumerable here. it will cause nullability to fail on reference
|
||||
// properties, because "$ref" does not mix and match well with "nullable" in OpenAPI.
|
||||
if (type.IsInterface is false)
|
||||
{
|
||||
return new[] { type };
|
||||
}
|
||||
```
|
||||
|
||||
**Why**: Interfaces must return concrete types to avoid OpenAPI schema conflicts.
|
||||
|
||||
---
|
||||
|
||||
## 4. Testing
|
||||
|
||||
**Location**: No direct tests - tested via integration tests in consuming APIs
|
||||
|
||||
**How to test changes**:
|
||||
```bash
|
||||
# Run integration tests that exercise this library
|
||||
dotnet test tests/Umbraco.Tests.Integration/
|
||||
|
||||
# Verify OpenAPI generation
|
||||
# 1. Run Management API
|
||||
# 2. Navigate to /umbraco/swagger/
|
||||
# 3. Check schema IDs and operation IDs
|
||||
```
|
||||
|
||||
**Focus areas when testing**:
|
||||
- OpenAPI document generation (schema IDs, operation IDs)
|
||||
- Polymorphic JSON serialization/deserialization
|
||||
- OpenIddict authentication flow
|
||||
- Problem details formatting
|
||||
|
||||
---
|
||||
|
||||
## 5. OpenIddict Authentication
|
||||
|
||||
### Key Configuration (DependencyInjection/UmbracoBuilderAuthExtensions.cs)
|
||||
|
||||
**Reference Tokens over JWT** (line 76-80):
|
||||
```csharp
|
||||
// Enable reference tokens
|
||||
// - see https://documentation.openiddict.com/configuration/token-storage.html
|
||||
options
|
||||
.UseReferenceAccessTokens()
|
||||
.UseReferenceRefreshTokens();
|
||||
```
|
||||
|
||||
**Why**: More secure (revocable), better for load balancing, uses ASP.NET Core Data Protection.
|
||||
|
||||
**Token Lifetime** (line 88-91):
|
||||
```csharp
|
||||
// Make the access token lifetime 25% of the refresh token lifetime
|
||||
options.SetAccessTokenLifetime(new TimeSpan(timeOut.Ticks / 4));
|
||||
options.SetRefreshTokenLifetime(timeOut);
|
||||
```
|
||||
|
||||
**PKCE Required** (line 59-63):
|
||||
```csharp
|
||||
// Enable authorization code flow with PKCE
|
||||
options
|
||||
.AllowAuthorizationCodeFlow()
|
||||
.RequireProofKeyForCodeExchange()
|
||||
.AllowRefreshTokenFlow();
|
||||
```
|
||||
|
||||
**Endpoints**: Backoffice `/umbraco/management/api/v1/security/*`, Member `/umbraco/member/api/v1/security/*`
|
||||
|
||||
### Secure Cookie-Based Token Storage (v17+)
|
||||
|
||||
**Implementation** (DependencyInjection/HideBackOfficeTokensHandler.cs):
|
||||
|
||||
Back-office tokens are hidden from client-side JavaScript via HTTP-only cookies:
|
||||
|
||||
```csharp
|
||||
private const string AccessTokenCookieKey = "__Host-umbAccessToken";
|
||||
private const string RefreshTokenCookieKey = "__Host-umbRefreshToken";
|
||||
|
||||
// Tokens are encrypted via Data Protection and stored in cookies
|
||||
SetCookie(httpContext, AccessTokenCookieKey, context.Response.AccessToken);
|
||||
context.Response.AccessToken = "[redacted]"; // Client sees redacted value
|
||||
```
|
||||
|
||||
**Key Security Features** (lines 143-165): `HttpOnly`, `IsEssential`, `Path="/"`, `Secure` (HTTPS), `__Host-` prefix
|
||||
|
||||
**Configuration**: `BackOfficeTokenCookieSettings.Enabled` (default: true in v17+)
|
||||
|
||||
**Implications**: Client-side cannot access tokens; encrypted with Data Protection; load balancing needs shared key ring; API requests need `credentials: include`
|
||||
|
||||
---
|
||||
|
||||
## 6. Common Issues & Edge Cases
|
||||
|
||||
### Polymorphic Deserialization Requires `$type`
|
||||
|
||||
**Issue**: Deserializing to an interface without `$type` discriminator fails.
|
||||
|
||||
**Handled in** (Json/NamedSystemTextJsonInputFormatter.cs:24-29):
|
||||
```csharp
|
||||
catch (NotSupportedException exception)
|
||||
{
|
||||
// This happens when trying to deserialize to an interface, without sending the $type as part of the request
|
||||
context.ModelState.TryAddModelException(string.Empty, new InputFormatterException(exception.Message, exception));
|
||||
return await InputFormatterResult.FailureAsync();
|
||||
}
|
||||
```
|
||||
|
||||
**Solution**: Clients must include `$type` property for interface types, or use concrete types.
|
||||
|
||||
### Schema ID Collisions with TypeScript
|
||||
|
||||
**Issue**: Type names like `Document` clash with TypeScript built-ins.
|
||||
|
||||
**Solution**: Add "Model" suffix (OpenApi/SchemaIdHandler.cs:24-29)
|
||||
|
||||
### Generic Type Handling
|
||||
|
||||
**Issue**: `PagedViewModel<T>` needs flattened schema name.
|
||||
|
||||
**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 a Custom OpenAPI Handler
|
||||
|
||||
1. **Implement interface**:
|
||||
```csharp
|
||||
public class MySchemaIdHandler : SchemaIdHandler
|
||||
{
|
||||
public override bool CanHandle(Type type)
|
||||
=> type.Namespace?.StartsWith("MyProject") is true;
|
||||
|
||||
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
|
||||
|
||||
```csharp
|
||||
var problemDetails = new ProblemDetailsBuilder()
|
||||
.WithTitle("Validation Failed")
|
||||
.WithDetail("The request contains errors")
|
||||
.WithType("ValidationError")
|
||||
.WithOperationStatus(MyOperationStatus.ValidationFailed)
|
||||
.WithRequestModelErrors(errors)
|
||||
.Build();
|
||||
|
||||
return BadRequest(problemDetails);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Project-Specific Notes
|
||||
|
||||
### Why Virtual Handlers?
|
||||
|
||||
**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
|
||||
|
||||
**Why**: Cache discovered subtypes (UmbracoJsonTypeInfoResolver.cs:14) to avoid expensive reflection calls
|
||||
|
||||
### Known Limitations
|
||||
|
||||
1. **Polymorphic Deserialization**:
|
||||
- Requires `$type` discriminator in JSON for interfaces
|
||||
- Only discovers types in Umbraco namespaces
|
||||
- Not all .NET types are discoverable
|
||||
|
||||
2. **OpenAPI Schema Generation**:
|
||||
- Generic types are flattened (e.g., `PagedViewModel<T>` → `PagedTModel`)
|
||||
- Type names may need "Model" suffix to avoid clashes
|
||||
|
||||
3. **OpenIddict Multi-Server**:
|
||||
- Requires shared Data Protection key ring
|
||||
- All servers must have synchronized clocks (NTP)
|
||||
- Reference tokens require database storage
|
||||
|
||||
### External Dependencies
|
||||
|
||||
**OpenIddict**:
|
||||
- OAuth 2.0 / OpenID Connect provider
|
||||
- Version: See `Directory.Packages.props`
|
||||
- Uses ASP.NET Core Data Protection for token encryption
|
||||
|
||||
**Swashbuckle**:
|
||||
- OpenAPI 3.0 document generation
|
||||
- Custom filters: `EnumSchemaFilter`, `MimeTypeDocumentFilter`, `RemoveSecuritySchemesDocumentFilter`
|
||||
|
||||
**Asp.Versioning**:
|
||||
- API versioning via `ApiVersion` attribute
|
||||
- API explorer integration for multi-version Swagger docs
|
||||
|
||||
### Configuration
|
||||
|
||||
**HTTPS**: `DisableTransportSecurityRequirement` for local dev only (ConfigureOpenIddict.cs:14). **Warning**: Never disable in production.
|
||||
|
||||
### Usage Pattern
|
||||
|
||||
Consuming APIs call `builder.AddUmbracoApiOpenApiUI().AddUmbracoOpenIddict()`
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### Essential Commands
|
||||
|
||||
```bash
|
||||
# Build project
|
||||
dotnet build src/Umbraco.Cms.Api.Common/Umbraco.Cms.Api.Common.csproj
|
||||
|
||||
# Pack for NuGet
|
||||
dotnet pack src/Umbraco.Cms.Api.Common/Umbraco.Cms.Api.Common.csproj -c Release
|
||||
|
||||
# Test via integration tests
|
||||
dotnet test tests/Umbraco.Tests.Integration/
|
||||
|
||||
# Check packages
|
||||
dotnet list src/Umbraco.Cms.Api.Common/Umbraco.Cms.Api.Common.csproj package --outdated
|
||||
dotnet list src/Umbraco.Cms.Api.Common/Umbraco.Cms.Api.Common.csproj package --vulnerable
|
||||
```
|
||||
|
||||
### Key Classes
|
||||
|
||||
| Class | Purpose | File |
|
||||
|-------|---------|------|
|
||||
| `ProblemDetailsBuilder` | Build RFC 7807 error responses | Builders/ProblemDetailsBuilder.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 |
|
||||
| `PagedViewModel<T>` | Generic pagination model | ViewModels/Pagination/PagedViewModel.cs |
|
||||
|
||||
### Important Files
|
||||
|
||||
- `Umbraco.Cms.Api.Common.csproj` - Project dependencies
|
||||
- `DependencyInjection/UmbracoBuilderApiExtensions.cs` - OpenAPI registration (line 12-31)
|
||||
- `DependencyInjection/UmbracoBuilderAuthExtensions.cs` - OpenIddict setup (line 20-183)
|
||||
- `Security/Paths.cs` - API endpoint path constants
|
||||
|
||||
### Getting Help
|
||||
|
||||
- **Root documentation**: `/CLAUDE.md` - Repository overview
|
||||
- **Core patterns**: `/src/Umbraco.Core/CLAUDE.md` - Core contracts and patterns
|
||||
- **Official docs**: https://docs.umbraco.com/
|
||||
- **OpenIddict docs**: https://documentation.openiddict.com/
|
||||
|
||||
---
|
||||
|
||||
**This library is the foundation for all Umbraco CMS REST APIs. Focus on OpenAPI customization, authentication configuration, and polymorphic serialization when working here.**
|
||||
@@ -1,14 +1,10 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Umbraco.Cms.Api.Common.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Configures <see cref="ApiBehaviorOptions"/> for Umbraco APIs.
|
||||
/// </summary>
|
||||
public class ConfigureApiBehaviorOptions : IConfigureOptions<ApiBehaviorOptions>
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public void Configure(ApiBehaviorOptions options) =>
|
||||
// disable ProblemDetails as default result type for every non-success response (i.e. 404)
|
||||
// - see https://learn.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.mvc.apibehavioroptions.suppressmapclienterrors
|
||||
|
||||
@@ -5,21 +5,12 @@ using Umbraco.Cms.Api.Common.Json;
|
||||
|
||||
namespace Umbraco.Cms.Api.Common.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Configures <see cref="MvcOptions"/> with named JSON input and output formatters for Umbraco APIs.
|
||||
/// </summary>
|
||||
public class ConfigureMvcJsonOptions : IConfigureOptions<MvcOptions>
|
||||
{
|
||||
private readonly string _jsonOptionsName;
|
||||
private readonly IOptionsMonitor<JsonOptions> _jsonOptions;
|
||||
private readonly ILoggerFactory _loggerFactory;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ConfigureMvcJsonOptions"/> class.
|
||||
/// </summary>
|
||||
/// <param name="jsonOptionsName">The name of the JSON options configuration to use.</param>
|
||||
/// <param name="jsonOptions">The JSON options monitor.</param>
|
||||
/// <param name="loggerFactory">The logger factory.</param>
|
||||
public ConfigureMvcJsonOptions(
|
||||
string jsonOptionsName,
|
||||
IOptionsMonitor<JsonOptions> jsonOptions,
|
||||
@@ -30,7 +21,6 @@ public class ConfigureMvcJsonOptions : IConfigureOptions<MvcOptions>
|
||||
_loggerFactory = loggerFactory;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Configure(MvcOptions options)
|
||||
{
|
||||
JsonOptions jsonOptions = _jsonOptions.Get(_jsonOptionsName);
|
||||
|
||||
@@ -4,24 +4,12 @@ using Umbraco.Cms.Core.Configuration.Models;
|
||||
|
||||
namespace Umbraco.Cms.Api.Common.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Configures OpenIddict server options for Umbraco authentication.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Disables transport security requirement when HTTPS is not configured in global settings.
|
||||
/// Warning: This should only be used in development environments.
|
||||
/// </remarks>
|
||||
internal sealed class ConfigureOpenIddict : IConfigureOptions<OpenIddictServerAspNetCoreOptions>
|
||||
{
|
||||
private readonly IOptions<GlobalSettings> _globalSettings;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ConfigureOpenIddict"/> class.
|
||||
/// </summary>
|
||||
/// <param name="globalSettings">The global settings options.</param>
|
||||
public ConfigureOpenIddict(IOptions<GlobalSettings> globalSettings) => _globalSettings = globalSettings;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Configure(OpenIddictServerAspNetCoreOptions options)
|
||||
=> options.DisableTransportSecurityRequirement = _globalSettings.Value.UseHttps is false;
|
||||
}
|
||||
|
||||
@@ -1,62 +1,32 @@
|
||||
using Asp.Versioning;
|
||||
using Microsoft.AspNetCore.Mvc.Abstractions;
|
||||
using Microsoft.AspNetCore.Mvc.ApiExplorer;
|
||||
using Microsoft.AspNetCore.Mvc.Controllers;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.OpenApi;
|
||||
using Microsoft.OpenApi.Models;
|
||||
using Swashbuckle.AspNetCore.SwaggerGen;
|
||||
using Umbraco.Cms.Api.Common.OpenApi;
|
||||
using Umbraco.Cms.Core.DependencyInjection;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
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>())
|
||||
{
|
||||
_operationIdSelector = operationIdSelector;
|
||||
_schemaIdSelector = schemaIdSelector;
|
||||
_subTypesSelector = subTypesSelector;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Configure(SwaggerGenOptions swaggerGenOptions)
|
||||
{
|
||||
swaggerGenOptions.SwaggerDoc(
|
||||
@@ -69,11 +39,19 @@ public class ConfigureUmbracoSwaggerGenOptions : IConfigureOptions<SwaggerGenOpt
|
||||
});
|
||||
|
||||
swaggerGenOptions.CustomOperationIds(description => _operationIdSelector.OperationId(description));
|
||||
swaggerGenOptions.DocInclusionPredicate(_documentInclusionSelector.Include);
|
||||
swaggerGenOptions.TagActionsBy(api =>
|
||||
api.GroupName is null
|
||||
? []
|
||||
: new[] { api.GroupName });
|
||||
swaggerGenOptions.DocInclusionPredicate((name, api) =>
|
||||
{
|
||||
if (api.ActionDescriptor is ControllerActionDescriptor controllerActionDescriptor
|
||||
&& controllerActionDescriptor.HasMapToApiAttribute(name))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
ApiVersionMetadata apiVersionMetadata = api.ActionDescriptor.GetApiVersionMetadata();
|
||||
return apiVersionMetadata.Name == name
|
||||
|| (string.IsNullOrEmpty(apiVersionMetadata.Name) && name == DefaultApiConfiguration.ApiName);
|
||||
});
|
||||
swaggerGenOptions.TagActionsBy(api => new[] { api.GroupName });
|
||||
swaggerGenOptions.OrderActionsBy(ActionOrderBy);
|
||||
swaggerGenOptions.SchemaFilter<EnumSchemaFilter>();
|
||||
swaggerGenOptions.CustomSchemaIds(_schemaIdSelector.SchemaId);
|
||||
@@ -81,14 +59,7 @@ public class ConfigureUmbracoSwaggerGenOptions : IConfigureOptions<SwaggerGenOpt
|
||||
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>
|
||||
// see https://github.com/domaindrivendev/Swashbuckle.AspNetCore#change-operation-sort-order-eg-for-ui-sorting
|
||||
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,12 +1,6 @@
|
||||
namespace Umbraco.Cms.Api.Common.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Contains default configuration values for the API.
|
||||
/// </summary>
|
||||
internal static class DefaultApiConfiguration
|
||||
{
|
||||
/// <summary>
|
||||
/// The default API name used for endpoints not assigned to a specific API.
|
||||
/// </summary>
|
||||
public const string ApiName = "default";
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Options;
|
||||
using OpenIddict.Abstractions;
|
||||
using OpenIddict.Server;
|
||||
using OpenIddict.Validation;
|
||||
using Umbraco.Cms.Core;
|
||||
@@ -14,61 +13,33 @@ using Umbraco.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Api.Common.DependencyInjection;
|
||||
|
||||
/// <summary>
|
||||
/// Handles secure storage of back-office authentication tokens in HTTP-only cookies.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This handler intercepts OpenIddict token responses for the back-office client and stores
|
||||
/// access tokens, refresh tokens, and PKCE codes in encrypted HTTP-only cookies. The tokens
|
||||
/// are redacted from the response to prevent client-side JavaScript access.
|
||||
/// </remarks>
|
||||
internal sealed class HideBackOfficeTokensHandler
|
||||
: IOpenIddictServerHandler<OpenIddictServerEvents.ApplyTokenResponseContext>,
|
||||
IOpenIddictServerHandler<OpenIddictServerEvents.ApplyAuthorizationResponseContext>,
|
||||
IOpenIddictServerHandler<OpenIddictServerEvents.ExtractTokenRequestContext>,
|
||||
IOpenIddictServerHandler<OpenIddictServerEvents.ExtractRevocationRequestContext>,
|
||||
IOpenIddictValidationHandler<OpenIddictValidationEvents.ProcessAuthenticationContext>,
|
||||
INotificationHandler<UserLogoutSuccessNotification>
|
||||
{
|
||||
private const string RedactedTokenValue = "[redacted]";
|
||||
|
||||
// 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 AccessTokenCookieKey = "__Host-umbAccessToken";
|
||||
private const string RefreshTokenCookieKey = "__Host-umbRefreshToken";
|
||||
private const string PkceCodeCookieKey = "__Host-umbPkceCode";
|
||||
|
||||
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||
private readonly IDataProtectionProvider _dataProtectionProvider;
|
||||
#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>
|
||||
/// Initializes a new instance of the <see cref="HideBackOfficeTokensHandler"/> class.
|
||||
/// </summary>
|
||||
/// <param name="httpContextAccessor">The HTTP context accessor.</param>
|
||||
/// <param name="dataProtectionProvider">The data protection provider for encrypting cookie values.</param>
|
||||
/// <param name="backOfficeTokenCookieSettings">The back-office token cookie settings.</param>
|
||||
/// <param name="globalSettings">The global settings.</param>
|
||||
public HideBackOfficeTokensHandler(
|
||||
IHttpContextAccessor httpContextAccessor,
|
||||
IDataProtectionProvider dataProtectionProvider,
|
||||
#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;
|
||||
_backOfficeTokenCookieSettings = backOfficeTokenCookieSettings.Value;
|
||||
_globalSettings = globalSettings.Value;
|
||||
|
||||
_accessTokenCookieName += _backOfficeTokenCookieSettings.SiteName;
|
||||
_refreshTokenCookieName += _backOfficeTokenCookieSettings.SiteName;
|
||||
_pkceCodeCookieName += _backOfficeTokenCookieSettings.SiteName;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -88,13 +59,13 @@ internal sealed class HideBackOfficeTokensHandler
|
||||
|
||||
if (context.Response.AccessToken is not null)
|
||||
{
|
||||
SetCookie(httpContext, _accessTokenCookieName, context.Response.AccessToken);
|
||||
SetCookie(httpContext, AccessTokenCookieKey, context.Response.AccessToken);
|
||||
context.Response.AccessToken = RedactedTokenValue;
|
||||
}
|
||||
|
||||
if (context.Response.RefreshToken is not null)
|
||||
{
|
||||
SetCookie(httpContext, _refreshTokenCookieName, context.Response.RefreshToken);
|
||||
SetCookie(httpContext, RefreshTokenCookieKey, context.Response.RefreshToken);
|
||||
context.Response.RefreshToken = RedactedTokenValue;
|
||||
}
|
||||
|
||||
@@ -116,7 +87,7 @@ internal sealed class HideBackOfficeTokensHandler
|
||||
|
||||
if (context.Response.Code is not null)
|
||||
{
|
||||
SetCookie(GetHttpContext(), _pkceCodeCookieName, context.Response.Code);
|
||||
SetCookie(GetHttpContext(), PkceCodeCookieKey, context.Response.Code);
|
||||
context.Response.Code = RedactedTokenValue;
|
||||
}
|
||||
|
||||
@@ -134,16 +105,14 @@ internal sealed class HideBackOfficeTokensHandler
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
HttpContext httpContext = GetHttpContext();
|
||||
|
||||
// Handle when the PKCE code is being exchanged for an access token.
|
||||
if (context.Request.Code == RedactedTokenValue
|
||||
&& TryGetCookie(httpContext, _pkceCodeCookieName, out var code))
|
||||
&& TryGetCookie(PkceCodeCookieKey, out var code))
|
||||
{
|
||||
context.Request.Code = code;
|
||||
|
||||
// We won't need the PKCE cookie after this, let's remove it.
|
||||
RemoveCookie(httpContext, _pkceCodeCookieName);
|
||||
RemoveCookie(GetHttpContext(), PkceCodeCookieKey);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -154,7 +123,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(RefreshTokenCookieKey, out var refreshToken))
|
||||
{
|
||||
context.Request.RefreshToken = refreshToken;
|
||||
}
|
||||
@@ -169,40 +138,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>
|
||||
@@ -214,7 +149,7 @@ internal sealed class HideBackOfficeTokensHandler
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
if (TryGetCookie(GetHttpContext(), _accessTokenCookieName, out var accessToken))
|
||||
if (TryGetCookie(AccessTokenCookieKey, out var accessToken))
|
||||
{
|
||||
context.AccessToken = accessToken;
|
||||
}
|
||||
@@ -222,11 +157,10 @@ internal sealed class HideBackOfficeTokensHandler
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Handle(UserLogoutSuccessNotification notification)
|
||||
{
|
||||
HttpContext? httpContext = _httpContextAccessor.HttpContext;
|
||||
if (httpContext is null)
|
||||
HttpContext? context = _httpContextAccessor.HttpContext;
|
||||
if (context is null)
|
||||
{
|
||||
// For some reason there is no ambient HTTP context, so we can't clean up the cookies.
|
||||
// This is OK, because the tokens in the cookies have already been revoked at user sign-out,
|
||||
@@ -234,32 +168,23 @@ internal sealed class HideBackOfficeTokensHandler
|
||||
return;
|
||||
}
|
||||
|
||||
RemoveCookie(httpContext, _accessTokenCookieName);
|
||||
RemoveCookie(httpContext, _refreshTokenCookieName);
|
||||
context.Response.Cookies.Delete(AccessTokenCookieKey);
|
||||
context.Response.Cookies.Delete(RefreshTokenCookieKey);
|
||||
}
|
||||
|
||||
private HttpContext GetHttpContext()
|
||||
=> _httpContextAccessor.GetRequiredHttpContext();
|
||||
|
||||
private string GetCookieKey(HttpContext httpContext, string cookieName)
|
||||
=> _globalSettings.UseHttps || httpContext.Request.IsHttps
|
||||
? $"{SecureCookiePrefix}{cookieName}"
|
||||
: cookieName;
|
||||
|
||||
private void SetCookie(HttpContext httpContext, string cookieName, string value)
|
||||
private void SetCookie(HttpContext httpContext, string key, string value)
|
||||
{
|
||||
var key = GetCookieKey(httpContext, cookieName);
|
||||
var cookieValue = EncryptionHelper.Encrypt(value, _dataProtectionProvider);
|
||||
|
||||
RemoveCookie(httpContext, cookieName);
|
||||
RemoveCookie(httpContext, key);
|
||||
httpContext.Response.Cookies.Append(key, cookieValue, GetCookieOptions(httpContext));
|
||||
}
|
||||
|
||||
private void RemoveCookie(HttpContext httpContext, string cookieName)
|
||||
{
|
||||
var key = GetCookieKey(httpContext, cookieName);
|
||||
httpContext.Response.Cookies.Delete(key, GetCookieOptions(httpContext));
|
||||
}
|
||||
private void RemoveCookie(HttpContext httpContext, string key)
|
||||
=> httpContext.Response.Cookies.Delete(key, GetCookieOptions(httpContext));
|
||||
|
||||
private CookieOptions GetCookieOptions(HttpContext httpContext) =>
|
||||
new()
|
||||
@@ -286,10 +211,9 @@ internal sealed class HideBackOfficeTokensHandler
|
||||
SameSite = ParseSameSiteMode(_backOfficeTokenCookieSettings.SameSite),
|
||||
};
|
||||
|
||||
private bool TryGetCookie(HttpContext httpContext, string cookieName, [NotNullWhen(true)] out string? value)
|
||||
private bool TryGetCookie(string key, [NotNullWhen(true)] out string? value)
|
||||
{
|
||||
var key = GetCookieKey(httpContext, cookieName);
|
||||
if (httpContext.Request.Cookies.TryGetValue(key, out var cookieValue))
|
||||
if (GetHttpContext().Request.Cookies.TryGetValue(key, out var cookieValue))
|
||||
{
|
||||
value = EncryptionHelper.Decrypt(cookieValue, _dataProtectionProvider);
|
||||
return true;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
@@ -6,18 +6,8 @@ using Umbraco.Cms.Api.Common.Configuration;
|
||||
|
||||
namespace Umbraco.Cms.Api.Common.DependencyInjection;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for <see cref="IMvcBuilder"/>.
|
||||
/// </summary>
|
||||
public static class MvcBuilderExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds named JSON serialization options to the MVC builder.
|
||||
/// </summary>
|
||||
/// <param name="builder">The MVC builder.</param>
|
||||
/// <param name="settingsName">The name for the JSON options configuration.</param>
|
||||
/// <param name="configure">The action to configure the JSON options.</param>
|
||||
/// <returns>The MVC builder for method chaining.</returns>
|
||||
public static IMvcBuilder AddJsonOptions(this IMvcBuilder builder, string settingsName, Action<JsonOptions> configure)
|
||||
{
|
||||
builder.Services.Configure(settingsName, configure);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using OpenIddict.Server;
|
||||
using OpenIddict.Validation;
|
||||
using Umbraco.Cms.Core;
|
||||
@@ -6,23 +6,12 @@ using Umbraco.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Api.Common.DependencyInjection;
|
||||
|
||||
/// <summary>
|
||||
/// Handles OpenIddict request processing to skip handling for non-authentication requests.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This handler prevents OpenIddict from processing every request to the server,
|
||||
/// limiting its scope to back-office and well-known OpenID Connect endpoints.
|
||||
/// </remarks>
|
||||
public class ProcessRequestContextHandler
|
||||
: IOpenIddictServerHandler<OpenIddictServerEvents.ProcessRequestContext>, IOpenIddictValidationHandler<OpenIddictValidationEvents.ProcessRequestContext>
|
||||
{
|
||||
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||
private readonly string[] _pathsToHandle;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ProcessRequestContextHandler"/> class.
|
||||
/// </summary>
|
||||
/// <param name="httpContextAccessor">The HTTP context accessor.</param>
|
||||
public ProcessRequestContextHandler(IHttpContextAccessor httpContextAccessor)
|
||||
{
|
||||
_httpContextAccessor = httpContextAccessor;
|
||||
@@ -32,11 +21,6 @@ public class ProcessRequestContextHandler
|
||||
_pathsToHandle = [backOfficePathSegment, "/.well-known/openid-configuration", "/.well-known/jwks"];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles the server process request context event.
|
||||
/// </summary>
|
||||
/// <param name="context">The process request context.</param>
|
||||
/// <returns>A <see cref="ValueTask"/> representing the asynchronous operation.</returns>
|
||||
public ValueTask HandleAsync(OpenIddictServerEvents.ProcessRequestContext context)
|
||||
{
|
||||
if (SkipOpenIddictHandlingForRequest())
|
||||
@@ -47,11 +31,6 @@ public class ProcessRequestContextHandler
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles the validation process request context event.
|
||||
/// </summary>
|
||||
/// <param name="context">The process request context.</param>
|
||||
/// <returns>A <see cref="ValueTask"/> representing the asynchronous operation.</returns>
|
||||
public ValueTask HandleAsync(OpenIddictValidationEvents.ProcessRequestContext context)
|
||||
{
|
||||
if (SkipOpenIddictHandlingForRequest())
|
||||
|
||||
@@ -7,16 +7,8 @@ using Umbraco.Cms.Web.Common.ApplicationBuilder;
|
||||
|
||||
namespace Umbraco.Cms.Api.Common.DependencyInjection;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for <see cref="IUmbracoBuilder"/> to configure API services.
|
||||
/// </summary>
|
||||
public static class UmbracoBuilderApiExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds Umbraco API OpenAPI/Swagger UI services to the builder.
|
||||
/// </summary>
|
||||
/// <param name="builder">The Umbraco builder.</param>
|
||||
/// <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(OperationIdSelector)))
|
||||
@@ -33,7 +25,6 @@ public static class UmbracoBuilderApiExtensions
|
||||
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")));
|
||||
|
||||
return builder;
|
||||
|
||||
@@ -10,25 +10,13 @@ using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Configuration.Models;
|
||||
using Umbraco.Cms.Core.DependencyInjection;
|
||||
using Umbraco.Cms.Core.Notifications;
|
||||
using Umbraco.Cms.Infrastructure.BackgroundJobs;
|
||||
using Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs.DistributedJobs;
|
||||
using Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Api.Common.DependencyInjection;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for <see cref="IUmbracoBuilder"/> to configure authentication services.
|
||||
/// </summary>
|
||||
public static class UmbracoBuilderAuthExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds OpenIddict authentication services for Umbraco APIs.
|
||||
/// </summary>
|
||||
/// <param name="builder">The Umbraco builder.</param>
|
||||
/// <returns>The Umbraco builder for method chaining.</returns>
|
||||
/// <remarks>
|
||||
/// Configures OpenIddict with authorization code flow (with PKCE), client credentials flow,
|
||||
/// reference tokens, and ASP.NET Core Data Protection for token encryption.
|
||||
/// </remarks>
|
||||
public static IUmbracoBuilder AddUmbracoOpenIddict(this IUmbracoBuilder builder)
|
||||
{
|
||||
if (builder.Services.Any(x => !x.IsKeyedService && x.ImplementationType == typeof(OpenIddictCleanupJob)) is false)
|
||||
@@ -41,6 +29,11 @@ public static class UmbracoBuilderAuthExtensions
|
||||
|
||||
private static void ConfigureOpenIddict(IUmbracoBuilder builder)
|
||||
{
|
||||
// Optionally hide tokens from the back-office.
|
||||
var hideBackOfficeTokens = (builder.Config
|
||||
.GetSection(Constants.Configuration.ConfigBackOfficeTokenCookie)
|
||||
.Get<BackOfficeTokenCookieSettings>() ?? new BackOfficeTokenCookieSettings()).Enabled;
|
||||
|
||||
builder.Services.AddOpenIddict()
|
||||
// Register the OpenIddict server components.
|
||||
.AddServer(options =>
|
||||
@@ -127,30 +120,27 @@ public static class UmbracoBuilderAuthExtensions
|
||||
configuration.UseSingletonHandler<ProcessRequestContextHandler>().SetOrder(OpenIddict.Server.AspNetCore.OpenIddictServerAspNetCoreHandlers.ResolveRequestUri.Descriptor.Order - 1);
|
||||
});
|
||||
|
||||
options.AddEventHandler<OpenIddictServerEvents.ApplyTokenResponseContext>(configuration =>
|
||||
if (hideBackOfficeTokens)
|
||||
{
|
||||
configuration
|
||||
.UseSingletonHandler<HideBackOfficeTokensHandler>()
|
||||
.SetOrder(OpenIddict.Server.AspNetCore.OpenIddictServerAspNetCoreHandlers.ProcessJsonResponse<OpenIddictServerEvents.ApplyTokenResponseContext>.Descriptor.Order - 1);
|
||||
});
|
||||
options.AddEventHandler<OpenIddictServerEvents.ApplyAuthorizationResponseContext>(configuration =>
|
||||
{
|
||||
configuration
|
||||
.UseSingletonHandler<HideBackOfficeTokensHandler>()
|
||||
.SetOrder(OpenIddict.Server.AspNetCore.OpenIddictServerAspNetCoreHandlers.Authentication.ProcessQueryResponse.Descriptor.Order - 1);
|
||||
});
|
||||
options.AddEventHandler<OpenIddictServerEvents.ExtractTokenRequestContext>(configuration =>
|
||||
{
|
||||
configuration
|
||||
.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);
|
||||
});
|
||||
options.AddEventHandler<OpenIddictServerEvents.ApplyTokenResponseContext>(configuration =>
|
||||
{
|
||||
configuration
|
||||
.UseSingletonHandler<HideBackOfficeTokensHandler>()
|
||||
.SetOrder(OpenIddict.Server.AspNetCore.OpenIddictServerAspNetCoreHandlers.ProcessJsonResponse<OpenIddictServerEvents.ApplyTokenResponseContext>.Descriptor.Order - 1);
|
||||
});
|
||||
options.AddEventHandler<OpenIddictServerEvents.ApplyAuthorizationResponseContext>(configuration =>
|
||||
{
|
||||
configuration
|
||||
.UseSingletonHandler<HideBackOfficeTokensHandler>()
|
||||
.SetOrder(OpenIddict.Server.AspNetCore.OpenIddictServerAspNetCoreHandlers.Authentication.ProcessQueryResponse.Descriptor.Order - 1);
|
||||
});
|
||||
options.AddEventHandler<OpenIddictServerEvents.ExtractTokenRequestContext>(configuration =>
|
||||
{
|
||||
configuration
|
||||
.UseSingletonHandler<HideBackOfficeTokensHandler>()
|
||||
.SetOrder(OpenIddict.Server.AspNetCore.OpenIddictServerAspNetCoreHandlers.ExtractPostRequest<OpenIddictServerEvents.ExtractTokenRequestContext>.Descriptor.Order + 1);
|
||||
});
|
||||
}
|
||||
})
|
||||
|
||||
// Register the OpenIddict validation components.
|
||||
@@ -176,18 +166,24 @@ public static class UmbracoBuilderAuthExtensions
|
||||
configuration.UseSingletonHandler<ProcessRequestContextHandler>().SetOrder(OpenIddict.Validation.AspNetCore.OpenIddictValidationAspNetCoreHandlers.ResolveRequestUri.Descriptor.Order - 1);
|
||||
});
|
||||
|
||||
options.AddEventHandler<OpenIddictValidationEvents.ProcessAuthenticationContext>(configuration =>
|
||||
if (hideBackOfficeTokens)
|
||||
{
|
||||
configuration
|
||||
.UseSingletonHandler<HideBackOfficeTokensHandler>()
|
||||
// IMPORTANT: the handler must be AFTER the built-in query string handler, because the client-side SignalR library sometimes appends access tokens to the query string.
|
||||
.SetOrder(OpenIddict.Validation.AspNetCore.OpenIddictValidationAspNetCoreHandlers.ExtractAccessTokenFromQueryString.Descriptor.Order + 1);
|
||||
});
|
||||
options.AddEventHandler<OpenIddictValidationEvents.ProcessAuthenticationContext>(configuration =>
|
||||
{
|
||||
configuration
|
||||
.UseSingletonHandler<HideBackOfficeTokensHandler>()
|
||||
// IMPORTANT: the handler must be AFTER the built-in query string handler, because the client-side SignalR library sometimes appends access tokens to the query string.
|
||||
.SetOrder(OpenIddict.Validation.AspNetCore.OpenIddictValidationAspNetCoreHandlers.ExtractAccessTokenFromQueryString.Descriptor.Order + 1);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
builder.Services.AddSingleton<IDistributedBackgroundJob, OpenIddictCleanupJob>();
|
||||
builder.Services.AddRecurringBackgroundJob<OpenIddictCleanupJob>();
|
||||
builder.Services.ConfigureOptions<ConfigureOpenIddict>();
|
||||
|
||||
builder.AddNotificationHandler<UserLogoutSuccessNotification, HideBackOfficeTokensHandler>();
|
||||
if (hideBackOfficeTokens)
|
||||
{
|
||||
builder.AddNotificationHandler<UserLogoutSuccessNotification, HideBackOfficeTokensHandler>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,4 +33,4 @@ public static class ActionDescriptorApiCommonExtensions
|
||||
|
||||
return mapToApiAttributes.SingleOrDefault()?.ApiName;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,28 +5,16 @@ using Umbraco.Cms.Api.Common.Configuration;
|
||||
|
||||
namespace Umbraco.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for <see cref="MethodInfo"/> to work with API-related attributes.
|
||||
/// </summary>
|
||||
public static class MethodInfoApiCommonExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the API version values from <see cref="MapToApiVersionAttribute"/> applied to the method.
|
||||
/// </summary>
|
||||
/// <param name="methodInfo">The method info to inspect.</param>
|
||||
/// <returns>A pipe-separated string of API version values.</returns>
|
||||
public static string GetMapToApiVersionAttributeValue(this MethodInfo methodInfo)
|
||||
|
||||
public static string? GetMapToApiVersionAttributeValue(this MethodInfo methodInfo)
|
||||
{
|
||||
MapToApiVersionAttribute[] mapToApis = methodInfo.GetCustomAttributes(typeof(MapToApiVersionAttribute), inherit: true).Cast<MapToApiVersionAttribute>().ToArray();
|
||||
|
||||
return string.Join("|", mapToApis.SelectMany(x => x.Versions));
|
||||
return string.Join("|", mapToApis.SelectMany(x=>x.Versions));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the API name from <see cref="MapToApiAttribute"/> applied to the method's declaring type.
|
||||
/// </summary>
|
||||
/// <param name="methodInfo">The method info to inspect.</param>
|
||||
/// <returns>The API name if the attribute is present; otherwise, <c>null</c>.</returns>
|
||||
public static string? GetMapToApiAttributeValue(this MethodInfo methodInfo)
|
||||
{
|
||||
MapToApiAttribute[] mapToApis = (methodInfo.DeclaringType?.GetCustomAttributes(typeof(MapToApiAttribute), inherit: true) ?? Array.Empty<object>()).Cast<MapToApiAttribute>().ToArray();
|
||||
@@ -34,15 +22,6 @@ public static class MethodInfoApiCommonExtensions
|
||||
return mapToApis.SingleOrDefault()?.ApiName;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether the method's declaring type has a <see cref="MapToApiAttribute"/> with the specified API name.
|
||||
/// </summary>
|
||||
/// <param name="methodInfo">The method info to inspect.</param>
|
||||
/// <param name="apiName">The API name to check for.</param>
|
||||
/// <returns>
|
||||
/// <c>true</c> if the attribute is present and matches the specified API name,
|
||||
/// or if the attribute is not present and the API name matches the default API name; otherwise, <c>false</c>.
|
||||
/// </returns>
|
||||
public static bool HasMapToApiAttribute(this MethodInfo methodInfo, string apiName)
|
||||
{
|
||||
var value = methodInfo.GetMapToApiAttributeValue();
|
||||
|
||||
@@ -1,19 +1,9 @@
|
||||
namespace Umbraco.Cms.Api.Common.Filters;
|
||||
namespace Umbraco.Cms.Api.Common.Filters;
|
||||
|
||||
/// <summary>
|
||||
/// Attribute used to specify the named JSON serialization options for a controller.
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Class)]
|
||||
public class JsonOptionsNameAttribute : Attribute
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="JsonOptionsNameAttribute"/> class.
|
||||
/// </summary>
|
||||
/// <param name="jsonOptionsName">The name of the JSON options configuration to use.</param>
|
||||
public JsonOptionsNameAttribute(string jsonOptionsName) => JsonOptionsName = jsonOptionsName;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the name of the JSON options configuration.
|
||||
/// </summary>
|
||||
public string JsonOptionsName { get; }
|
||||
}
|
||||
|
||||
@@ -1,18 +1,10 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Umbraco.Cms.Api.Common.Filters;
|
||||
|
||||
namespace Umbraco.Cms.Api.Common.Json;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for <see cref="HttpContext"/> related to JSON serialization.
|
||||
/// </summary>
|
||||
public static class HttpContextJsonExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the named JSON options configuration for the current endpoint.
|
||||
/// </summary>
|
||||
/// <param name="context">The HTTP context.</param>
|
||||
/// <returns>The JSON options name if specified via <see cref="JsonOptionsNameAttribute"/>; otherwise, <c>null</c>.</returns>
|
||||
public static string? CurrentJsonOptionsName(this HttpContext context)
|
||||
=> context.GetEndpoint()?.Metadata.GetMetadata<JsonOptionsNameAttribute>()?.JsonOptionsName;
|
||||
}
|
||||
|
||||
@@ -1,31 +1,20 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Formatters;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Umbraco.Cms.Api.Common.Json;
|
||||
|
||||
/// <summary>
|
||||
/// A JSON input formatter that only processes requests for endpoints with matching named JSON options.
|
||||
/// </summary>
|
||||
internal sealed class NamedSystemTextJsonInputFormatter : SystemTextJsonInputFormatter
|
||||
{
|
||||
private readonly string _jsonOptionsName;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="NamedSystemTextJsonInputFormatter"/> class.
|
||||
/// </summary>
|
||||
/// <param name="jsonOptionsName">The name of the JSON options configuration this formatter handles.</param>
|
||||
/// <param name="options">The JSON options.</param>
|
||||
/// <param name="logger">The logger.</param>
|
||||
public NamedSystemTextJsonInputFormatter(string jsonOptionsName, JsonOptions options, ILogger<NamedSystemTextJsonInputFormatter> logger)
|
||||
: base(options, logger) =>
|
||||
_jsonOptionsName = jsonOptionsName;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override bool CanRead(InputFormatterContext context)
|
||||
=> context.HttpContext.CurrentJsonOptionsName() == _jsonOptionsName && base.CanRead(context);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async Task<InputFormatterResult> ReadAsync(InputFormatterContext context)
|
||||
{
|
||||
try
|
||||
|
||||
@@ -1,26 +1,17 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Mvc.Formatters;
|
||||
|
||||
namespace Umbraco.Cms.Api.Common.Json;
|
||||
|
||||
/// <summary>
|
||||
/// A JSON output formatter that only processes responses for endpoints with matching named JSON options.
|
||||
/// </summary>
|
||||
internal sealed class NamedSystemTextJsonOutputFormatter : SystemTextJsonOutputFormatter
|
||||
{
|
||||
private readonly string _jsonOptionsName;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="NamedSystemTextJsonOutputFormatter"/> class.
|
||||
/// </summary>
|
||||
/// <param name="jsonOptionsName">The name of the JSON options configuration this formatter handles.</param>
|
||||
/// <param name="jsonSerializerOptions">The JSON serializer options.</param>
|
||||
public NamedSystemTextJsonOutputFormatter(string jsonOptionsName, JsonSerializerOptions jsonSerializerOptions) : base(jsonSerializerOptions)
|
||||
{
|
||||
_jsonOptionsName = jsonOptionsName;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override bool CanWriteResult(OutputFormatterCanWriteContext context)
|
||||
=> context.HttpContext.CurrentJsonOptionsName() == _jsonOptionsName && base.CanWriteResult(context);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Routing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
@@ -16,13 +16,6 @@ public sealed class EmptyCreatedAtActionResult : ActionResult
|
||||
private readonly object _routeValues;
|
||||
private readonly string _resourceIdentifier;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="EmptyCreatedAtActionResult"/> class.
|
||||
/// </summary>
|
||||
/// <param name="actionName">The name of the action to generate the URL for.</param>
|
||||
/// <param name="controllerName">The name of the controller to generate the URL for.</param>
|
||||
/// <param name="routeValues">The route values to use for URL generation.</param>
|
||||
/// <param name="resourceIdentifier">The identifier of the created resource.</param>
|
||||
public EmptyCreatedAtActionResult(string actionName, string controllerName, object routeValues, string resourceIdentifier)
|
||||
{
|
||||
_actionName = actionName;
|
||||
@@ -31,7 +24,6 @@ public sealed class EmptyCreatedAtActionResult : ActionResult
|
||||
_resourceIdentifier = resourceIdentifier;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override void ExecuteResult(ActionContext context)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,35 +1,25 @@
|
||||
using System.Reflection;
|
||||
using System.Reflection;
|
||||
using System.Runtime.Serialization;
|
||||
using System.Text.Json.Nodes;
|
||||
using Microsoft.OpenApi;
|
||||
using Microsoft.OpenApi.Any;
|
||||
using Microsoft.OpenApi.Models;
|
||||
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)
|
||||
public void Apply(OpenApiSchema model, SchemaFilterContext context)
|
||||
{
|
||||
if (model is not OpenApiSchema schema || context.Type.IsEnum is false)
|
||||
if (context.Type.IsEnum)
|
||||
{
|
||||
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);
|
||||
model.Type = "string";
|
||||
model.Format = null;
|
||||
model.Enum.Clear();
|
||||
foreach (var name in Enum.GetNames(context.Type))
|
||||
{
|
||||
var actualName = context.Type.GetField(name)?.GetCustomAttribute<EnumMemberAttribute>()?.Value ?? name;
|
||||
model.Enum.Add(new OpenApiString(actualName));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -2,22 +2,9 @@ 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);
|
||||
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);
|
||||
string Handle(ApiDescription apiDescription);
|
||||
}
|
||||
|
||||
@@ -3,15 +3,7 @@ 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);
|
||||
}
|
||||
|
||||
@@ -1,21 +1,8 @@
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,6 @@
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -1,22 +1,8 @@
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,6 @@
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using Microsoft.OpenApi;
|
||||
using Microsoft.OpenApi.Models;
|
||||
using Swashbuckle.AspNetCore.SwaggerGen;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
@@ -11,13 +11,8 @@ 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)
|
||||
@@ -26,32 +21,25 @@ public class MimeTypeDocumentFilter : IDocumentFilter
|
||||
}
|
||||
|
||||
OpenApiOperation[] operations = swaggerDoc.Paths
|
||||
.SelectMany(path => path.Value.Operations?.Values ?? Enumerable.Empty<OpenApiOperation>())
|
||||
.SelectMany(path => path.Value.Operations.Values)
|
||||
.ToArray();
|
||||
|
||||
static void RemoveUnwantedMimeTypes(IDictionary<string, OpenApiMediaType>? content)
|
||||
void RemoveUnwantedMimeTypes(IDictionary<string, OpenApiMediaType> content)
|
||||
{
|
||||
if (content is null || content.ContainsKey("application/json") is false)
|
||||
if (content.ContainsKey("application/json"))
|
||||
{
|
||||
return;
|
||||
content.RemoveAll(r => r.Key != "application/json");
|
||||
}
|
||||
|
||||
content.RemoveAll(r => r.Key != "application/json");
|
||||
}
|
||||
|
||||
OpenApiRequestBody[] requestBodies = operations
|
||||
.Select(operation => operation.RequestBody)
|
||||
.OfType<OpenApiRequestBody>()
|
||||
.ToArray();
|
||||
OpenApiRequestBody[] requestBodies = operations.Select(operation => operation.RequestBody).WhereNotNull().ToArray();
|
||||
foreach (OpenApiRequestBody requestBody in requestBodies)
|
||||
{
|
||||
RemoveUnwantedMimeTypes(requestBody.Content);
|
||||
}
|
||||
|
||||
OpenApiResponse[] responses = operations
|
||||
.SelectMany(operation => operation.Responses?.Values ?? Enumerable.Empty<IOpenApiResponse>())
|
||||
.OfType<OpenApiResponse>()
|
||||
.ToArray();
|
||||
OpenApiResponse[] responses = operations.SelectMany(operation => operation.Responses.Values).WhereNotNull().ToArray();
|
||||
foreach (OpenApiResponse response in responses)
|
||||
{
|
||||
RemoveUnwantedMimeTypes(response.Content);
|
||||
|
||||
@@ -6,24 +6,14 @@ 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>
|
||||
// NOTE: Left unsealed on purpose, so it is extendable.
|
||||
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)
|
||||
@@ -34,16 +24,9 @@ public class OperationIdHandler : IOperationIdHandler
|
||||
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);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Umbraco.Cms.Api.Common.OpenApi;
|
||||
|
||||
|
||||
@@ -3,30 +3,19 @@ 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.")]
|
||||
[Obsolete("Use non-obsolete constructor. This will be removed in Umbraco 15.")]
|
||||
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));
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using Microsoft.OpenApi;
|
||||
using Microsoft.OpenApi.Models;
|
||||
using Swashbuckle.AspNetCore.SwaggerGen;
|
||||
|
||||
namespace Umbraco.Cms.Api.Common.OpenApi;
|
||||
@@ -10,14 +10,9 @@ 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)
|
||||
@@ -25,6 +20,6 @@ public class RemoveSecuritySchemesDocumentFilter : IDocumentFilter
|
||||
return;
|
||||
}
|
||||
|
||||
swaggerDoc.Components?.SecuritySchemes?.Clear();
|
||||
swaggerDoc.Components.SecuritySchemes.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,20 +3,12 @@ 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>
|
||||
// NOTE: Left unsealed on purpose, so it is extendable.
|
||||
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);
|
||||
|
||||
|
||||
@@ -1,20 +1,12 @@
|
||||
namespace Umbraco.Cms.Api.Common.OpenApi;
|
||||
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));
|
||||
|
||||
@@ -2,33 +2,19 @@ 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);
|
||||
}
|
||||
|
||||
@@ -8,9 +8,6 @@ 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;
|
||||
@@ -18,13 +15,20 @@ public class SubTypesSelector : ISubTypesSelector
|
||||
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>
|
||||
[Obsolete("The settings parameter is not required anymore, use the other constructor instead. Scheduled for removal in Umbraco 17.")]
|
||||
public SubTypesSelector(
|
||||
IOptions<GlobalSettings> settings,
|
||||
IHostingEnvironment hostingEnvironment,
|
||||
IHttpContextAccessor httpContextAccessor,
|
||||
IEnumerable<ISubTypesHandler> subTypeHandlers,
|
||||
IUmbracoJsonTypeInfoResolver umbracoJsonTypeInfoResolver)
|
||||
{
|
||||
_hostingEnvironment = hostingEnvironment;
|
||||
_httpContextAccessor = httpContextAccessor;
|
||||
_subTypeHandlers = subTypeHandlers;
|
||||
_umbracoJsonTypeInfoResolver = umbracoJsonTypeInfoResolver;
|
||||
}
|
||||
|
||||
public SubTypesSelector(
|
||||
IHostingEnvironment hostingEnvironment,
|
||||
IHttpContextAccessor httpContextAccessor,
|
||||
@@ -37,7 +41,6 @@ public class SubTypesSelector : ISubTypesSelector
|
||||
_umbracoJsonTypeInfoResolver = umbracoJsonTypeInfoResolver;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IEnumerable<Type> SubTypes(Type type)
|
||||
{
|
||||
var backOfficePath = _hostingEnvironment.GetBackOfficePath();
|
||||
@@ -46,7 +49,8 @@ public class SubTypesSelector : ISubTypesSelector
|
||||
if (_httpContextAccessor.HttpContext?.Request.Path.StartsWithSegments(swaggerPath) ?? false)
|
||||
{
|
||||
// Split the path into segments
|
||||
var segments = _httpContextAccessor.HttpContext.Request.Path.Value![swaggerPath.Length..]
|
||||
var segments = _httpContextAccessor.HttpContext.Request.Path.Value!
|
||||
.Substring(swaggerPath.Length)
|
||||
.TrimStart(Constants.CharArrays.ForwardSlash)
|
||||
.Split(Constants.CharArrays.ForwardSlash);
|
||||
|
||||
|
||||
@@ -3,25 +3,19 @@ using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.OpenApi;
|
||||
using Microsoft.OpenApi.Models;
|
||||
using Swashbuckle.AspNetCore.SwaggerGen;
|
||||
using Swashbuckle.AspNetCore.SwaggerUI;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Hosting;
|
||||
using Umbraco.Cms.Web.Common.ApplicationBuilder;
|
||||
using Umbraco.Extensions;
|
||||
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;
|
||||
@@ -43,36 +37,15 @@ public class SwaggerRouteTemplatePipelineFilter : UmbracoPipelineFilter
|
||||
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,
|
||||
|
||||
@@ -4,64 +4,30 @@ using Umbraco.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Api.Common.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// Implements output expansion strategy for element-only rendering in the Delivery API.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This strategy handles the expansion and filtering of properties when rendering content
|
||||
/// through the Delivery API based on expand and fields query parameters.
|
||||
/// </remarks>
|
||||
public class ElementOnlyOutputExpansionStrategy : IOutputExpansionStrategy
|
||||
{
|
||||
/// <summary>
|
||||
/// The parameter value indicating all properties should be included.
|
||||
/// </summary>
|
||||
protected const string All = "$all";
|
||||
|
||||
/// <summary>
|
||||
/// The parameter value indicating no properties should be included.
|
||||
/// </summary>
|
||||
protected const string None = "";
|
||||
|
||||
/// <summary>
|
||||
/// The name of the expand query parameter.
|
||||
/// </summary>
|
||||
protected const string ExpandParameterName = "expand";
|
||||
|
||||
/// <summary>
|
||||
/// The name of the fields query parameter.
|
||||
/// </summary>
|
||||
protected const string FieldsParameterName = "fields";
|
||||
|
||||
private readonly IApiPropertyRenderer _propertyRenderer;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the stack of expand property nodes for tracking nested expansions.
|
||||
/// </summary>
|
||||
protected Stack<Node?> ExpandProperties { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the stack of include property nodes for tracking nested field selections.
|
||||
/// </summary>
|
||||
protected Stack<Node?> IncludeProperties { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ElementOnlyOutputExpansionStrategy"/> class.
|
||||
/// </summary>
|
||||
/// <param name="propertyRenderer">The property renderer for converting property values.</param>
|
||||
public ElementOnlyOutputExpansionStrategy(
|
||||
IApiPropertyRenderer propertyRenderer)
|
||||
{
|
||||
_propertyRenderer = propertyRenderer;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public virtual IDictionary<string, object?> MapContentProperties(IPublishedContent content)
|
||||
=> content.ItemType == PublishedItemType.Content
|
||||
? MapProperties(content.Properties)
|
||||
: throw new ArgumentException($"Invalid item type. This method can only be used with item type {nameof(PublishedItemType.Content)}, got: {content.ItemType}");
|
||||
|
||||
/// <inheritdoc/>
|
||||
public virtual IDictionary<string, object?> MapMediaProperties(IPublishedContent media, bool skipUmbracoProperties = true)
|
||||
{
|
||||
if (media.ItemType != PublishedItemType.Media)
|
||||
@@ -79,7 +45,6 @@ public class ElementOnlyOutputExpansionStrategy : IOutputExpansionStrategy
|
||||
: new Dictionary<string, object?>();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public virtual IDictionary<string, object?> MapElementProperties(IPublishedElement element)
|
||||
=> MapProperties(element.Properties, true);
|
||||
|
||||
@@ -122,27 +87,12 @@ public class ElementOnlyOutputExpansionStrategy : IOutputExpansionStrategy
|
||||
private object? GetPropertyValue(IPublishedProperty property)
|
||||
=> _propertyRenderer.GetPropertyValue(property, ExpandProperties.Peek() is not null);
|
||||
|
||||
/// <summary>
|
||||
/// Represents a node in the parsed expand/fields parameter tree structure.
|
||||
/// </summary>
|
||||
protected sealed class Node
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the key of this node.
|
||||
/// </summary>
|
||||
public string Key { get; private set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the child nodes of this node.
|
||||
/// </summary>
|
||||
public List<Node> Items { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Parses an expand/fields parameter value into a node tree structure.
|
||||
/// </summary>
|
||||
/// <param name="value">The parameter value to parse.</param>
|
||||
/// <returns>The root node of the parsed tree.</returns>
|
||||
/// <exception cref="ArgumentException">Thrown when the value has invalid syntax.</exception>
|
||||
public static Node Parse(string value)
|
||||
{
|
||||
// verify that there are as many start brackets as there are end brackets
|
||||
|
||||
-84
@@ -1,84 +0,0 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Options;
|
||||
using OpenIddict.Server;
|
||||
using Umbraco.Cms.Core.Configuration.Models;
|
||||
using Umbraco.Cms.Core.Security;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Infrastructure.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Provides OpenIddict server event handlers to expose the backoffice authentication token via a custom authentication scheme.
|
||||
/// </summary>
|
||||
public class ExposeBackOfficeAuthenticationOpenIddictServerEventsHandler : IOpenIddictServerHandler<OpenIddictServerEvents.GenerateTokenContext>,
|
||||
IOpenIddictServerHandler<OpenIddictServerEvents.ApplyRevocationResponseContext>
|
||||
{
|
||||
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||
private readonly string[] _claimTypes;
|
||||
private readonly TimeSpan _timeOut;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ExposeBackOfficeAuthenticationOpenIddictServerEventsHandler"/> class.
|
||||
/// </summary>
|
||||
public ExposeBackOfficeAuthenticationOpenIddictServerEventsHandler(
|
||||
IHttpContextAccessor httpContextAccessor,
|
||||
IOptions<GlobalSettings> globalSettings,
|
||||
IOptions<BackOfficeIdentityOptions> backOfficeIdentityOptions)
|
||||
{
|
||||
_httpContextAccessor = httpContextAccessor;
|
||||
_timeOut = globalSettings.Value.TimeOut;
|
||||
|
||||
// These are the type identifiers for the claims required by the principal
|
||||
// for the custom authentication scheme.
|
||||
// We make available the ID, user name and allowed applications (sections) claims.
|
||||
_claimTypes =
|
||||
[
|
||||
backOfficeIdentityOptions.Value.ClaimsIdentity.UserIdClaimType,
|
||||
backOfficeIdentityOptions.Value.ClaimsIdentity.UserNameClaimType,
|
||||
Core.Constants.Security.AllowedApplicationsClaimType,
|
||||
];
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
/// <remarks>
|
||||
/// Event handler for when access tokens are generated (created or refreshed).
|
||||
/// </remarks>
|
||||
public async ValueTask HandleAsync(OpenIddictServerEvents.GenerateTokenContext context)
|
||||
{
|
||||
// Only proceed if this is a back-office sign-in.
|
||||
if (context.Principal.Identity?.AuthenticationType != Core.Constants.Security.BackOfficeAuthenticationType)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a new principal with the claims from the authenticated principal.
|
||||
var principal = new ClaimsPrincipal(
|
||||
new ClaimsIdentity(
|
||||
context.Principal.Claims.Where(claim => _claimTypes.Contains(claim.Type)),
|
||||
Core.Constants.Security.BackOfficeExposedAuthenticationType));
|
||||
|
||||
// Sign-in the new principal for the custom authentication scheme.
|
||||
await _httpContextAccessor
|
||||
.GetRequiredHttpContext()
|
||||
.SignInAsync(Core.Constants.Security.BackOfficeExposedAuthenticationType, principal, GetAuthenticationProperties());
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
/// <remarks>
|
||||
/// Event handler for when access tokens are revoked.
|
||||
/// </remarks>
|
||||
public async ValueTask HandleAsync(OpenIddictServerEvents.ApplyRevocationResponseContext context)
|
||||
=> await _httpContextAccessor
|
||||
.GetRequiredHttpContext()
|
||||
.SignOutAsync(Core.Constants.Security.BackOfficeExposedAuthenticationType, GetAuthenticationProperties());
|
||||
|
||||
private AuthenticationProperties GetAuthenticationProperties()
|
||||
=> new()
|
||||
{
|
||||
IsPersistent = true,
|
||||
IssuedUtc = DateTimeOffset.UtcNow,
|
||||
ExpiresUtc = DateTimeOffset.UtcNow.Add(_timeOut)
|
||||
};
|
||||
}
|
||||
@@ -2,77 +2,35 @@ using Umbraco.Cms.Core;
|
||||
|
||||
namespace Umbraco.Cms.Api.Common.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Contains OAuth/OpenID Connect endpoint paths for Umbraco APIs.
|
||||
/// </summary>
|
||||
public static class Paths
|
||||
{
|
||||
/// <summary>
|
||||
/// Contains endpoint paths for back-office authentication.
|
||||
/// </summary>
|
||||
public static class BackOfficeApi
|
||||
{
|
||||
/// <summary>
|
||||
/// The base endpoint template for back-office security endpoints.
|
||||
/// </summary>
|
||||
public const string EndpointTemplate = "security/back-office";
|
||||
|
||||
/// <summary>
|
||||
/// The authorization endpoint path.
|
||||
/// </summary>
|
||||
public static readonly string AuthorizationEndpoint = EndpointPath($"{EndpointTemplate}/authorize");
|
||||
|
||||
/// <summary>
|
||||
/// The token endpoint path.
|
||||
/// </summary>
|
||||
public static readonly string TokenEndpoint = EndpointPath($"{EndpointTemplate}/token");
|
||||
|
||||
/// <summary>
|
||||
/// The logout/sign-out endpoint path.
|
||||
/// </summary>
|
||||
public static readonly string LogoutEndpoint = EndpointPath($"{EndpointTemplate}/signout");
|
||||
|
||||
/// <summary>
|
||||
/// The token revocation endpoint path.
|
||||
/// </summary>
|
||||
public static readonly string RevokeEndpoint = EndpointPath($"{EndpointTemplate}/revoke");
|
||||
|
||||
private static string EndpointPath(string relativePath) => $"/umbraco{Constants.Web.ManagementApiPath}v1/{relativePath}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Contains endpoint paths for member authentication.
|
||||
/// </summary>
|
||||
public static class MemberApi
|
||||
{
|
||||
/// <summary>
|
||||
/// The base endpoint template for member security endpoints.
|
||||
/// </summary>
|
||||
public const string EndpointTemplate = "security/member";
|
||||
|
||||
/// <summary>
|
||||
/// The authorization endpoint path.
|
||||
/// </summary>
|
||||
public static readonly string AuthorizationEndpoint = EndpointPath($"{EndpointTemplate}/authorize");
|
||||
|
||||
/// <summary>
|
||||
/// The token endpoint path.
|
||||
/// </summary>
|
||||
public static readonly string TokenEndpoint = EndpointPath($"{EndpointTemplate}/token");
|
||||
|
||||
/// <summary>
|
||||
/// The logout/sign-out endpoint path.
|
||||
/// </summary>
|
||||
public static readonly string LogoutEndpoint = EndpointPath($"{EndpointTemplate}/signout");
|
||||
|
||||
/// <summary>
|
||||
/// The token revocation endpoint path.
|
||||
/// </summary>
|
||||
public static readonly string RevokeEndpoint = EndpointPath($"{EndpointTemplate}/revoke");
|
||||
|
||||
/// <summary>
|
||||
/// The user info endpoint path.
|
||||
/// </summary>
|
||||
public static readonly string UserinfoEndpoint = EndpointPath($"{EndpointTemplate}/userinfo");
|
||||
|
||||
// NOTE: we're NOT using /api/v1.0/ here because it will clash with the Delivery API docs
|
||||
|
||||
@@ -2,22 +2,9 @@ using System.Text.Json.Serialization.Metadata;
|
||||
|
||||
namespace Umbraco.Cms.Api.Common.Serialization;
|
||||
|
||||
/// <summary>
|
||||
/// Extends <see cref="IJsonTypeInfoResolver"/> with Umbraco-specific type resolution for polymorphic JSON serialization.
|
||||
/// </summary>
|
||||
public interface IUmbracoJsonTypeInfoResolver : IJsonTypeInfoResolver
|
||||
{
|
||||
/// <summary>
|
||||
/// Finds all sub-types of the specified type for polymorphic serialization.
|
||||
/// </summary>
|
||||
/// <param name="type">The base type to find sub-types for.</param>
|
||||
/// <returns>An enumerable of sub-types.</returns>
|
||||
IEnumerable<Type> FindSubTypes(Type type);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the type discriminator value used for polymorphic serialization.
|
||||
/// </summary>
|
||||
/// <param name="type">The type to get the discriminator value for.</param>
|
||||
/// <returns>The discriminator value, or <c>null</c> if not applicable.</returns>
|
||||
string? GetTypeDiscriminatorValue(Type type);
|
||||
}
|
||||
|
||||
@@ -8,26 +8,14 @@ using Umbraco.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Api.Common.Serialization;
|
||||
|
||||
/// <summary>
|
||||
/// Implements JSON type info resolution for Umbraco with support for polymorphic serialization.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This resolver discovers sub-types of interfaces for polymorphic JSON serialization,
|
||||
/// caching results for performance. It also handles type discriminator values for OpenAPI schema generation.
|
||||
/// </remarks>
|
||||
public sealed class UmbracoJsonTypeInfoResolver : DefaultJsonTypeInfoResolver, IUmbracoJsonTypeInfoResolver
|
||||
{
|
||||
private readonly ITypeFinder _typeFinder;
|
||||
private readonly ConcurrentDictionary<Type, ISet<Type>> _subTypesCache = new ConcurrentDictionary<Type, ISet<Type>>();
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="UmbracoJsonTypeInfoResolver"/> class.
|
||||
/// </summary>
|
||||
/// <param name="typeFinder">The type finder for discovering sub-types.</param>
|
||||
public UmbracoJsonTypeInfoResolver(ITypeFinder typeFinder)
|
||||
=> _typeFinder = typeFinder;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IEnumerable<Type> FindSubTypes(Type type)
|
||||
{
|
||||
JsonDerivedTypeAttribute[] explicitJsonDerivedTypes = type
|
||||
@@ -56,7 +44,6 @@ public sealed class UmbracoJsonTypeInfoResolver : DefaultJsonTypeInfoResolver, I
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string? GetTypeDiscriminatorValue(Type type)
|
||||
{
|
||||
JsonDerivedTypeAttribute? jsonDerivedTypeAttribute = type
|
||||
@@ -75,7 +62,6 @@ public sealed class UmbracoJsonTypeInfoResolver : DefaultJsonTypeInfoResolver, I
|
||||
return typeof(IOpenApiDiscriminator).IsAssignableFrom(type) ? type.Name : null;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override JsonTypeInfo GetTypeInfo(Type type, JsonSerializerOptions options)
|
||||
{
|
||||
JsonTypeInfo result = base.GetTypeInfo(type, options);
|
||||
|
||||
@@ -4,12 +4,6 @@
|
||||
<Description>Contains the bits and pieces that are shared between the Umbraco CMS APIs.</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
|
||||
<_Parameter1>Umbraco.Tests.UnitTests</_Parameter1>
|
||||
</AssemblyAttribute>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
@@ -22,6 +16,11 @@
|
||||
<PackageReference Include="OpenIddict.AspNetCore" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Take top-level depedendency on OpenIddict.AspNetCore depends on a vulnerable version -->
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Memory" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Umbraco.Core\Umbraco.Core.csproj" />
|
||||
<ProjectReference Include="..\Umbraco.Web.Common\Umbraco.Web.Common.csproj" />
|
||||
|
||||
@@ -1,28 +1,14 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Umbraco.Cms.Api.Common.ViewModels.Pagination;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a paged collection of items with total count.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of items in the collection.</typeparam>
|
||||
public class PagedViewModel<T>
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the total number of items available.
|
||||
/// </summary>
|
||||
[Required]
|
||||
public long Total { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the items in the current page.
|
||||
/// </summary>
|
||||
[Required]
|
||||
public IEnumerable<T> Items { get; set; } = Enumerable.Empty<T>();
|
||||
|
||||
/// <summary>
|
||||
/// Creates an empty paged view model.
|
||||
/// </summary>
|
||||
/// <returns>An empty <see cref="PagedViewModel{T}"/> instance.</returns>
|
||||
public static PagedViewModel<T> Empty() => new();
|
||||
}
|
||||
|
||||
@@ -2,33 +2,16 @@ using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Umbraco.Cms.Api.Common.ViewModels.Pagination;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a subset of items with counts of items before and after the subset.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of items in the collection.</typeparam>
|
||||
public class SubsetViewModel<T>
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the total number of items before this subset.
|
||||
/// </summary>
|
||||
[Required]
|
||||
public long TotalBefore { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the total number of items after this subset.
|
||||
/// </summary>
|
||||
[Required]
|
||||
public long TotalAfter { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the items in the subset.
|
||||
/// </summary>
|
||||
[Required]
|
||||
public IEnumerable<T> Items { get; set; } = Enumerable.Empty<T>();
|
||||
|
||||
/// <summary>
|
||||
/// Creates an empty subset view model.
|
||||
/// </summary>
|
||||
/// <returns>An empty <see cref="SubsetViewModel{T}"/> instance.</returns>
|
||||
public static SubsetViewModel<T> Empty() => new();
|
||||
}
|
||||
|
||||
@@ -1,382 +0,0 @@
|
||||
# Umbraco.Cms.Api.Delivery
|
||||
|
||||
Headless content delivery REST API for Umbraco CMS. Enables frontend applications to fetch published content, media, and member-protected resources.
|
||||
|
||||
---
|
||||
|
||||
## 1. Architecture
|
||||
|
||||
**Type**: Class Library (NuGet Package)
|
||||
**Target Framework**: .NET 10.0
|
||||
**Purpose**: Content Delivery API for headless CMS scenarios
|
||||
|
||||
### Key Technologies
|
||||
|
||||
- **ASP.NET Core** - Web framework
|
||||
- **OpenIddict** - Member authentication (OAuth 2.0)
|
||||
- **Asp.Versioning** - API versioning (V1, V2)
|
||||
- **Output Caching** - Configurable response caching
|
||||
- **Examine/Lucene** - Content querying
|
||||
|
||||
### Dependencies
|
||||
|
||||
- `Umbraco.Cms.Api.Common` - Shared API infrastructure (OpenAPI, auth)
|
||||
- `Umbraco.Web.Common` - Web functionality
|
||||
|
||||
### Project Structure (86 files)
|
||||
|
||||
```
|
||||
Umbraco.Cms.Api.Delivery/
|
||||
├── Controllers/
|
||||
│ ├── Content/ # Content endpoints (by ID, route, query)
|
||||
│ ├── Media/ # Media endpoints (by ID, path, query)
|
||||
│ └── Security/ # Member auth (authorize, token, signout)
|
||||
├── Querying/
|
||||
│ ├── Filters/ # ContentType, Name, CreateDate, UpdateDate
|
||||
│ ├── Selectors/ # Ancestors, Children, Descendants
|
||||
│ └── Sorts/ # Name, CreateDate, UpdateDate, Level, SortOrder
|
||||
├── Indexing/ # Lucene index field handlers
|
||||
├── Services/ # Business logic and query building
|
||||
├── Caching/ # Output cache policies
|
||||
├── Rendering/ # Output expansion strategies
|
||||
├── Configuration/ # Swagger configuration
|
||||
└── Filters/ # Action filters (access, validation)
|
||||
```
|
||||
|
||||
### Design Patterns
|
||||
|
||||
1. **Strategy Pattern** - Query handlers (`ISelectorHandler`, `IFilterHandler`, `ISortHandler`)
|
||||
2. **Factory Pattern** - `ApiContentQueryFactory` builds Examine queries
|
||||
3. **Template Method** - `ContentApiControllerBase` for shared controller logic
|
||||
4. **Options Pattern** - `DeliveryApiSettings` for all configuration
|
||||
|
||||
---
|
||||
|
||||
## 2. Commands
|
||||
|
||||
See "Quick Reference" section at bottom for common commands.
|
||||
|
||||
---
|
||||
|
||||
## 3. Key Patterns
|
||||
|
||||
### API Versioning (V1 vs V2)
|
||||
|
||||
**V1** (legacy) and **V2** (current) coexist. Key difference is output expansion:
|
||||
|
||||
```csharp
|
||||
// DependencyInjection/UmbracoBuilderExtensions.cs:49-52
|
||||
// V1 uses RequestContextOutputExpansionStrategy
|
||||
// V2+ uses RequestContextOutputExpansionStrategyV2
|
||||
return apiVersion.MajorVersion == 1
|
||||
? provider.GetRequiredService<RequestContextOutputExpansionStrategy>()
|
||||
: provider.GetRequiredService<RequestContextOutputExpansionStrategyV2>();
|
||||
```
|
||||
|
||||
**Why V2**: Improved `expand` and `fields` query parameter parsing (tree-based).
|
||||
|
||||
### Query System Architecture
|
||||
|
||||
Content querying flows through handlers registered in DI:
|
||||
|
||||
1. **Selectors** (`fetch` parameter): `ancestors:id`, `children:id`, `descendants:id`
|
||||
2. **Filters** (`filter[]` parameter): `contentType:alias`, `name:value`, `createDate>2024-01-01`
|
||||
3. **Sorts** (`sort[]` parameter): `name:asc`, `createDate:desc`, `level:asc`
|
||||
|
||||
```csharp
|
||||
// Services/ApiContentQueryService.cs:91-96
|
||||
ISelectorHandler? selectorHandler = _selectorHandlers.FirstOrDefault(h => h.CanHandle(fetch));
|
||||
return selectorHandler?.BuildSelectorOption(fetch);
|
||||
```
|
||||
|
||||
### Path Decoding Workaround
|
||||
|
||||
ASP.NET Core doesn't decode forward slashes in route parameters:
|
||||
|
||||
```csharp
|
||||
// Controllers/DeliveryApiControllerBase.cs:21-31
|
||||
// OpenAPI clients URL-encode paths, but ASP.NET Core doesn't decode "/"
|
||||
// See https://github.com/dotnet/aspnetcore/issues/11544
|
||||
if (path.Contains("%2F", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
path = WebUtility.UrlDecode(path);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Testing
|
||||
|
||||
**Location**: No direct tests - tested via integration tests in test projects
|
||||
|
||||
```bash
|
||||
dotnet test tests/Umbraco.Tests.Integration/ --filter "FullyQualifiedName~Delivery"
|
||||
```
|
||||
|
||||
**Internals exposed to** (csproj lines 28-36):
|
||||
- `Umbraco.Tests.UnitTests`
|
||||
- `Umbraco.Tests.Integration`
|
||||
- `DynamicProxyGenAssembly2` (for mocking)
|
||||
|
||||
**Focus areas**:
|
||||
- Query parsing (selectors, filters, sorts)
|
||||
- Member authentication flows
|
||||
- Output caching behavior
|
||||
- Protected content access
|
||||
|
||||
---
|
||||
|
||||
## 5. Security & Access Control
|
||||
|
||||
### Three Access Modes
|
||||
|
||||
```csharp
|
||||
// Services/ApiAccessService.cs:21-27
|
||||
public bool HasPublicAccess() => _deliveryApiSettings.PublicAccess || HasValidApiKey();
|
||||
public bool HasPreviewAccess() => HasValidApiKey();
|
||||
public bool HasMediaAccess() => _deliveryApiSettings is { PublicAccess: true, Media.PublicAccess: true } || HasValidApiKey();
|
||||
```
|
||||
|
||||
**Access levels**:
|
||||
1. **Public** - No authentication required (if enabled)
|
||||
2. **API Key** - Via `Api-Key` header
|
||||
3. **Preview** - Always requires API key
|
||||
|
||||
### Member Authentication
|
||||
|
||||
OpenIddict-based OAuth 2.0 for member-protected content:
|
||||
|
||||
**Flows supported** (Controllers/Security/MemberController.cs):
|
||||
- Authorization Code + PKCE (line 53)
|
||||
- Client Credentials (line 112)
|
||||
- Refresh Token (line 98)
|
||||
|
||||
**Endpoints**:
|
||||
- `GET /umbraco/delivery/api/v1/security/member/authorize`
|
||||
- `POST /umbraco/delivery/api/v1/security/member/token`
|
||||
- `GET /umbraco/delivery/api/v1/security/member/signout`
|
||||
|
||||
**Scopes**: Only `openid` and `offline_access` allowed for members (line 220-222)
|
||||
|
||||
### Protected Content
|
||||
|
||||
Member access checked via `ProtectedAccess` model:
|
||||
|
||||
```csharp
|
||||
// Controllers/Content/QueryContentApiController.cs:56-57
|
||||
ProtectedAccess protectedAccess = await _requestMemberAccessService.MemberAccessAsync();
|
||||
Attempt<PagedModel<Guid>, ApiContentQueryOperationStatus> queryAttempt =
|
||||
_apiContentQueryService.ExecuteQuery(fetch, filter, sort, protectedAccess, skip, take);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Output Caching
|
||||
|
||||
### Cache Policy Configuration
|
||||
|
||||
```csharp
|
||||
// DependencyInjection/UmbracoBuilderExtensions.cs:120-136
|
||||
// Content and Media have separate cache durations
|
||||
options.AddPolicy(
|
||||
Constants.DeliveryApi.OutputCache.ContentCachePolicy,
|
||||
new DeliveryApiOutputCachePolicy(
|
||||
outputCacheSettings.ContentDuration,
|
||||
new StringValues([AcceptLanguage, AcceptSegment, StartItem])));
|
||||
```
|
||||
|
||||
**Cache invalidation conditions** (Caching/DeliveryApiOutputCachePolicy.cs:31):
|
||||
```csharp
|
||||
// Never cache preview or non-public access
|
||||
context.EnableOutputCaching = requestPreviewService.IsPreview() is false
|
||||
&& apiAccessService.HasPublicAccess();
|
||||
```
|
||||
|
||||
**Vary by headers**: `Accept-Language`, `Accept-Segment`, `Start-Item`
|
||||
|
||||
---
|
||||
|
||||
## 7. Edge Cases & Known Issues
|
||||
|
||||
### Technical Debt (TODOs in codebase)
|
||||
|
||||
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
|
||||
- `CS0618/CS0612` - Obsolete member references
|
||||
|
||||
### Empty Query Results
|
||||
|
||||
Query service returns empty results (not errors) for invalid options:
|
||||
|
||||
```csharp
|
||||
// Services/ApiContentQueryService.cs:54-78
|
||||
// Invalid selector/filter/sort returns fail status with empty result
|
||||
return Attempt.FailWithStatus(ApiContentQueryOperationStatus.SelectorOptionNotFound, emptyResult);
|
||||
```
|
||||
|
||||
### Start Item Fallback
|
||||
|
||||
When no `fetch` parameter provided, uses start item or all content:
|
||||
|
||||
```csharp
|
||||
// Services/ApiContentQueryService.cs:99-112
|
||||
if (_requestStartItemProviderAccessor.TryGetValue(out IRequestStartItemProvider? requestStartItemProvider))
|
||||
{
|
||||
IPublishedContent? startItem = requestStartItemProvider.GetStartItem();
|
||||
// Use descendants of start item
|
||||
}
|
||||
return _apiContentQueryProvider.AllContentSelectorOption(); // Fallback to all
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Project-Specific Notes
|
||||
|
||||
### V1 vs V2 Differences
|
||||
|
||||
| Feature | V1 | V2 |
|
||||
|---------|----|----|
|
||||
| Output expansion | Basic | Tree-based parsing |
|
||||
| `expand` parameter | Flat list | Nested syntax |
|
||||
| `fields` parameter | Limited | Full property selection |
|
||||
| Default expansion strategy | `RequestContextOutputExpansionStrategy` | `RequestContextOutputExpansionStrategyV2` |
|
||||
|
||||
**Migration note**: V1 is deprecated; plan removal when V17+ drops V1 support.
|
||||
|
||||
### JSON Configuration
|
||||
|
||||
Delivery API has its own JSON options (distinct from Management API):
|
||||
|
||||
```csharp
|
||||
// DependencyInjection/UmbracoBuilderExtensions.cs:82-88
|
||||
.AddJsonOptions(Constants.JsonOptionsNames.DeliveryApi, options =>
|
||||
{
|
||||
options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
|
||||
options.JsonSerializerOptions.TypeInfoResolver = new DeliveryApiJsonTypeResolver();
|
||||
options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
|
||||
});
|
||||
```
|
||||
|
||||
### Member Token Revocation
|
||||
|
||||
Tokens automatically revoked on member changes:
|
||||
|
||||
```csharp
|
||||
// DependencyInjection/UmbracoBuilderExtensions.cs:93-96
|
||||
builder.AddNotificationAsyncHandler<MemberSavedNotification, RevokeMemberAuthenticationTokensNotificationHandler>();
|
||||
builder.AddNotificationAsyncHandler<MemberDeletedNotification, RevokeMemberAuthenticationTokensNotificationHandler>();
|
||||
builder.AddNotificationAsyncHandler<AssignedMemberRolesNotification, RevokeMemberAuthenticationTokensNotificationHandler>();
|
||||
builder.AddNotificationAsyncHandler<RemovedMemberRolesNotification, RevokeMemberAuthenticationTokensNotificationHandler>();
|
||||
```
|
||||
|
||||
### External Dependencies
|
||||
|
||||
**Examine/Lucene** (via Core):
|
||||
- Powers content querying
|
||||
- Selector/Filter/Sort handlers build Lucene queries
|
||||
|
||||
**OpenIddict** (via Api.Common):
|
||||
- Member OAuth 2.0 authentication
|
||||
- Reference tokens (not JWT)
|
||||
|
||||
### Configuration (appsettings.json)
|
||||
|
||||
```json
|
||||
{
|
||||
"Umbraco": {
|
||||
"CMS": {
|
||||
"DeliveryApi": {
|
||||
"Enabled": true,
|
||||
"PublicAccess": true,
|
||||
"ApiKey": "your-api-key",
|
||||
"Media": {
|
||||
"Enabled": true,
|
||||
"PublicAccess": true
|
||||
},
|
||||
"MemberAuthorization": {
|
||||
"AuthorizationCodeFlow": { "Enabled": true },
|
||||
"ClientCredentialsFlow": { "Enabled": false }
|
||||
},
|
||||
"OutputCache": {
|
||||
"Enabled": true,
|
||||
"ContentDuration": "00:01:00",
|
||||
"MediaDuration": "00:01:00"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### API Endpoints Summary
|
||||
|
||||
**Content** (`/umbraco/delivery/api/v2/content`):
|
||||
- `GET /item/{id}` - Single content by GUID
|
||||
- `GET /item/{path}` - Single content by route
|
||||
- `GET /items` - Multiple by IDs
|
||||
- `GET /` - Query with fetch/filter/sort
|
||||
|
||||
**Media** (`/umbraco/delivery/api/v2/media`):
|
||||
- `GET /item/{id}` - Single media by GUID
|
||||
- `GET /item/{path}` - Single media by path
|
||||
- `GET /items` - Multiple by IDs
|
||||
- `GET /` - Query media
|
||||
|
||||
**Security** (`/umbraco/delivery/api/v1/security/member`):
|
||||
- `GET /authorize` - Start OAuth flow
|
||||
- `POST /token` - Exchange code for token
|
||||
- `GET /signout` - Revoke session
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### Essential Commands
|
||||
|
||||
```bash
|
||||
# Build project
|
||||
dotnet build src/Umbraco.Cms.Api.Delivery/Umbraco.Cms.Api.Delivery.csproj
|
||||
|
||||
# Pack for NuGet
|
||||
dotnet pack src/Umbraco.Cms.Api.Delivery/Umbraco.Cms.Api.Delivery.csproj -c Release
|
||||
|
||||
# Run integration tests
|
||||
dotnet test tests/Umbraco.Tests.Integration/ --filter "FullyQualifiedName~Delivery"
|
||||
|
||||
# Check packages
|
||||
dotnet list src/Umbraco.Cms.Api.Delivery/Umbraco.Cms.Api.Delivery.csproj package --outdated
|
||||
```
|
||||
|
||||
### Key Classes
|
||||
|
||||
| Class | Purpose | File |
|
||||
|-------|---------|------|
|
||||
| `DeliveryApiControllerBase` | Base controller with path decoding | Controllers/DeliveryApiControllerBase.cs |
|
||||
| `ApiContentQueryService` | Query orchestration | Services/ApiContentQueryService.cs |
|
||||
| `ApiAccessService` | Access control logic | Services/ApiAccessService.cs |
|
||||
| `DeliveryApiOutputCachePolicy` | Cache policy implementation | Caching/DeliveryApiOutputCachePolicy.cs |
|
||||
| `MemberController` | OAuth endpoints | Controllers/Security/MemberController.cs |
|
||||
| `RequestContextOutputExpansionStrategyV2` | V2 output expansion | Rendering/RequestContextOutputExpansionStrategyV2.cs |
|
||||
|
||||
### Important Files
|
||||
|
||||
- `Umbraco.Cms.Api.Delivery.csproj` - Project dependencies
|
||||
- `DependencyInjection/UmbracoBuilderExtensions.cs` - DI registration (lines 33-141)
|
||||
- `Configuration/DeliveryApiConfiguration.cs` - API constants
|
||||
- `Services/ApiContentQueryService.cs` - Query execution
|
||||
|
||||
### Getting Help
|
||||
|
||||
- **Root documentation**: `/CLAUDE.md` - Repository overview
|
||||
- **API Common patterns**: `/src/Umbraco.Cms.Api.Common/CLAUDE.md`
|
||||
- **Official docs**: https://docs.umbraco.com/umbraco-cms/reference/content-delivery-api
|
||||
- **Media docs**: https://docs.umbraco.com/umbraco-cms/reference/content-delivery-api/media-delivery-api
|
||||
|
||||
---
|
||||
|
||||
**This library exposes Umbraco content and media via REST for headless scenarios. Focus on query handlers, access control, and member authentication when working here.**
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.OpenApi;
|
||||
using Microsoft.OpenApi.Models;
|
||||
using Swashbuckle.AspNetCore.SwaggerGen;
|
||||
using Umbraco.Cms.Api.Common.OpenApi;
|
||||
using Umbraco.Cms.Api.Delivery.Filters;
|
||||
|
||||
+22
-8
@@ -1,6 +1,6 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.OpenApi;
|
||||
using Microsoft.OpenApi.Models;
|
||||
using Swashbuckle.AspNetCore.SwaggerGen;
|
||||
using Umbraco.Cms.Api.Common.Security;
|
||||
using Umbraco.Cms.Api.Delivery.Controllers.Content;
|
||||
@@ -35,9 +35,23 @@ public class ConfigureUmbracoMemberAuthenticationDeliveryApiSwaggerGenOptions :
|
||||
return;
|
||||
}
|
||||
|
||||
var schemaRef = new OpenApiSecuritySchemeReference(AuthSchemeName, context.Document);
|
||||
operation.Security ??= new List<OpenApiSecurityRequirement>();
|
||||
operation.Security.Add(new OpenApiSecurityRequirement { [schemaRef] = [] });
|
||||
operation.Security = new List<OpenApiSecurityRequirement>
|
||||
{
|
||||
new OpenApiSecurityRequirement
|
||||
{
|
||||
{
|
||||
new OpenApiSecurityScheme
|
||||
{
|
||||
Reference = new OpenApiReference
|
||||
{
|
||||
Type = ReferenceType.SecurityScheme,
|
||||
Id = AuthSchemeName,
|
||||
}
|
||||
},
|
||||
[]
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public void Apply(OpenApiDocument swaggerDoc, DocumentFilterContext context)
|
||||
@@ -47,7 +61,7 @@ public class ConfigureUmbracoMemberAuthenticationDeliveryApiSwaggerGenOptions :
|
||||
return;
|
||||
}
|
||||
|
||||
swaggerDoc.AddComponent(
|
||||
swaggerDoc.Components.SecuritySchemes.Add(
|
||||
AuthSchemeName,
|
||||
new OpenApiSecurityScheme
|
||||
{
|
||||
@@ -60,9 +74,9 @@ public class ConfigureUmbracoMemberAuthenticationDeliveryApiSwaggerGenOptions :
|
||||
AuthorizationCode = new OpenApiOAuthFlow
|
||||
{
|
||||
AuthorizationUrl = new Uri(Paths.MemberApi.AuthorizationEndpoint, UriKind.Relative),
|
||||
TokenUrl = new Uri(Paths.MemberApi.TokenEndpoint, UriKind.Relative),
|
||||
},
|
||||
},
|
||||
TokenUrl = new Uri(Paths.MemberApi.TokenEndpoint, UriKind.Relative)
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,8 +99,8 @@ public class ByRouteContentApiController : ContentApiItemControllerBase
|
||||
|
||||
private IActionResult RedirectTo(IApiContentRoute redirectRoute)
|
||||
{
|
||||
Response.Headers.Append("Location-Start-Item-Path", redirectRoute.StartItem.Path);
|
||||
Response.Headers.Append("Location-Start-Item-Id", redirectRoute.StartItem.Id.ToString("D"));
|
||||
Response.Headers.Add("Location-Start-Item-Path", redirectRoute.StartItem.Path);
|
||||
Response.Headers.Add("Location-Start-Item-Id", redirectRoute.StartItem.Id.ToString("D"));
|
||||
return RedirectPermanent(redirectRoute.Path);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ using Umbraco.Cms.Api.Delivery.Configuration;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Features;
|
||||
using Umbraco.Cms.Web.Common.Authorization;
|
||||
using Umbraco.Cms.Web.Common.Controllers;
|
||||
|
||||
namespace Umbraco.Cms.Api.Delivery.Controllers;
|
||||
|
||||
@@ -15,7 +14,6 @@ namespace Umbraco.Cms.Api.Delivery.Controllers;
|
||||
[JsonOptionsName(Constants.JsonOptionsNames.DeliveryApi)]
|
||||
[MapToApi(DeliveryApiConfiguration.ApiName)]
|
||||
[Authorize(Policy = AuthorizationPolicies.UmbracoFeatureEnabled)]
|
||||
[MaintenanceModeActionFilter]
|
||||
public abstract class DeliveryApiControllerBase : Controller, IUmbracoFeature
|
||||
{
|
||||
protected string DecodePath(string path)
|
||||
|
||||
-26
@@ -1,26 +0,0 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Umbraco.Cms.Web.Common.ApplicationBuilder;
|
||||
|
||||
namespace Umbraco.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="IApplicationBuilder" /> extensions for the Umbraco Delivery API.
|
||||
/// </summary>
|
||||
public static class DeliveryApiApplicationBuilderExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Sets up routes for the Umbraco Delivery API.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This method maps attribute-routed controllers including the Delivery API endpoints.
|
||||
/// Call this when using <c>AddDeliveryApi()</c> without <c>AddBackOffice()</c>, as the
|
||||
/// backoffice endpoints normally handle the controller mapping.
|
||||
/// </remarks>
|
||||
/// <param name="builder">The Umbraco endpoint builder context.</param>
|
||||
/// <returns>The <see cref="IUmbracoEndpointBuilderContext" /> for chaining.</returns>
|
||||
public static IUmbracoEndpointBuilderContext UseDeliveryApiEndpoints(this IUmbracoEndpointBuilderContext builder)
|
||||
{
|
||||
builder.EndpointRouteBuilder.MapControllers();
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ using Umbraco.Cms.Core.Configuration.Models;
|
||||
using Umbraco.Cms.Core.DeliveryApi;
|
||||
using Umbraco.Cms.Core.DependencyInjection;
|
||||
using Umbraco.Cms.Core.Notifications;
|
||||
using Umbraco.Cms.Core.Security;
|
||||
using Umbraco.Cms.Infrastructure.Security;
|
||||
using Umbraco.Cms.Web.Common.ApplicationBuilder;
|
||||
|
||||
@@ -29,20 +30,8 @@ namespace Umbraco.Extensions;
|
||||
|
||||
public static class UmbracoBuilderExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Add services for the Umbraco Delivery API (headless content delivery).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This method assumes that either <c>AddBackOffice()</c> or <c>AddCore()</c> has already been called.
|
||||
/// It registers Delivery API-specific services such as controllers, output caching, and member authentication.
|
||||
/// </remarks>
|
||||
/// <param name="builder">The Umbraco builder.</param>
|
||||
/// <returns>The Umbraco builder.</returns>
|
||||
public static IUmbracoBuilder AddDeliveryApi(this IUmbracoBuilder builder)
|
||||
{
|
||||
// Delivery API supports member authentication for protected content
|
||||
builder.AddMembersIdentity();
|
||||
|
||||
builder.Services.AddScoped<IRequestStartItemProvider, RequestStartItemProvider>();
|
||||
builder.Services.AddScoped<RequestContextOutputExpansionStrategy>();
|
||||
builder.Services.AddScoped<RequestContextOutputExpansionStrategyV2>();
|
||||
|
||||
@@ -4,11 +4,7 @@ using Umbraco.Cms.Core.DeliveryApi;
|
||||
|
||||
namespace Umbraco.Cms.Api.Delivery.Filters;
|
||||
|
||||
/// <summary>
|
||||
/// An action filter attribute that verifies public or preview access to the Delivery API, returning
|
||||
/// a <c>401 Unauthorized</c> result if access is denied.
|
||||
/// </summary>
|
||||
public sealed class DeliveryApiAccessAttribute : TypeFilterAttribute
|
||||
internal sealed class DeliveryApiAccessAttribute : TypeFilterAttribute
|
||||
{
|
||||
public DeliveryApiAccessAttribute()
|
||||
: base(typeof(DeliveryApiAccessFilter))
|
||||
|
||||
@@ -4,11 +4,7 @@ using Umbraco.Cms.Core.DeliveryApi;
|
||||
|
||||
namespace Umbraco.Cms.Api.Delivery.Filters;
|
||||
|
||||
/// <summary>
|
||||
/// An action filter attribute that verifies public access to the media Delivery API, returning
|
||||
/// a <c>401 Unauthorized</c> result if access is denied.
|
||||
/// </summary>
|
||||
public sealed class DeliveryApiMediaAccessAttribute : TypeFilterAttribute
|
||||
internal sealed class DeliveryApiMediaAccessAttribute : TypeFilterAttribute
|
||||
{
|
||||
public DeliveryApiMediaAccessAttribute()
|
||||
: base(typeof(DeliveryApiMediaAccessFilter))
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using Microsoft.OpenApi;
|
||||
using Microsoft.OpenApi.Any;
|
||||
using Microsoft.OpenApi.Models;
|
||||
using Swashbuckle.AspNetCore.SwaggerGen;
|
||||
using Umbraco.Cms.Api.Delivery.Configuration;
|
||||
using Umbraco.Cms.Api.Delivery.Controllers;
|
||||
using Umbraco.Cms.Api.Delivery.Controllers.Content;
|
||||
using Umbraco.Cms.Core;
|
||||
|
||||
namespace Umbraco.Cms.Api.Delivery.Filters;
|
||||
|
||||
@@ -13,7 +13,7 @@ internal sealed class SwaggerContentDocumentationFilter : SwaggerDocumentationFi
|
||||
|
||||
protected override void ApplyOperation(OpenApiOperation operation, OperationFilterContext context)
|
||||
{
|
||||
operation.Parameters ??= new List<IOpenApiParameter>();
|
||||
operation.Parameters ??= new List<OpenApiParameter>();
|
||||
|
||||
AddExpand(operation, context);
|
||||
|
||||
@@ -21,50 +21,50 @@ internal sealed class SwaggerContentDocumentationFilter : SwaggerDocumentationFi
|
||||
|
||||
operation.Parameters.Add(new OpenApiParameter
|
||||
{
|
||||
Name = Constants.DeliveryApi.HeaderNames.AcceptLanguage,
|
||||
Name = Core.Constants.DeliveryApi.HeaderNames.AcceptLanguage,
|
||||
In = ParameterLocation.Header,
|
||||
Required = false,
|
||||
Description = "Defines the language to return. Use this when querying language variant content items.",
|
||||
Schema = new OpenApiSchema { Type = JsonSchemaType.String },
|
||||
Examples = new Dictionary<string, IOpenApiExample>
|
||||
Schema = new OpenApiSchema { Type = "string" },
|
||||
Examples = new Dictionary<string, OpenApiExample>
|
||||
{
|
||||
{ "Default", new OpenApiExample { Value = string.Empty } },
|
||||
{ "English culture", new OpenApiExample { Value = "en-us" } },
|
||||
},
|
||||
{ "Default", new OpenApiExample { Value = new OpenApiString(string.Empty) } },
|
||||
{ "English culture", new OpenApiExample { Value = new OpenApiString("en-us") } }
|
||||
}
|
||||
});
|
||||
|
||||
operation.Parameters.Add(new OpenApiParameter
|
||||
{
|
||||
Name = Constants.DeliveryApi.HeaderNames.AcceptSegment,
|
||||
Name = Core.Constants.DeliveryApi.HeaderNames.AcceptSegment,
|
||||
In = ParameterLocation.Header,
|
||||
Required = false,
|
||||
Description = "Defines the segment to return. Use this when querying segment variant content items.",
|
||||
Schema = new OpenApiSchema { Type = JsonSchemaType.String },
|
||||
Examples = new Dictionary<string, IOpenApiExample>
|
||||
Schema = new OpenApiSchema { Type = "string" },
|
||||
Examples = new Dictionary<string, OpenApiExample>
|
||||
{
|
||||
{ "Default", new OpenApiExample { Value = string.Empty } },
|
||||
{ "Segment One", new OpenApiExample { Value = "segment-one" } },
|
||||
},
|
||||
{ "Default", new OpenApiExample { Value = new OpenApiString(string.Empty) } },
|
||||
{ "Segment One", new OpenApiExample { Value = new OpenApiString("segment-one") } }
|
||||
}
|
||||
});
|
||||
|
||||
AddApiKey(operation);
|
||||
|
||||
operation.Parameters.Add(new OpenApiParameter
|
||||
{
|
||||
Name = Constants.DeliveryApi.HeaderNames.Preview,
|
||||
Name = Core.Constants.DeliveryApi.HeaderNames.Preview,
|
||||
In = ParameterLocation.Header,
|
||||
Required = false,
|
||||
Description = "Whether to request draft content.",
|
||||
Schema = new OpenApiSchema { Type = JsonSchemaType.Boolean },
|
||||
Schema = new OpenApiSchema { Type = "boolean" }
|
||||
});
|
||||
|
||||
operation.Parameters.Add(new OpenApiParameter
|
||||
{
|
||||
Name = Constants.DeliveryApi.HeaderNames.StartItem,
|
||||
Name = Core.Constants.DeliveryApi.HeaderNames.StartItem,
|
||||
In = ParameterLocation.Header,
|
||||
Required = false,
|
||||
Description = "URL segment or GUID of a root content item.",
|
||||
Schema = new OpenApiSchema { Type = JsonSchemaType.String },
|
||||
Schema = new OpenApiSchema { Type = "string" }
|
||||
});
|
||||
}
|
||||
|
||||
@@ -92,36 +92,105 @@ internal sealed class SwaggerContentDocumentationFilter : SwaggerDocumentationFi
|
||||
}
|
||||
}
|
||||
|
||||
private Dictionary<string, IOpenApiExample> FetchQueryParameterExamples() =>
|
||||
private Dictionary<string, OpenApiExample> FetchQueryParameterExamples() =>
|
||||
new()
|
||||
{
|
||||
{ "Select all", new OpenApiExample { Value = string.Empty } },
|
||||
{ "Select all ancestors of a node by id", new OpenApiExample { Value = "ancestors:id" } },
|
||||
{ "Select all ancestors of a node by path", new OpenApiExample { Value = "ancestors:path" } },
|
||||
{ "Select all children of a node by id", new OpenApiExample { Value = "children:id" } },
|
||||
{ "Select all children of a node by path", new OpenApiExample { Value = "children:path" } },
|
||||
{ "Select all descendants of a node by id", new OpenApiExample { Value = "descendants:id" } },
|
||||
{ "Select all descendants of a node by path", new OpenApiExample { Value = "descendants:path" } },
|
||||
{ "Select all", new OpenApiExample { Value = new OpenApiString(string.Empty) } },
|
||||
{
|
||||
"Select all ancestors of a node by id",
|
||||
new OpenApiExample { Value = new OpenApiString("ancestors:id") }
|
||||
},
|
||||
{
|
||||
"Select all ancestors of a node by path",
|
||||
new OpenApiExample { Value = new OpenApiString("ancestors:path") }
|
||||
},
|
||||
{
|
||||
"Select all children of a node by id",
|
||||
new OpenApiExample { Value = new OpenApiString("children:id") }
|
||||
},
|
||||
{
|
||||
"Select all children of a node by path",
|
||||
new OpenApiExample { Value = new OpenApiString("children:path") }
|
||||
},
|
||||
{
|
||||
"Select all descendants of a node by id",
|
||||
new OpenApiExample { Value = new OpenApiString("descendants:id") }
|
||||
},
|
||||
{
|
||||
"Select all descendants of a node by path",
|
||||
new OpenApiExample { Value = new OpenApiString("descendants:path") }
|
||||
}
|
||||
};
|
||||
|
||||
private Dictionary<string, IOpenApiExample> FilterQueryParameterExamples() =>
|
||||
private Dictionary<string, OpenApiExample> FilterQueryParameterExamples() =>
|
||||
new()
|
||||
{
|
||||
{ "Default filter", new OpenApiExample { Value = string.Empty } },
|
||||
{ "Filter by content type (equals)", new OpenApiExample { Value = new JsonArray { "contentType:alias1" } } },
|
||||
{ "Filter by name (contains)", new OpenApiExample { Value = new JsonArray { "name:nodeName" } } },
|
||||
{ "Filter by creation date (less than)", new OpenApiExample { Value = new JsonArray { "createDate<2024-01-01" } } },
|
||||
{ "Filter by update date (greater than or equal)", new OpenApiExample { Value = new JsonArray { "updateDate>:2023-01-01" } } },
|
||||
{ "Default filter", new OpenApiExample { Value = new OpenApiString(string.Empty) } },
|
||||
{
|
||||
"Filter by content type (equals)",
|
||||
new OpenApiExample { Value = new OpenApiArray { new OpenApiString("contentType:alias1") } }
|
||||
},
|
||||
{
|
||||
"Filter by name (contains)",
|
||||
new OpenApiExample { Value = new OpenApiArray { new OpenApiString("name:nodeName") } }
|
||||
},
|
||||
{
|
||||
"Filter by creation date (less than)",
|
||||
new OpenApiExample { Value = new OpenApiArray { new OpenApiString("createDate<2024-01-01") } }
|
||||
},
|
||||
{
|
||||
"Filter by update date (greater than or equal)",
|
||||
new OpenApiExample { Value = new OpenApiArray { new OpenApiString("updateDate>:2023-01-01") } }
|
||||
}
|
||||
};
|
||||
|
||||
private Dictionary<string, IOpenApiExample> SortQueryParameterExamples() =>
|
||||
private Dictionary<string, OpenApiExample> SortQueryParameterExamples() =>
|
||||
new()
|
||||
{
|
||||
{ "Default sort", new OpenApiExample { Value = string.Empty } },
|
||||
{ "Sort by create date", new OpenApiExample { Value = new JsonArray { "createDate:asc", "createDate:desc" } } },
|
||||
{ "Sort by level", new OpenApiExample { Value = new JsonArray { "level:asc", "level:desc" } } },
|
||||
{ "Sort by name", new OpenApiExample { Value = new JsonArray { "name:asc", "name:desc" } } },
|
||||
{ "Sort by sort order", new OpenApiExample { Value = new JsonArray { "sortOrder:asc", "sortOrder:desc" } } },
|
||||
{ "Sort by update date", new OpenApiExample { Value = new JsonArray { "updateDate:asc", "updateDate:desc" } } },
|
||||
{ "Default sort", new OpenApiExample { Value = new OpenApiString(string.Empty) } },
|
||||
{
|
||||
"Sort by create date",
|
||||
new OpenApiExample
|
||||
{
|
||||
Value = new OpenApiArray
|
||||
{
|
||||
new OpenApiString("createDate:asc"), new OpenApiString("createDate:desc")
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"Sort by level",
|
||||
new OpenApiExample
|
||||
{
|
||||
Value = new OpenApiArray { new OpenApiString("level:asc"), new OpenApiString("level:desc") }
|
||||
}
|
||||
},
|
||||
{
|
||||
"Sort by name",
|
||||
new OpenApiExample
|
||||
{
|
||||
Value = new OpenApiArray { new OpenApiString("name:asc"), new OpenApiString("name:desc") }
|
||||
}
|
||||
},
|
||||
{
|
||||
"Sort by sort order",
|
||||
new OpenApiExample
|
||||
{
|
||||
Value = new OpenApiArray
|
||||
{
|
||||
new OpenApiString("sortOrder:asc"), new OpenApiString("sortOrder:desc")
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"Sort by update date",
|
||||
new OpenApiExample
|
||||
{
|
||||
Value = new OpenApiArray
|
||||
{
|
||||
new OpenApiString("updateDate:asc"), new OpenApiString("updateDate:desc")
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.OpenApi;
|
||||
using Microsoft.OpenApi.Any;
|
||||
using Microsoft.OpenApi.Models;
|
||||
using Swashbuckle.AspNetCore.SwaggerGen;
|
||||
using Umbraco.Cms.Core;
|
||||
|
||||
namespace Umbraco.Cms.Api.Delivery.Filters;
|
||||
|
||||
@@ -9,8 +9,6 @@ internal abstract class SwaggerDocumentationFilterBase<TBaseController>
|
||||
: SwaggerFilterBase<TBaseController>, IOperationFilter, IParameterFilter
|
||||
where TBaseController : Controller
|
||||
{
|
||||
protected abstract string DocumentationLink { get; }
|
||||
|
||||
public void Apply(OpenApiOperation operation, OperationFilterContext context)
|
||||
{
|
||||
if (CanApply(context))
|
||||
@@ -19,19 +17,21 @@ internal abstract class SwaggerDocumentationFilterBase<TBaseController>
|
||||
}
|
||||
}
|
||||
|
||||
public void Apply(IOpenApiParameter parameter, ParameterFilterContext context)
|
||||
public void Apply(OpenApiParameter parameter, ParameterFilterContext context)
|
||||
{
|
||||
if (CanApply(context) && parameter is OpenApiParameter openApiParameter)
|
||||
if (CanApply(context))
|
||||
{
|
||||
ApplyParameter(openApiParameter, context);
|
||||
ApplyParameter(parameter, context);
|
||||
}
|
||||
}
|
||||
|
||||
protected abstract string DocumentationLink { get; }
|
||||
|
||||
protected abstract void ApplyOperation(OpenApiOperation operation, OperationFilterContext context);
|
||||
|
||||
protected abstract void ApplyParameter(OpenApiParameter parameter, ParameterFilterContext context);
|
||||
|
||||
protected void AddQueryParameterDocumentation(OpenApiParameter parameter, Dictionary<string, IOpenApiExample> examples, string description)
|
||||
protected void AddQueryParameterDocumentation(OpenApiParameter parameter, Dictionary<string, OpenApiExample> examples, string description)
|
||||
{
|
||||
parameter.Description = QueryParameterDescription(description);
|
||||
parameter.Examples = examples;
|
||||
@@ -60,19 +60,15 @@ internal abstract class SwaggerDocumentationFilterBase<TBaseController>
|
||||
AddFields(operation);
|
||||
}
|
||||
|
||||
protected void AddApiKey(OpenApiOperation operation)
|
||||
{
|
||||
operation.Parameters ??= new List<IOpenApiParameter>();
|
||||
operation.Parameters.Add(
|
||||
new OpenApiParameter
|
||||
{
|
||||
Name = Constants.DeliveryApi.HeaderNames.ApiKey,
|
||||
In = ParameterLocation.Header,
|
||||
Required = false,
|
||||
Description = "API key specified through configuration to authorize access to the API.",
|
||||
Schema = new OpenApiSchema { Type = JsonSchemaType.String },
|
||||
});
|
||||
}
|
||||
protected void AddApiKey(OpenApiOperation operation) =>
|
||||
operation.Parameters.Add(new OpenApiParameter
|
||||
{
|
||||
Name = Core.Constants.DeliveryApi.HeaderNames.ApiKey,
|
||||
In = ParameterLocation.Header,
|
||||
Required = false,
|
||||
Description = "API key specified through configuration to authorize access to the API.",
|
||||
Schema = new OpenApiSchema { Type = "string" }
|
||||
});
|
||||
|
||||
protected string PaginationDescription(bool skip, string itemType)
|
||||
=> $"Specifies the number of found {itemType} items to {(skip ? "skip" : "take")}. Use this to control pagination of the response.";
|
||||
@@ -86,70 +82,78 @@ internal abstract class SwaggerDocumentationFilterBase<TBaseController>
|
||||
|
||||
// FIXME: remove this when Delivery API V1 has been removed (expectedly in V15)
|
||||
private void AddExpandV1(OpenApiOperation operation)
|
||||
{
|
||||
operation.Parameters ??= new List<IOpenApiParameter>();
|
||||
operation.Parameters.Add(
|
||||
new OpenApiParameter
|
||||
=> operation.Parameters.Add(new OpenApiParameter
|
||||
{
|
||||
Name = "expand",
|
||||
In = ParameterLocation.Query,
|
||||
Required = false,
|
||||
Description = QueryParameterDescription("Defines the properties that should be expanded in the response"),
|
||||
Schema = new OpenApiSchema { Type = "string" },
|
||||
Examples = new Dictionary<string, OpenApiExample>
|
||||
{
|
||||
Name = "expand",
|
||||
In = ParameterLocation.Query,
|
||||
Required = false,
|
||||
Description =
|
||||
QueryParameterDescription("Defines the properties that should be expanded in the response"),
|
||||
Schema = new OpenApiSchema { Type = JsonSchemaType.String },
|
||||
Examples = new Dictionary<string, IOpenApiExample>
|
||||
{ "Expand none", new OpenApiExample { Value = new OpenApiString(string.Empty) } },
|
||||
{ "Expand all", new OpenApiExample { Value = new OpenApiString("all") } },
|
||||
{
|
||||
{ "Expand none", new OpenApiExample { Value = string.Empty } },
|
||||
{ "Expand all", new OpenApiExample { Value = "all" } },
|
||||
{ "Expand specific property", new OpenApiExample { Value = "property:alias1" } },
|
||||
{ "Expand specific properties", new OpenApiExample { Value = "property:alias1,alias2" } },
|
||||
"Expand specific property",
|
||||
new OpenApiExample { Value = new OpenApiString("property:alias1") }
|
||||
},
|
||||
});
|
||||
}
|
||||
{
|
||||
"Expand specific properties",
|
||||
new OpenApiExample { Value = new OpenApiString("property:alias1,alias2") }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
private void AddExpand(OpenApiOperation operation)
|
||||
{
|
||||
operation.Parameters ??= new List<IOpenApiParameter>();
|
||||
operation.Parameters.Add(
|
||||
new OpenApiParameter
|
||||
=> operation.Parameters.Add(new OpenApiParameter
|
||||
{
|
||||
Name = "expand",
|
||||
In = ParameterLocation.Query,
|
||||
Required = false,
|
||||
Description = QueryParameterDescription("Defines the properties that should be expanded in the response"),
|
||||
Schema = new OpenApiSchema { Type = "string" },
|
||||
Examples = new Dictionary<string, OpenApiExample>
|
||||
{
|
||||
Name = "expand",
|
||||
In = ParameterLocation.Query,
|
||||
Required = false,
|
||||
Description =
|
||||
QueryParameterDescription("Defines the properties that should be expanded in the response"),
|
||||
Schema = new OpenApiSchema { Type = JsonSchemaType.String },
|
||||
Examples = new Dictionary<string, IOpenApiExample>
|
||||
{ "Expand none", new OpenApiExample { Value = new OpenApiString(string.Empty) } },
|
||||
{ "Expand all properties", new OpenApiExample { Value = new OpenApiString("properties[$all]") } },
|
||||
{
|
||||
{ "Expand none", new OpenApiExample { Value = string.Empty } },
|
||||
{ "Expand all properties", new OpenApiExample { Value = "properties[$all]" } },
|
||||
{ "Expand specific property", new OpenApiExample { Value = "properties[alias1]" } },
|
||||
{ "Expand specific properties", new OpenApiExample { Value = "properties[alias1,alias2]" } },
|
||||
{ "Expand nested properties", new OpenApiExample { Value = "properties[alias1[properties[nestedAlias1,nestedAlias2]]]" } },
|
||||
"Expand specific property",
|
||||
new OpenApiExample { Value = new OpenApiString("properties[alias1]") }
|
||||
},
|
||||
});
|
||||
}
|
||||
{
|
||||
"Expand specific properties",
|
||||
new OpenApiExample { Value = new OpenApiString("properties[alias1,alias2]") }
|
||||
},
|
||||
{
|
||||
"Expand nested properties",
|
||||
new OpenApiExample { Value = new OpenApiString("properties[alias1[properties[nestedAlias1,nestedAlias2]]]") }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
private void AddFields(OpenApiOperation operation)
|
||||
{
|
||||
operation.Parameters ??= new List<IOpenApiParameter>();
|
||||
operation.Parameters.Add(
|
||||
new OpenApiParameter
|
||||
=> operation.Parameters.Add(new OpenApiParameter
|
||||
{
|
||||
Name = "fields",
|
||||
In = ParameterLocation.Query,
|
||||
Required = false,
|
||||
Description = QueryParameterDescription("Explicitly defines which properties should be included in the response (by default all properties are included)"),
|
||||
Schema = new OpenApiSchema { Type = "string" },
|
||||
Examples = new Dictionary<string, OpenApiExample>
|
||||
{
|
||||
Name = "fields",
|
||||
In = ParameterLocation.Query,
|
||||
Required = false,
|
||||
Description =
|
||||
QueryParameterDescription(
|
||||
"Explicitly defines which properties should be included in the response (by default all properties are included)"),
|
||||
Schema = new OpenApiSchema { Type = JsonSchemaType.String },
|
||||
Examples = new Dictionary<string, IOpenApiExample>
|
||||
{ "Include all properties", new OpenApiExample { Value = new OpenApiString("properties[$all]") } },
|
||||
{
|
||||
{ "Include all properties", new OpenApiExample { Value = "properties[$all]" } },
|
||||
{ "Include only specific property", new OpenApiExample { Value = "properties[alias1]" } },
|
||||
{ "Include only specific properties", new OpenApiExample { Value = "properties[alias1,alias2]" } },
|
||||
{ "Include only specific nested properties", new OpenApiExample { Value = "properties[alias1[properties[nestedAlias1,nestedAlias2]]]" } },
|
||||
"Include only specific property",
|
||||
new OpenApiExample { Value = new OpenApiString("properties[alias1]") }
|
||||
},
|
||||
});
|
||||
}
|
||||
{
|
||||
"Include only specific properties",
|
||||
new OpenApiExample { Value = new OpenApiString("properties[alias1,alias2]") }
|
||||
},
|
||||
{
|
||||
"Include only specific nested properties",
|
||||
new OpenApiExample { Value = new OpenApiString("properties[alias1[properties[nestedAlias1,nestedAlias2]]]") }
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using Microsoft.OpenApi;
|
||||
using Microsoft.OpenApi.Any;
|
||||
using Microsoft.OpenApi.Models;
|
||||
using Swashbuckle.AspNetCore.SwaggerGen;
|
||||
using Umbraco.Cms.Api.Delivery.Configuration;
|
||||
using Umbraco.Cms.Api.Delivery.Controllers;
|
||||
using Umbraco.Cms.Api.Delivery.Controllers.Media;
|
||||
|
||||
namespace Umbraco.Cms.Api.Delivery.Filters;
|
||||
@@ -12,7 +13,7 @@ internal sealed class SwaggerMediaDocumentationFilter : SwaggerDocumentationFilt
|
||||
|
||||
protected override void ApplyOperation(OpenApiOperation operation, OperationFilterContext context)
|
||||
{
|
||||
operation.Parameters ??= new List<IOpenApiParameter>();
|
||||
operation.Parameters ??= new List<OpenApiParameter>();
|
||||
|
||||
AddExpand(operation, context);
|
||||
|
||||
@@ -45,29 +46,77 @@ internal sealed class SwaggerMediaDocumentationFilter : SwaggerDocumentationFilt
|
||||
}
|
||||
}
|
||||
|
||||
private Dictionary<string, IOpenApiExample> FetchQueryParameterExamples() =>
|
||||
private Dictionary<string, OpenApiExample> FetchQueryParameterExamples() =>
|
||||
new()
|
||||
{
|
||||
{ "Select all children at root level", new OpenApiExample { Value = "children:/" } },
|
||||
{ "Select all children of a media item by id", new OpenApiExample { Value = "children:id" } },
|
||||
{ "Select all children of a media item by path", new OpenApiExample { Value = "children:path" } },
|
||||
{
|
||||
"Select all children at root level",
|
||||
new OpenApiExample { Value = new OpenApiString("children:/") }
|
||||
},
|
||||
{
|
||||
"Select all children of a media item by id",
|
||||
new OpenApiExample { Value = new OpenApiString("children:id") }
|
||||
},
|
||||
{
|
||||
"Select all children of a media item by path",
|
||||
new OpenApiExample { Value = new OpenApiString("children:path") }
|
||||
}
|
||||
};
|
||||
|
||||
private Dictionary<string, IOpenApiExample> FilterQueryParameterExamples() =>
|
||||
private Dictionary<string, OpenApiExample> FilterQueryParameterExamples() =>
|
||||
new()
|
||||
{
|
||||
{ "Default filter", new OpenApiExample { Value = string.Empty } },
|
||||
{ "Filter by media type", new OpenApiExample { Value = new JsonArray { "mediaType:alias1" } } },
|
||||
{ "Filter by name", new OpenApiExample { Value = new JsonArray { "name:nodeName" } } },
|
||||
{ "Default filter", new OpenApiExample { Value = new OpenApiString(string.Empty) } },
|
||||
{
|
||||
"Filter by media type",
|
||||
new OpenApiExample { Value = new OpenApiArray { new OpenApiString("mediaType:alias1") } }
|
||||
},
|
||||
{
|
||||
"Filter by name",
|
||||
new OpenApiExample { Value = new OpenApiArray { new OpenApiString("name:nodeName") } }
|
||||
}
|
||||
};
|
||||
|
||||
private Dictionary<string, IOpenApiExample> SortQueryParameterExamples() =>
|
||||
private Dictionary<string, OpenApiExample> SortQueryParameterExamples() =>
|
||||
new()
|
||||
{
|
||||
{ "Default sort", new OpenApiExample { Value = string.Empty } },
|
||||
{ "Sort by create date", new OpenApiExample { Value = new JsonArray { "createDate:asc", "createDate:desc" } } },
|
||||
{ "Sort by name", new OpenApiExample { Value = new JsonArray { "name:asc", "name:desc" } } },
|
||||
{ "Sort by sort order", new OpenApiExample { Value = new JsonArray { "sortOrder:asc", "sortOrder:desc" } } },
|
||||
{ "Sort by update date", new OpenApiExample { Value = new JsonArray { "updateDate:asc", "updateDate:desc" } } },
|
||||
{ "Default sort", new OpenApiExample { Value = new OpenApiString(string.Empty) } },
|
||||
{
|
||||
"Sort by create date",
|
||||
new OpenApiExample
|
||||
{
|
||||
Value = new OpenApiArray
|
||||
{
|
||||
new OpenApiString("createDate:asc"), new OpenApiString("createDate:desc")
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"Sort by name",
|
||||
new OpenApiExample
|
||||
{
|
||||
Value = new OpenApiArray { new OpenApiString("name:asc"), new OpenApiString("name:desc") }
|
||||
}
|
||||
},
|
||||
{
|
||||
"Sort by sort order",
|
||||
new OpenApiExample
|
||||
{
|
||||
Value = new OpenApiArray
|
||||
{
|
||||
new OpenApiString("sortOrder:asc"), new OpenApiString("sortOrder:desc")
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"Sort by update date",
|
||||
new OpenApiExample
|
||||
{
|
||||
Value = new OpenApiArray
|
||||
{
|
||||
new OpenApiString("updateDate:asc"), new OpenApiString("updateDate:desc")
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Umbraco.Cms.Core.DeliveryApi;
|
||||
using Umbraco.Cms.Core.DependencyInjection;
|
||||
using Umbraco.Cms.Core.Models.PublishedContent;
|
||||
using Umbraco.Cms.Core.PublishedCache;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Api.Delivery.Querying;
|
||||
@@ -11,6 +14,30 @@ public abstract class QueryOptionBase
|
||||
private readonly IApiDocumentUrlService _apiDocumentUrlService;
|
||||
private readonly IVariationContextAccessor _variationContextAccessor;
|
||||
|
||||
[Obsolete("Please use the non-obsolete constructor. Will be removed in V17.")]
|
||||
public QueryOptionBase(
|
||||
IPublishedContentCache publishedContentCache,
|
||||
IRequestRoutingService requestRoutingService)
|
||||
: this(
|
||||
requestRoutingService,
|
||||
StaticServiceProvider.Instance.GetRequiredService<IRequestPreviewService>(),
|
||||
StaticServiceProvider.Instance.GetRequiredService<IApiDocumentUrlService>(),
|
||||
StaticServiceProvider.Instance.GetRequiredService<IVariationContextAccessor>())
|
||||
{
|
||||
}
|
||||
|
||||
[Obsolete("Please use the non-obsolete constructor. Will be removed in V17.")]
|
||||
public QueryOptionBase(
|
||||
IPublishedContentCache publishedContentCache,
|
||||
IRequestRoutingService requestRoutingService,
|
||||
IRequestPreviewService requestPreviewService,
|
||||
IRequestCultureService requestCultureService,
|
||||
IApiDocumentUrlService apiDocumentUrlService,
|
||||
IVariationContextAccessor variationContextAccessor)
|
||||
: this(requestRoutingService, requestPreviewService, apiDocumentUrlService, variationContextAccessor)
|
||||
{
|
||||
}
|
||||
|
||||
public QueryOptionBase(
|
||||
IRequestRoutingService requestRoutingService,
|
||||
IRequestPreviewService requestPreviewService,
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Umbraco.Cms.Api.Delivery.Indexing.Selectors;
|
||||
using Umbraco.Cms.Core.DeliveryApi;
|
||||
using Umbraco.Cms.Core.DependencyInjection;
|
||||
using Umbraco.Cms.Core.Models.PublishedContent;
|
||||
using Umbraco.Cms.Core.PublishedCache;
|
||||
using Umbraco.Cms.Core.Services.Navigation;
|
||||
|
||||
namespace Umbraco.Cms.Api.Delivery.Querying.Selectors;
|
||||
@@ -10,6 +13,46 @@ public sealed class AncestorsSelector : QueryOptionBase, ISelectorHandler
|
||||
private readonly IDocumentNavigationQueryService _navigationQueryService;
|
||||
private const string AncestorsSpecifier = "ancestors:";
|
||||
|
||||
[Obsolete("Please use the non-obsolete constructor. Will be removed in V17.")]
|
||||
public AncestorsSelector(
|
||||
IPublishedContentCache publishedContentCache,
|
||||
IRequestRoutingService requestRoutingService,
|
||||
IDocumentNavigationQueryService navigationQueryService,
|
||||
IRequestPreviewService requestPreviewService)
|
||||
: this(
|
||||
requestRoutingService,
|
||||
requestPreviewService,
|
||||
StaticServiceProvider.Instance.GetRequiredService<IApiDocumentUrlService>(),
|
||||
StaticServiceProvider.Instance.GetRequiredService<IVariationContextAccessor>(),
|
||||
navigationQueryService)
|
||||
{
|
||||
}
|
||||
|
||||
[Obsolete("Please use the non-obsolete constructor. Will be removed in V17.")]
|
||||
public AncestorsSelector(
|
||||
IPublishedContentCache publishedContentCache,
|
||||
IRequestRoutingService requestRoutingService,
|
||||
IDocumentNavigationQueryService navigationQueryService)
|
||||
: this(
|
||||
requestRoutingService,
|
||||
StaticServiceProvider.Instance.GetRequiredService<IRequestPreviewService>(),
|
||||
StaticServiceProvider.Instance.GetRequiredService<IApiDocumentUrlService>(),
|
||||
StaticServiceProvider.Instance.GetRequiredService<IVariationContextAccessor>(),
|
||||
navigationQueryService)
|
||||
{
|
||||
}
|
||||
|
||||
[Obsolete("Use the constructor that takes all parameters. Scheduled for removal in V17.")]
|
||||
public AncestorsSelector(IPublishedContentCache publishedContentCache, IRequestRoutingService requestRoutingService)
|
||||
: this(
|
||||
requestRoutingService,
|
||||
StaticServiceProvider.Instance.GetRequiredService<IRequestPreviewService>(),
|
||||
StaticServiceProvider.Instance.GetRequiredService<IApiDocumentUrlService>(),
|
||||
StaticServiceProvider.Instance.GetRequiredService<IVariationContextAccessor>(),
|
||||
StaticServiceProvider.Instance.GetRequiredService<IDocumentNavigationQueryService>())
|
||||
{
|
||||
}
|
||||
|
||||
public AncestorsSelector(
|
||||
IRequestRoutingService requestRoutingService,
|
||||
IRequestPreviewService requestPreviewService,
|
||||
@@ -19,6 +62,18 @@ public sealed class AncestorsSelector : QueryOptionBase, ISelectorHandler
|
||||
: base(requestRoutingService, requestPreviewService, apiDocumentUrlService, variationContextAccessor)
|
||||
=> _navigationQueryService = navigationQueryService;
|
||||
|
||||
[Obsolete("Use the constructor that takes all parameters. Scheduled for removal in V17.")]
|
||||
public AncestorsSelector(
|
||||
IRequestRoutingService requestRoutingService,
|
||||
IPublishedContentCache publishedContentCache,
|
||||
IRequestPreviewService requestPreviewService,
|
||||
IApiDocumentUrlService apiDocumentUrlService,
|
||||
IVariationContextAccessor variationContextAccessor,
|
||||
IDocumentNavigationQueryService navigationQueryService)
|
||||
: this(requestRoutingService, requestPreviewService, apiDocumentUrlService, variationContextAccessor, navigationQueryService)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanHandle(string query)
|
||||
=> query.StartsWith(AncestorsSpecifier, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
@@ -12,6 +12,27 @@ public sealed class ChildrenSelector : QueryOptionBase, ISelectorHandler
|
||||
{
|
||||
private const string ChildrenSpecifier = "children:";
|
||||
|
||||
[Obsolete("Please use the non-obsolete constructor. Will be removed in V17.")]
|
||||
public ChildrenSelector(IPublishedContentCache publishedContentCache, IRequestRoutingService requestRoutingService)
|
||||
: this(
|
||||
requestRoutingService,
|
||||
StaticServiceProvider.Instance.GetRequiredService<IRequestPreviewService>(),
|
||||
StaticServiceProvider.Instance.GetRequiredService<IApiDocumentUrlService>(),
|
||||
StaticServiceProvider.Instance.GetRequiredService<IVariationContextAccessor>())
|
||||
{
|
||||
}
|
||||
|
||||
[Obsolete("Please use the non-obsolete constructor. Will be removed in V17.")]
|
||||
public ChildrenSelector(
|
||||
IPublishedContentCache publishedContentCache,
|
||||
IRequestRoutingService requestRoutingService,
|
||||
IRequestPreviewService requestPreviewService,
|
||||
IApiDocumentUrlService apiDocumentUrlService,
|
||||
IVariationContextAccessor variationContextAccessor)
|
||||
: this(requestRoutingService, requestPreviewService, apiDocumentUrlService, variationContextAccessor)
|
||||
{
|
||||
}
|
||||
|
||||
public ChildrenSelector(
|
||||
IRequestRoutingService requestRoutingService,
|
||||
IRequestPreviewService requestPreviewService,
|
||||
|
||||
@@ -12,6 +12,27 @@ public sealed class DescendantsSelector : QueryOptionBase, ISelectorHandler
|
||||
{
|
||||
private const string DescendantsSpecifier = "descendants:";
|
||||
|
||||
[Obsolete("Please use the non-obsolete constructor. Will be removed in V17.")]
|
||||
public DescendantsSelector(IPublishedContentCache publishedContentCache, IRequestRoutingService requestRoutingService)
|
||||
: this(
|
||||
requestRoutingService,
|
||||
StaticServiceProvider.Instance.GetRequiredService<IRequestPreviewService>(),
|
||||
StaticServiceProvider.Instance.GetRequiredService<IApiDocumentUrlService>(),
|
||||
StaticServiceProvider.Instance.GetRequiredService<IVariationContextAccessor>())
|
||||
{
|
||||
}
|
||||
|
||||
[Obsolete("Please use the non-obsolete constructor. Will be removed in V17.")]
|
||||
public DescendantsSelector(
|
||||
IPublishedContentCache publishedContentCache,
|
||||
IRequestRoutingService requestRoutingService,
|
||||
IRequestPreviewService requestPreviewService,
|
||||
IApiDocumentUrlService apiDocumentUrlService,
|
||||
IVariationContextAccessor variationContextAccessor)
|
||||
: this(requestRoutingService, requestPreviewService, apiDocumentUrlService, variationContextAccessor)
|
||||
{
|
||||
}
|
||||
|
||||
public DescendantsSelector(
|
||||
IRequestRoutingService requestRoutingService,
|
||||
IRequestPreviewService requestPreviewService,
|
||||
|
||||
@@ -66,14 +66,9 @@ internal sealed class DeliveryApiItemsEndpointsMatcherPolicy : MatcherPolicy, IE
|
||||
{
|
||||
ApiVersion[]? supportedApiVersions = endpoint.Metadata.GetMetadata<MapToApiVersionAttribute>()?.Versions.ToArray();
|
||||
|
||||
// If the endpoint is versioned, the requested API version must be among the API versions supported by the endpoint.
|
||||
// If the endpoint is NOT versioned, it cannot be used with a requested API version.
|
||||
if (supportedApiVersions is not null && requestedApiVersion is not null)
|
||||
{
|
||||
return supportedApiVersions.Contains(requestedApiVersion);
|
||||
}
|
||||
|
||||
return requestedApiVersion is null;
|
||||
// if the endpoint is versioned, the requested API version must be among the API versions supported by the endpoint.
|
||||
// if the endpoint is NOT versioned, it cannot be used with a requested API version
|
||||
return supportedApiVersions?.Contains(requestedApiVersion) ?? requestedApiVersion is null;
|
||||
}
|
||||
|
||||
private static bool IsByIdsController(ControllerActionDescriptor? controllerActionDescriptor)
|
||||
|
||||
@@ -2,10 +2,7 @@ using Umbraco.Cms.Web.Common.Routing;
|
||||
|
||||
namespace Umbraco.Cms.Api.Delivery.Routing;
|
||||
|
||||
/// <summary>
|
||||
/// A routing attribute that ensures consistent Delivery API endpoint paths.
|
||||
/// </summary>
|
||||
public sealed class VersionedDeliveryApiRouteAttribute : BackOfficeRouteAttribute
|
||||
internal sealed class VersionedDeliveryApiRouteAttribute : BackOfficeRouteAttribute
|
||||
{
|
||||
public VersionedDeliveryApiRouteAttribute(string template)
|
||||
: base($"delivery/api/v{{version:apiVersion}}/{template.TrimStart('/')}")
|
||||
|
||||
@@ -66,7 +66,7 @@ internal sealed class ApiMediaQueryService : IApiMediaQueryService
|
||||
private IPublishedContent? TryGetByPath(string path, IPublishedMediaCache mediaCache)
|
||||
{
|
||||
var segments = path.Split(Constants.CharArrays.ForwardSlash, StringSplitOptions.RemoveEmptyEntries);
|
||||
IEnumerable<IPublishedContent> currentChildren = GetRootContent(mediaCache);
|
||||
IEnumerable<IPublishedContent> currentChildren = mediaCache.GetAtRoot();
|
||||
IPublishedContent? resolvedMedia = null;
|
||||
|
||||
foreach (var segment in segments)
|
||||
@@ -103,7 +103,7 @@ internal sealed class ApiMediaQueryService : IApiMediaQueryService
|
||||
IPublishedMediaCache mediaCache = GetRequiredPublishedMediaCache();
|
||||
if (childrenOf.Trim(Constants.CharArrays.ForwardSlash).Length == 0)
|
||||
{
|
||||
return GetRootContent(mediaCache);
|
||||
return mediaCache.GetAtRoot();
|
||||
}
|
||||
|
||||
IPublishedContent? parent = Guid.TryParse(childrenOf, out Guid parentKey)
|
||||
@@ -196,8 +196,4 @@ internal sealed class ApiMediaQueryService : IApiMediaQueryService
|
||||
|
||||
return Attempt.SucceedWithStatus(ApiMediaQueryOperationStatus.Success, result);
|
||||
}
|
||||
|
||||
private IEnumerable<IPublishedContent> GetRootContent(IPublishedMediaCache mediaCache)
|
||||
=> _mediaNavigationQueryService.TryGetRootKeys(out IEnumerable<Guid> rootKeys) is false ? []
|
||||
: rootKeys.Select(x => mediaCache.GetById(false, x)).WhereNotNull();
|
||||
}
|
||||
|
||||
@@ -19,6 +19,12 @@ internal sealed partial class RequestCultureService : RequestHeaderHandler, IReq
|
||||
return ValidLanguageHeaderRegex().IsMatch(acceptLanguage) ? acceptLanguage : null;
|
||||
}
|
||||
|
||||
[Obsolete("Use IVariationContextAccessor to manipulate the variation context. Scheduled for removal in V17.")]
|
||||
public void SetRequestCulture(string culture)
|
||||
{
|
||||
// no-op
|
||||
}
|
||||
|
||||
// at the time of writing we're introducing this to get rid of accept-language header values like "en-GB,en-US;q=0.9,en;q=0.8",
|
||||
// so we don't want to be too restrictive in this regex - keep it simple for now.
|
||||
[GeneratedRegex(@"^[\w-]*$")]
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user