Compare commits

..
4513 changed files with 23650 additions and 150570 deletions
-5
View File
@@ -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
-5
View File
@@ -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
+218 -1
View File
@@ -1 +1,218 @@
The full development guide for this repository lives in [CLAUDE.md](../CLAUDE.md). Please read that file for complete instructions on architecture, build steps, testing, branching conventions, and coding patterns.
# Umbraco CMS Development Guide
Always reference these instructions first and fallback to search or bash commands only when you encounter unexpected information that does not match the info here.
## Working Effectively
Bootstrap, build, and test the repository:
- Install .NET SDK (version specified in global.json):
- `curl -sSL https://dot.net/v1/dotnet-install.sh | bash /dev/stdin --version $(jq -r '.sdk.version' global.json)`
- `export PATH="/home/runner/.dotnet:$PATH"`
- Install Node.js (version specified in src/Umbraco.Web.UI.Client/.nvmrc):
- `curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.0/install.sh | bash`
- `export NVM_DIR="$HOME/.nvm" && [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"`
- `nvm install $(cat src/Umbraco.Web.UI.Client/.nvmrc) && nvm use $(cat src/Umbraco.Web.UI.Client/.nvmrc)`
- Fix shallow clone issue (required for GitVersioning):
- `git fetch --unshallow`
- Restore packages:
- `dotnet restore` -- takes 50 seconds. NEVER CANCEL. Set timeout to 90+ seconds.
- Build the solution:
- `dotnet build` -- takes 4.5 minutes. NEVER CANCEL. Set timeout to 10+ minutes.
- Install and build frontend:
- `cd src/Umbraco.Web.UI.Client`
- `npm ci --no-fund --no-audit --prefer-offline` -- takes 11 seconds.
- `npm run build:for:cms` -- takes 1.25 minutes. NEVER CANCEL. Set timeout to 5+ minutes.
- Install and build Login
- `cd src/Umbraco.Web.UI.Login`
- `npm ci --no-fund --no-audit --prefer-offline`
- `npm run build`
- Run the application:
- `cd src/Umbraco.Web.UI`
- `dotnet run --no-build` -- Application runs on https://localhost:44339 and http://localhost:11000
Check out [BUILD.md](./BUILD.md) for more detailed instructions.
## Validation
- ALWAYS run through at least one complete end-to-end scenario after making changes.
- Build and unit tests must pass before committing changes.
- Frontend build produces output in src/Umbraco.Web.UI.Client/dist-cms/ which gets copied to src/Umbraco.Web.UI/wwwroot/umbraco/backoffice/
- Always run `dotnet build` and `npm run build:for:cms` before running the application to see your changes.
- For login-only changes, you can run `npm run build` from src/Umbraco.Web.UI.Login and then `dotnet run --no-build` from src/Umbraco.Web.UI.
- For frontend-only changes, you can run `npm run dev:server` from src/Umbraco.Web.UI.Client for hot reloading.
- Frontend changes should be linted using `npm run lint:fix` which uses Eslint.
## Testing
### Unit Tests (.NET)
- Location: tests/Umbraco.Tests.UnitTests/
- Run: `dotnet test tests/Umbraco.Tests.UnitTests/Umbraco.Tests.UnitTests.csproj --configuration Release --verbosity minimal`
- Duration: ~1 minute with 3,343 tests
- NEVER CANCEL: Set timeout to 5+ minutes
### Integration Tests (.NET)
- Location: tests/Umbraco.Tests.Integration/
- Run: `dotnet test tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj --configuration Release --verbosity minimal`
- NEVER CANCEL: Set timeout to 10+ minutes
### Frontend Tests
- Location: src/Umbraco.Web.UI.Client/
- Run: `npm test` (requires `npx playwright install` first)
- Frontend tests use Web Test Runner with Playwright
### Acceptance Tests (E2E)
- Location: tests/Umbraco.Tests.AcceptanceTest/
- Requires running Umbraco application and configuration
- See tests/Umbraco.Tests.AcceptanceTest/README.md for detailed setup (requires `npx playwright install` first)
## Project Structure
The solution contains 30 C# projects organized as follows:
### Main Application Projects
- **Umbraco.Web.UI**: Main web application project (startup project)
- **Umbraco.Web.UI.Client**: TypeScript frontend (backoffice)
- **Umbraco.Web.UI.Login**: Separate login screen frontend
- **Umbraco.Core**: Core domain models and interfaces
- **Umbraco.Infrastructure**: Data access and infrastructure
- **Umbraco.Cms**: Main CMS package
### API Projects
- **Umbraco.Cms.Api.Management**: Management API
- **Umbraco.Cms.Api.Delivery**: Content Delivery API
- **Umbraco.Cms.Api.Common**: Shared API components
### Persistence Projects
- **Umbraco.Cms.Persistence.SqlServer**: SQL Server support
- **Umbraco.Cms.Persistence.Sqlite**: SQLite support
- **Umbraco.Cms.Persistence.EFCore**: Entity Framework Core abstractions
### Test Projects
- **Umbraco.Tests.UnitTests**: Unit tests
- **Umbraco.Tests.Integration**: Integration tests
- **Umbraco.Tests.AcceptanceTest**: End-to-end tests with Playwright
- **Umbraco.Tests.Common**: Shared test utilities
## Common Tasks
### Running Umbraco in Different Modes
**Production Mode (Standard Development)**
Use this for backend development, testing full builds, or when you don't need hot reloading:
1. Build frontend assets: `cd src/Umbraco.Web.UI.Client && npm run build:for:cms`
2. Run backend: `cd src/Umbraco.Web.UI && dotnet run --no-build`
3. Access backoffice: `https://localhost:44339/umbraco`
4. Application uses compiled frontend from `wwwroot/umbraco/backoffice/`
**Vite Dev Server Mode (Frontend Development with Hot Reload)**
Use this for frontend-only development with hot module reloading:
1. Configure backend for frontend development - Add to `src/Umbraco.Web.UI/appsettings.json` under `Umbraco:CMS:Security`:
```json
"BackOfficeHost": "http://localhost:5173",
"AuthorizeCallbackPathName": "/oauth_complete",
"AuthorizeCallbackLogoutPathName": "/logout",
"AuthorizeCallbackErrorPathName": "/error",
"BackOfficeTokenCookie": {
"SameSite": "None"
}
```
2. Run backend: `cd src/Umbraco.Web.UI && dotnet run --no-build`
3. Run frontend dev server: `cd src/Umbraco.Web.UI.Client && npm run dev:server`
4. Access backoffice: `http://localhost:5173/` (no `/umbraco` prefix)
5. Changes to TypeScript/Lit files hot reload automatically
**Important:** Remove the `BackOfficeHost` configuration before committing or switching back to production mode.
### Backend-Only Development
For backend-only changes, disable frontend builds:
- Comment out the target named "BuildStaticAssetsPreconditions" in src/Umbraco.Cms.StaticAssets.csproj:
```
<!--<Target Name="BuildStaticAssetsPreconditions" BeforeTargets="AssignTargetPaths">
[...]
</Target>-->
```
- Remember to uncomment before committing
### Building NuGet Packages
To build custom NuGet packages for testing:
```bash
dotnet pack -c Release -o Build.Out
dotnet nuget add source [Path to Build.Out folder] -n MyLocalFeed
```
### Regenerating Frontend API Types
When changing Management API:
```bash
cd src/Umbraco.Web.UI.Client
npm run generate:server-api-dev
```
Also update OpenApi.json from /umbraco/swagger/management/swagger.json
## Database Setup
Default configuration supports SQLite for development. For production-like testing:
- Use SQL Server/LocalDb for better performance
- Configure connection string in src/Umbraco.Web.UI/appsettings.json
## Clean Up / Reset
To reset development environment:
```bash
# Remove configuration and database
rm src/Umbraco.Web.UI/appsettings.json
rm -rf src/Umbraco.Web.UI/umbraco/Data
# Full clean (removes all untracked files)
git clean -xdf .
```
## Version Information
- Target Framework: .NET (version specified in global.json)
- Current Version: (specified in version.json)
- Node.js Requirement: (specified in src/Umbraco.Web.UI.Client/.nvmrc)
- npm Requirement: Latest compatible version
## Known Issues
- Build requires full git history (not shallow clone) due to GitVersioning
- Some NuGet package security warnings are expected (SixLabors.ImageSharp vulnerabilities)
- Frontend tests require Playwright browser installation: `npx playwright install`
- Older Node.js versions may show engine compatibility warnings (check .nvmrc for current requirement)
## Timing Expectations
**NEVER CANCEL** these operations - they are expected to take time:
| Operation | Expected Time | Timeout Setting |
| ----------------------- | ------------- | --------------- |
| `dotnet restore` | 50 seconds | 90+ seconds |
| `dotnet build` | 4.5 minutes | 10+ minutes |
| `npm ci` | 11 seconds | 30+ seconds |
| `npm run build:for:cms` | 1.25 minutes | 5+ minutes |
| `npm test` | 2 minutes | 5+ minutes |
| `npm run lint` | 1 minute | 5+ minutes |
| Unit tests | 1 minute | 5+ minutes |
| Integration tests | Variable | 10+ minutes |
Always wait for commands to complete rather than canceling and retrying.
+4
View File
@@ -4,7 +4,9 @@ on:
push:
branches:
- main
- release/*
- v*/dev
- v*/main
paths:
- src/Umbraco.Web.UI.Client/package.json
- src/Umbraco.Web.UI.Client/package-lock.json
@@ -14,7 +16,9 @@ on:
types: [opened, synchronize, reopened, closed]
branches:
- main
- release/*
- v*/dev
- v*/main
workflow_dispatch:
jobs:
+2
View File
@@ -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
-6
View File
@@ -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/
@@ -103,7 +99,6 @@ tools/docfx/
playwright-report
trace.zip
/tests/Umbraco.Tests.AcceptanceTest/results
/tests/Umbraco.Tests.AcceptanceTest/dist
# Ignore auto-generated schema
/src/Umbraco.Cms.Targets/tasks/
@@ -112,7 +107,6 @@ 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
-14
View File
@@ -1,14 +0,0 @@
{
"mcpServers": {
"umbraco-cms": {
"command": "npx",
"args": ["@umbraco-cms/mcp-dev@17"]
},
"playwright": {
"command": "npx",
"args": [
"@playwright/mcp@latest"
]
}
}
}
+2 -168
View File
@@ -259,108 +259,7 @@ Project ownership is distributed across teams. Check individual project director
---
## 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
## 5. Project-Specific Notes
### Centralized Package Management
@@ -393,18 +292,13 @@ The repository contains BOTH (actively supported):
All APIs use **OpenIddict** (OAuth 2.0/OpenID Connect):
- Reference tokens (not JWT) for better security
- **Secure cookie-based token storage** (v17+) - tokens stored in HTTP-only cookies with `__Host-` prefix
- Tokens are redacted from client-side responses and passed via secure cookies only (`[redacted]` placeholder)
- Tokens are redacted from client-side responses and passed via secure cookies only
- ASP.NET Core Data Protection for token encryption
- Configured in `Umbraco.Cms.Api.Common`
- API requests must include credentials (`credentials: include` for fetch)
**Load Balancing Requirement**: All servers must share the same Data Protection key ring.
**Frontend auth pitfalls** — see `src/Umbraco.Web.UI.Client/docs/edge-cases.md` (Auth & Cross-tab section) and `docs/security.md`. Key points:
- Never call `validateToken()` per API request — it revokes the previous reference token (ID2019 errors)
- `window.opener` is set for ANY `window.open()` target, not only OAuth popups — scope guards to the pathname too
- BroadcastChannel does not deliver messages to the sender's own tab
### Content Caching Strategy
**HybridCache** (`Umbraco.PublishedCache.HybridCache`):
@@ -419,65 +313,6 @@ APIs use `Asp.Versioning.Mvc`:
- 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
@@ -532,7 +367,6 @@ dotnet pack -c Release
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
+28 -29
View File
@@ -13,27 +13,27 @@
</ItemGroup>
<!-- Microsoft packages -->
<ItemGroup>
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation" Version="10.0.4" />
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation" Version="10.0.1" />
<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="Microsoft.Data.Sqlite" Version="10.0.1" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.1" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.1" />
<PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.1" />
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.1" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.1" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="10.0.1" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.1" />
<PackageVersion Include="Microsoft.Extensions.FileProviders.Embedded" Version="10.0.1" />
<PackageVersion Include="Microsoft.Extensions.FileProviders.Physical" Version="10.0.1" />
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.1" />
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.1" />
<PackageVersion Include="Microsoft.Extensions.Identity.Core" Version="10.0.1" />
<PackageVersion Include="Microsoft.Extensions.Identity.Stores" Version="10.0.1" />
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.1" />
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.1" />
<PackageVersion Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.1" />
<PackageVersion Include="Microsoft.Extensions.Options.DataAnnotations" Version="10.0.1" />
<PackageVersion Include="Microsoft.Extensions.Caching.Hybrid" Version="10.1.0" />
<PackageVersion Include="System.Linq.Async" Version="7.0.0" />
</ItemGroup>
<!-- Umbraco packages -->
@@ -42,27 +42,27 @@
</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="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.14.1" />
<PackageVersion Include="Markdig" Version="0.44.0" />
<PackageVersion Include="Markdown" Version="2.2.1" />
<PackageVersion Include="MessagePack" Version="3.1.4" />
<PackageVersion Include="MiniProfiler.AspNetCore.Mvc" Version="4.5.4" />
<PackageVersion Include="MiniProfiler.Shared" Version="4.5.4" />
<PackageVersion Include="ncrontab" Version="3.4.0" />
<PackageVersion Include="NPoco" Version="6.2.0" />
<PackageVersion Include="NPoco.SqlServer" Version="6.2.0" />
<PackageVersion Include="NPoco" Version="6.1.0" />
<PackageVersion Include="NPoco.SqlServer" Version="6.1.0" />
<PackageVersion Include="OpenIddict.Abstractions" Version="7.2.0" />
<PackageVersion Include="OpenIddict.AspNetCore" Version="7.2.0" />
<PackageVersion Include="OpenIddict.EntityFrameworkCore" Version="7.2.0" />
<PackageVersion Include="Serilog" Version="4.3.1" />
<PackageVersion Include="Serilog" Version="4.3.0" />
<PackageVersion Include="Serilog.AspNetCore" Version="9.0.0" />
<PackageVersion Include="Serilog.Enrichers.Process" Version="3.0.0" />
<PackageVersion Include="Serilog.Enrichers.Thread" Version="4.0.0" />
@@ -76,8 +76,7 @@
<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="Swashbuckle.AspNetCore" Version="10.0.1" />
</ItemGroup>
<!-- Transitive pinned versions (only required because our direct dependencies have vulnerable versions of transitive dependencies) -->
<ItemGroup>
-144
View File
@@ -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)
+6 -115
View File
@@ -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
dotnet tool install -g docfx
if ($lastexitcode -ne 0){
throw ("Error installing DocFX")
}
@@ -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:
+8 -4
View File
@@ -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}}">
+3 -9
View File
@@ -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."
+2 -2
View File
@@ -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
+1 -5
View File
@@ -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,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,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;
}
@@ -8,9 +8,6 @@ using Umbraco.Cms.Core.DependencyInjection;
namespace Umbraco.Cms.Api.Common.Configuration;
/// <summary>
/// Configures Swagger/OpenAPI generation options for Umbraco APIs.
/// </summary>
public class ConfigureUmbracoSwaggerGenOptions : IConfigureOptions<SwaggerGenOptions>
{
private readonly IOperationIdSelector _operationIdSelector;
@@ -18,13 +15,6 @@ public class ConfigureUmbracoSwaggerGenOptions : IConfigureOptions<SwaggerGenOpt
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,
@@ -37,12 +27,6 @@ public class ConfigureUmbracoSwaggerGenOptions : IConfigureOptions<SwaggerGenOpt
_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,
@@ -56,7 +40,6 @@ public class ConfigureUmbracoSwaggerGenOptions : IConfigureOptions<SwaggerGenOpt
{
}
/// <inheritdoc/>
public void Configure(SwaggerGenOptions swaggerGenOptions)
{
swaggerGenOptions.SwaggerDoc(
@@ -81,14 +64,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,19 +13,10 @@ 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>
{
@@ -35,40 +25,25 @@ internal sealed class HideBackOfficeTokensHandler
// The __Host- prefix enforces secure cookies at browser level (requires Secure, Path=/, no Domain).
// For local development over HTTP, we use a simpler prefix to avoid browser rejection.
private const string SecureCookiePrefix = "__Host-";
private readonly string _accessTokenCookieName = "umbAccessToken";
private readonly string _refreshTokenCookieName = "umbRefreshToken";
private readonly string _pkceCodeCookieName = "umbPkceCode";
private const string AccessTokenCookieName = "umbAccessToken";
private const string RefreshTokenCookieName = "umbRefreshToken";
private const string PkceCodeCookieName = "umbPkceCode";
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly IDataProtectionProvider _dataProtectionProvider;
#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 +63,13 @@ internal sealed class HideBackOfficeTokensHandler
if (context.Response.AccessToken is not null)
{
SetCookie(httpContext, _accessTokenCookieName, context.Response.AccessToken);
SetCookie(httpContext, AccessTokenCookieName, context.Response.AccessToken);
context.Response.AccessToken = RedactedTokenValue;
}
if (context.Response.RefreshToken is not null)
{
SetCookie(httpContext, _refreshTokenCookieName, context.Response.RefreshToken);
SetCookie(httpContext, RefreshTokenCookieName, context.Response.RefreshToken);
context.Response.RefreshToken = RedactedTokenValue;
}
@@ -116,7 +91,7 @@ internal sealed class HideBackOfficeTokensHandler
if (context.Response.Code is not null)
{
SetCookie(GetHttpContext(), _pkceCodeCookieName, context.Response.Code);
SetCookie(GetHttpContext(), PkceCodeCookieName, context.Response.Code);
context.Response.Code = RedactedTokenValue;
}
@@ -138,12 +113,12 @@ internal sealed class HideBackOfficeTokensHandler
// Handle when the PKCE code is being exchanged for an access token.
if (context.Request.Code == RedactedTokenValue
&& TryGetCookie(httpContext, _pkceCodeCookieName, out var code))
&& TryGetCookie(httpContext, PkceCodeCookieName, out var code))
{
context.Request.Code = code;
// We won't need the PKCE cookie after this, let's remove it.
RemoveCookie(httpContext, _pkceCodeCookieName);
RemoveCookie(httpContext, PkceCodeCookieName);
}
else
{
@@ -154,7 +129,7 @@ internal sealed class HideBackOfficeTokensHandler
// Handle when a refresh token is being exchanged for a new access token.
if (context.Request.RefreshToken == RedactedTokenValue
&& TryGetCookie(httpContext, _refreshTokenCookieName, out var refreshToken))
&& TryGetCookie(httpContext, RefreshTokenCookieName, out var refreshToken))
{
context.Request.RefreshToken = refreshToken;
}
@@ -169,40 +144,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 +155,7 @@ internal sealed class HideBackOfficeTokensHandler
return ValueTask.CompletedTask;
}
if (TryGetCookie(GetHttpContext(), _accessTokenCookieName, out var accessToken))
if (TryGetCookie(GetHttpContext(), AccessTokenCookieName, out var accessToken))
{
context.AccessToken = accessToken;
}
@@ -222,7 +163,6 @@ internal sealed class HideBackOfficeTokensHandler
return ValueTask.CompletedTask;
}
/// <inheritdoc/>
public void Handle(UserLogoutSuccessNotification notification)
{
HttpContext? httpContext = _httpContextAccessor.HttpContext;
@@ -234,8 +174,8 @@ internal sealed class HideBackOfficeTokensHandler
return;
}
RemoveCookie(httpContext, _accessTokenCookieName);
RemoveCookie(httpContext, _refreshTokenCookieName);
RemoveCookie(httpContext, AccessTokenCookieName);
RemoveCookie(httpContext, RefreshTokenCookieName);
}
private HttpContext GetHttpContext()
@@ -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)))
@@ -9,26 +9,14 @@ using Umbraco.Cms.Api.Common.Security;
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.Core.Notifications;
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)
@@ -145,12 +133,6 @@ public static class UmbracoBuilderAuthExtensions
.UseSingletonHandler<HideBackOfficeTokensHandler>()
.SetOrder(OpenIddict.Server.AspNetCore.OpenIddictServerAspNetCoreHandlers.ExtractPostRequest<OpenIddictServerEvents.ExtractTokenRequestContext>.Descriptor.Order + 1);
});
options.AddEventHandler<OpenIddictServerEvents.ExtractRevocationRequestContext>(configuration =>
{
configuration
.UseSingletonHandler<HideBackOfficeTokensHandler>()
.SetOrder(OpenIddict.Server.AspNetCore.OpenIddictServerAspNetCoreHandlers.ExtractPostRequest<OpenIddictServerEvents.ExtractRevocationRequestContext>.Descriptor.Order + 1);
});
})
// Register the OpenIddict validation components.
@@ -33,4 +33,4 @@ public static class ActionDescriptorApiCommonExtensions
return mapToApiAttributes.SingleOrDefault()?.ApiName;
}
}
}
@@ -5,16 +5,9 @@ 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)
{
MapToApiVersionAttribute[] mapToApis = methodInfo.GetCustomAttributes(typeof(MapToApiVersionAttribute), inherit: true).Cast<MapToApiVersionAttribute>().ToArray();
@@ -22,11 +15,6 @@ public static class MethodInfoApiCommonExtensions
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,4 +1,4 @@
using System.Reflection;
using System.Reflection;
using System.Runtime.Serialization;
using System.Text.Json.Nodes;
using Microsoft.OpenApi;
@@ -6,16 +6,8 @@ using Swashbuckle.AspNetCore.SwaggerGen;
namespace Umbraco.Cms.Api.Common.OpenApi;
/// <summary>
/// A schema filter that converts enum schemas to string type with enum member names.
/// </summary>
/// <remarks>
/// This filter ensures enums are represented as strings in the OpenAPI schema,
/// using <see cref="EnumMemberAttribute"/> values when available.
/// </remarks>
public class EnumSchemaFilter : ISchemaFilter
{
/// <inheritdoc/>
public void Apply(IOpenApiSchema model, SchemaFilterContext context)
{
if (model is not OpenApiSchema schema || context.Type.IsEnum is false)
@@ -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;
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)
@@ -29,7 +24,7 @@ public class MimeTypeDocumentFilter : IDocumentFilter
.SelectMany(path => path.Value.Operations?.Values ?? Enumerable.Empty<OpenApiOperation>())
.ToArray();
static void RemoveUnwantedMimeTypes(IDictionary<string, OpenApiMediaType>? content)
void RemoveUnwantedMimeTypes(IDictionary<string, OpenApiMediaType>? content)
{
if (content is null || content.ContainsKey("application/json") is false)
{
@@ -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;
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)
@@ -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,6 @@ 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>
public SubTypesSelector(
IHostingEnvironment hostingEnvironment,
IHttpContextAccessor httpContextAccessor,
@@ -37,7 +27,6 @@ public class SubTypesSelector : ISubTypesSelector
_umbracoJsonTypeInfoResolver = umbracoJsonTypeInfoResolver;
}
/// <inheritdoc/>
public IEnumerable<Type> SubTypes(Type type)
{
var backOfficePath = _hostingEnvironment.GetBackOfficePath();
@@ -46,7 +35,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);
@@ -13,15 +13,8 @@ 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 +36,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
@@ -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);
@@ -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();
}
@@ -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)
@@ -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))
@@ -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('/')}")
@@ -1,18 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- https://learn.microsoft.com/dotnet/fundamentals/package-validation/diagnostic-ids -->
<Suppressions xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Umbraco.Cms.Api.Management.Controllers.Document.GetPublicAccessDocumentController.GetPublicAccess(System.Threading.CancellationToken,System.Guid)</Target>
<Left>lib/net10.0/Umbraco.Cms.Api.Management.dll</Left>
<Right>lib/net10.0/Umbraco.Cms.Api.Management.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Umbraco.Cms.Api.Management.Controllers.UrlSegment.ResizeImagingController.Urls(System.Collections.Generic.HashSet{System.Guid},System.Int32,System.Int32,System.Nullable{Umbraco.Cms.Core.Models.ImageCropMode})</Target>
<Left>lib/net10.0/Umbraco.Cms.Api.Management.dll</Left>
<Right>lib/net10.0/Umbraco.Cms.Api.Management.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
</Suppressions>
@@ -140,20 +140,15 @@ public class ConfigureBackOfficeCookieOptions : IConfigureNamedOptions<CookieAut
await securityStampValidator.ValidateAsync(ctx);
// Only reset timestamps when a renewal was already triggered (by the SecurityStampValidator
// or by EnsureTicketRenewalIfKeepUserLoggedIn above).
// When the SecurityStampValidator refreshes the principal, it sets ShouldRenew but updates
// IssuedUtc without updating ExpiresUtc, causing the effective cookie lifetime to shrink
// with each validation. The manual reset here fixes that drift.
// IMPORTANT: Do NOT unconditionally set ShouldRenew or reset IssuedUtc - doing so prevents
// the SecurityStampValidator from ever exceeding its ValidationInterval during active use,
// which breaks AllowConcurrentLogins enforcement.
if (ctx.ShouldRenew)
{
DateTimeOffset now = _timeProvider.GetUtcNow();
ctx.Properties.IssuedUtc = now;
ctx.Properties.ExpiresUtc = now.Add(_globalSettings.TimeOut);
}
// We have to manually specify Issued and Expires,
// because the SecurityStampValidator refreshes the principal every 30 minutes,
// When the principal is refreshed the Issued is update to time of refresh, however, the Expires remains unchanged
// When we then try and renew, the difference of issued and expires effectively becomes the new ExpireTimeSpan
// meaning we effectively lose 30 minutes of our ExpireTimeSpan for EVERY principal refresh if we don't
// https://github.com/dotnet/aspnetcore/blob/main/src/Security/Authentication/Cookies/src/CookieAuthenticationHandler.cs#L115
ctx.Properties.IssuedUtc = _timeProvider.GetUtcNow();
ctx.Properties.ExpiresUtc = _timeProvider.GetUtcNow().Add(_globalSettings.TimeOut);
ctx.ShouldRenew = true;
},
OnSigningIn = ctx =>
{
@@ -1,45 +0,0 @@
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Options;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Extensions;
namespace Umbraco.Cms.Api.Management.Configuration;
/// <summary>
/// Used to configure <see cref="CookieAuthenticationOptions" /> for the back office "exposed" authentication type
/// </summary>
public class ConfigureBackOfficeExposedCookieOptions : IConfigureNamedOptions<CookieAuthenticationOptions>
{
private readonly SecuritySettings _securitySettings;
/// <summary>
/// Initializes a new instance of the <see cref="ConfigureBackOfficeExposedCookieOptions" /> class.
/// </summary>
/// <param name="securitySettings">The <see cref="SecuritySettings" /> options</param>
public ConfigureBackOfficeExposedCookieOptions(IOptions<SecuritySettings> securitySettings)
=> _securitySettings = securitySettings.Value;
/// <inheritdoc />
public void Configure(string? name, CookieAuthenticationOptions options)
{
if (name != Constants.Security.BackOfficeExposedAuthenticationType)
{
return;
}
Configure(options);
}
/// <inheritdoc />
public void Configure(CookieAuthenticationOptions options)
{
options.Cookie.Name = _securitySettings.AuthCookieName.IsNullOrWhiteSpace()
? Constants.Security.BackOfficeExposedCookieName
: $"{_securitySettings.AuthCookieName}{Constants.Security.BackOfficeExposedCookieNamePostfix}";
options.Cookie.HttpOnly = true;
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
options.SlidingExpiration = true;
}
}
@@ -23,6 +23,6 @@ public class ConfigureBackOfficeSecurityStampValidatorOptions : IConfigureOption
public void Configure(BackOfficeSecurityStampValidatorOptions options)
{
options.TimeProvider = _timeProvider;
ConfigureSecurityStampOptions.ConfigureOptions(options, _securitySettings.GetUserAllowConcurrentLogins());
ConfigureSecurityStampOptions.ConfigureOptions(options, _securitySettings);
}
}
@@ -7,6 +7,23 @@ using Umbraco.Cms.Core.Hosting;
namespace Umbraco.Cms.Api.Management;
[BindProperties]
public class BackOfficeLoginModel
{
/// <summary>
/// Gets or sets the value of the "ReturnUrl" query parameter or defaults to the configured Umbraco directory.
/// </summary>
[FromQuery(Name = "ReturnUrl")]
public string? ReturnUrl { get; set; }
/// <summary>
/// The configured Umbraco directory.
/// </summary>
public string? UmbracoUrl { get; set; }
public bool UserIsAlreadyLoggedIn { get; set; }
}
[ApiExplorerSettings(IgnoreApi = true)]
[Route(LoginPath)]
public class BackOfficeLoginController : Controller
@@ -1,20 +0,0 @@
using Microsoft.AspNetCore.Mvc;
namespace Umbraco.Cms.Api.Management;
[BindProperties]
public class BackOfficeLoginModel
{
/// <summary>
/// Gets or sets the value of the "ReturnUrl" query parameter or defaults to the configured Umbraco directory.
/// </summary>
[FromQuery(Name = "ReturnUrl")]
public string? ReturnUrl { get; set; }
/// <summary>
/// The configured Umbraco directory.
/// </summary>
public string? UmbracoUrl { get; set; }
public bool UserIsAlreadyLoggedIn { get; set; }
}
@@ -79,11 +79,11 @@ public abstract class ContentControllerBase : ManagementApiControllerBase
.Build()),
ContentEditingOperationStatus.CannotDeleteWhenReferenced => BadRequest(problemDetailsBuilder
.WithTitle("Cannot delete a referenced content item")
.WithDetail("Cannot delete a referenced content item, while the setting ContentSettings.DisableDeleteWhenReferenced is enabled.")
.WithDetail("Cannot delete a referenced document, while the setting ContentSettings.DisableDeleteWhenReferenced is enabled.")
.Build()),
ContentEditingOperationStatus.CannotMoveToRecycleBinWhenReferenced => BadRequest(problemDetailsBuilder
.WithTitle("Cannot move a referenced content item to the recycle bin")
.WithDetail("Cannot move a referenced content item to the recycle bin, while the setting ContentSettings.DisableDeleteWhenReferenced is enabled.")
.WithTitle("Cannot move a referenced document to the recycle bin")
.WithDetail("Cannot move a referenced document to the recycle bin, while the setting ContentSettings.DisableUnpublishWhenReferenced is enabled.")
.Build()),
ContentEditingOperationStatus.Unknown => StatusCode(
StatusCodes.Status500InternalServerError,
@@ -21,11 +21,13 @@ public class AllCultureController : CultureControllerBase
_cultureService = cultureService;
}
/// <summary>
/// Returns all cultures available for creating languages.
/// </summary>
/// <returns></returns>
[HttpGet]
[MapToApiVersion("1.0")]
[ProducesResponseType(typeof(PagedViewModel<CultureReponseModel>), StatusCodes.Status200OK)]
[EndpointSummary("Gets a paginated collection of cultures available for creating languages.")]
[EndpointDescription("Gets a paginated collection containing the English and localized names of all available cultures.")]
public Task<PagedViewModel<CultureReponseModel>> GetAll(CancellationToken cancellationToken, int skip = 0, int take = 100)
{
CultureInfo[] all = _cultureService.GetValidCultureInfos();
@@ -1,60 +0,0 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Api.Management.ViewModels;
using Umbraco.Cms.Api.Management.ViewModels.DataType;
using Umbraco.Cms.Core.Mapping;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Services;
namespace Umbraco.Cms.Api.Management.Controllers.DataType;
/// <summary>
/// Provides an API controller for retrieving the full details for multiple data types by key.
/// </summary>
[ApiVersion("1.0")]
public class BatchDataTypesController : DataTypeControllerBase
{
private readonly IDataTypeService _dataTypeService;
private readonly IUmbracoMapper _umbracoMapper;
/// <summary>
/// Initializes a new instance of the <see cref="BatchDataTypesController"/> class.
/// </summary>
/// <param name="dataTypeService">The data type service.</param>
/// <param name="umbracoMapper">The presentation model mapper.</param>
public BatchDataTypesController(IDataTypeService dataTypeService, IUmbracoMapper umbracoMapper)
{
_dataTypeService = dataTypeService;
_umbracoMapper = umbracoMapper;
}
[HttpGet("batch")]
[MapToApiVersion("1.0")]
[ProducesResponseType(typeof(BatchResponseModel<DataTypeResponseModel>), StatusCodes.Status200OK)]
[EndpointSummary("Gets multiple data types.")]
[EndpointDescription("Gets multiple data types identified by the provided Ids.")]
public async Task<IActionResult> Batch(
CancellationToken cancellationToken,
[FromQuery(Name = "id")] HashSet<Guid> ids)
{
Guid[] requestedIds = [.. ids];
if (requestedIds.Length == 0)
{
return Ok(new BatchResponseModel<DataTypeResponseModel>());
}
IEnumerable<IDataType> dataTypes = await _dataTypeService.GetAllAsync(requestedIds);
List<IDataType> ordered = OrderByRequestedIds(dataTypes, requestedIds);
var responseModels = ordered.Select(dt => _umbracoMapper.Map<DataTypeResponseModel>(dt)!).ToList();
return Ok(new BatchResponseModel<DataTypeResponseModel>
{
Total = responseModels.Count,
Items = responseModels,
});
}
}
@@ -1,4 +1,4 @@
using Asp.Versioning;
using Asp.Versioning;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Api.Management.ViewModels.DataType;
@@ -24,8 +24,6 @@ public class ByKeyDataTypeController : DataTypeControllerBase
[MapToApiVersion("1.0")]
[ProducesResponseType(typeof(DataTypeResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[EndpointSummary("Gets a data type.")]
[EndpointDescription("Gets a data type identified by the provided Id.")]
public async Task<IActionResult> ByKey(CancellationToken cancellationToken, Guid id)
{
IDataType? dataType = await _dataTypeService.GetAsync(id);
@@ -1,4 +1,4 @@
using Asp.Versioning;
using Asp.Versioning;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
@@ -18,8 +18,6 @@ public class ConfigurationDataTypeController : DataTypeControllerBase
[HttpGet("configuration")]
[MapToApiVersion("1.0")]
[ProducesResponseType(typeof(DatatypeConfigurationResponseModel), StatusCodes.Status200OK)]
[EndpointSummary("Gets the data type configuration.")]
[EndpointDescription("Gets the configuration settings for data types.")]
public Task<IActionResult> Configuration(CancellationToken cancellationToken)
{
var responseModel = new DatatypeConfigurationResponseModel
@@ -29,8 +29,6 @@ public class CopyDataTypeController : DataTypeControllerBase
[MapToApiVersion("1.0")]
[ProducesResponseType(StatusCodes.Status201Created)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[EndpointSummary("Copies a data type.")]
[EndpointDescription("Creates a duplicate of an existing data type identified by the provided unique Id. The copied data type will be given a new Id and have ' (copy)' appended to its name. Optionally, the copy can be placed in a specific container by providing a target container Id.")]
public async Task<IActionResult> Copy(CancellationToken cancellationToken, Guid id, CopyDataTypeRequestModel copyDataTypeRequestModel)
{
IDataType? source = await _dataTypeService.GetAsync(id);
@@ -33,8 +33,6 @@ public class CreateDataTypeController : DataTypeControllerBase
[ProducesResponseType(StatusCodes.Status201Created)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[EndpointSummary("Creates a new data type.")]
[EndpointDescription("Creates a new data type with the configuration specified in the request model.")]
public async Task<IActionResult> Create(CancellationToken cancellationToken, CreateDataTypeRequestModel createDataTypeRequestModel)
{
var attempt = await _dataTypePresentationFactory.CreateAsync(createDataTypeRequestModel);
@@ -29,8 +29,6 @@ public class DeleteDataTypeController : DataTypeControllerBase
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[EndpointSummary("Deletes a data type.")]
[EndpointDescription("Deletes a data type identified by the provided Id.")]
public async Task<IActionResult> Delete(CancellationToken cancellationToken, Guid id)
{
Attempt<IDataType?, DataTypeOperationStatus> result = await _dataTypeService.DeleteAsync(id, CurrentUserKey(_backOfficeSecurityAccessor));
@@ -1,4 +1,4 @@
using Asp.Versioning;
using Asp.Versioning;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Api.Common.ViewModels.Pagination;
@@ -26,8 +26,6 @@ public class FilterDataTypeFilterController : DataTypeFilterControllerBase
[HttpGet]
[MapToApiVersion("1.0")]
[ProducesResponseType(typeof(PagedViewModel<DataTypeItemResponseModel>), StatusCodes.Status200OK)]
[EndpointSummary("Gets a filtered collection of data types.")]
[EndpointDescription("Filters data types based on the provided criteria with support for pagination.")]
public async Task<IActionResult> Filter(
CancellationToken cancellationToken,
int skip = 0,
@@ -1,4 +1,4 @@
using Asp.Versioning;
using Asp.Versioning;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Api.Management.ViewModels.Folder;
@@ -21,7 +21,5 @@ public class ByKeyDataTypeFolderController : DataTypeFolderControllerBase
[MapToApiVersion("1.0")]
[ProducesResponseType(typeof(FolderResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[EndpointSummary("Gets a data type folder.")]
[EndpointDescription("Gets a data type folder identified by the provided Id.")]
public async Task<IActionResult> ByKey(CancellationToken cancellationToken, Guid id) => await GetFolderAsync(id);
}
@@ -1,4 +1,4 @@
using Asp.Versioning;
using Asp.Versioning;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Api.Management.ViewModels.Folder;
@@ -22,8 +22,6 @@ public class CreateDataTypeFolderController : DataTypeFolderControllerBase
[ProducesResponseType(StatusCodes.Status201Created)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[EndpointSummary("Creates a data type folder.")]
[EndpointDescription("Creates a new data type folder with the provided name and parent location.")]
public async Task<IActionResult> Create(CancellationToken cancellationToken, CreateFolderRequestModel createFolderRequestModel)
=> await CreateFolderAsync<ByKeyDataTypeFolderController>(
createFolderRequestModel,
@@ -1,4 +1,4 @@
using Asp.Versioning;
using Asp.Versioning;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Core.Security;
@@ -21,7 +21,5 @@ public class DeleteDataTypeFolderController : DataTypeFolderControllerBase
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[EndpointSummary("Deletes a data type folder.")]
[EndpointDescription("Deletes a data type folder identified by the provided Id.")]
public async Task<IActionResult> Delete(CancellationToken cancellationToken, Guid id) => await DeleteFolderAsync(id);
}
@@ -1,4 +1,4 @@
using Asp.Versioning;
using Asp.Versioning;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Api.Management.ViewModels.Folder;
@@ -22,8 +22,6 @@ public class UpdateDataTypeFolderController : DataTypeFolderControllerBase
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[EndpointSummary("Updates a data type folder.")]
[EndpointDescription("Updates a data type folder identified by the provided Id with the details provided in the request model.")]
public async Task<IActionResult> Update(
CancellationToken cancellationToken,
Guid id,
@@ -21,8 +21,6 @@ public class IsUsedDataTypeController : DataTypeControllerBase
[MapToApiVersion("1.0")]
[ProducesResponseType(typeof(bool), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[EndpointSummary("Checks if a data type is used.")]
[EndpointDescription("Checks if the data type identified by the provided Id is used in any content, media, or member types.")]
public async Task<IActionResult> IsUsed(CancellationToken cancellationToken, Guid id)
{
Attempt<bool, DataTypeOperationStatus> result = await _dataTypeUsageService.HasSavedValuesAsync(id);
@@ -1,39 +0,0 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Api.Management.Services.Entities;
using Umbraco.Cms.Api.Management.ViewModels.Item;
using Umbraco.Cms.Core.Models;
namespace Umbraco.Cms.Api.Management.Controllers.DataType.Item;
[ApiVersion("1.0")]
public class AncestorsDataTypeItemController : DatatypeItemControllerBase
{
private readonly IItemAncestorService _itemAncestorService;
public AncestorsDataTypeItemController(IItemAncestorService itemAncestorService)
=> _itemAncestorService = itemAncestorService;
[HttpGet("ancestors")]
[MapToApiVersion("1.0")]
[ProducesResponseType(typeof(IEnumerable<ItemAncestorsResponseModel<NamedItemResponseModel>>), StatusCodes.Status200OK)]
[EndpointSummary("Gets ancestors for a collection of data type items.")]
[EndpointDescription("Gets the ancestor chains for data type items identified by the provided Ids.")]
public async Task<IActionResult> Ancestors(
CancellationToken cancellationToken,
[FromQuery(Name = "id")] HashSet<Guid> ids)
{
if (ids.Count is 0)
{
return Ok(Enumerable.Empty<ItemAncestorsResponseModel<NamedItemResponseModel>>());
}
IEnumerable<ItemAncestorsResponseModel<NamedItemResponseModel>> result = await _itemAncestorService.GetAncestorsAsync(
UmbracoObjectTypes.DataType,
UmbracoObjectTypes.DataTypeContainer,
ids);
return Ok(result);
}
}
@@ -1,4 +1,4 @@
using Asp.Versioning;
using Asp.Versioning;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Api.Management.ViewModels.DataType.Item;
@@ -23,8 +23,6 @@ public class ItemDatatypeItemController : DatatypeItemControllerBase
[HttpGet]
[MapToApiVersion("1.0")]
[ProducesResponseType(typeof(IEnumerable<DataTypeItemResponseModel>), StatusCodes.Status200OK)]
[EndpointSummary("Gets a collection of data type items.")]
[EndpointDescription("Gets a collection of data type items identified by the provided Ids.")]
public async Task<IActionResult> Item(
CancellationToken cancellationToken,
[FromQuery(Name = "id")] HashSet<Guid> ids)
@@ -1,4 +1,4 @@
using Asp.Versioning;
using Asp.Versioning;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Api.Management.ViewModels.DataType.Item;
@@ -26,8 +26,6 @@ public class SearchDataTypeItemController : DatatypeItemControllerBase
[HttpGet("search")]
[MapToApiVersion("1.0")]
[ProducesResponseType(typeof(PagedModel<DataTypeItemResponseModel>), StatusCodes.Status200OK)]
[EndpointSummary("Searches data type items.")]
[EndpointDescription("Searches data type items by the provided query with pagination support.")]
public async Task<IActionResult> Search(CancellationToken cancellationToken, string query, int skip = 0, int take = 100)
{
PagedModel<IEntitySlim> searchResult = _entitySearchService.Search(UmbracoObjectTypes.DataType, query, skip, take);
@@ -29,8 +29,6 @@ public class MoveDataTypeController : DataTypeControllerBase
[MapToApiVersion("1.0")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[EndpointSummary("Moves a data type.")]
[EndpointDescription("Moves an existing data type identified by Id to a different container. The target container Id must be provided in the request model.")]
public async Task<IActionResult> Move(CancellationToken cancellationToken, Guid id, MoveDataTypeRequestModel moveDataTypeRequestModel)
{
IDataType? source = await _dataTypeService.GetAsync(id);
@@ -27,8 +27,6 @@ public class ReferencedByDataTypeController : DataTypeControllerBase
[HttpGet("{id:guid}/referenced-by")]
[MapToApiVersion("1.0")]
[ProducesResponseType(typeof(PagedViewModel<IReferenceResponseModel>), StatusCodes.Status200OK)]
[EndpointSummary("Gets a paged collection of entities that are referenced by a data type.")]
[EndpointDescription("Gets a paged collection of entities that are referenced by the data type with the provided Id, so you can see where it is being used.")]
public async Task<ActionResult<PagedViewModel<IReferenceResponseModel>>> ReferencedBy(
CancellationToken cancellationToken,
Guid id,
@@ -26,8 +26,6 @@ public class AncestorsDataTypeTreeController : DataTypeTreeControllerBase
[HttpGet("ancestors")]
[MapToApiVersion("1.0")]
[ProducesResponseType(typeof(IEnumerable<DataTypeTreeItemResponseModel>), StatusCodes.Status200OK)]
[EndpointSummary("Gets a collection of ancestor data type folders.")]
[EndpointDescription("Gets a collection of data type folders that are ancestors to the provided Id.")]
public async Task<ActionResult<IEnumerable<DataTypeTreeItemResponseModel>>> Ancestors(CancellationToken cancellationToken, Guid descendantId)
=> await GetAncestors(descendantId);
}
@@ -27,8 +27,6 @@ public class ChildrenDataTypeTreeController : DataTypeTreeControllerBase
[HttpGet("children")]
[MapToApiVersion("1.0")]
[ProducesResponseType(typeof(PagedViewModel<DataTypeTreeItemResponseModel>), StatusCodes.Status200OK)]
[EndpointSummary("Gets a collection of data type tree child items.")]
[EndpointDescription("Gets a paginated collection of data type tree items that are children of the provided parent Id.")]
public async Task<ActionResult<PagedViewModel<DataTypeTreeItemResponseModel>>> Children(CancellationToken cancellationToken, Guid parentId, int skip = 0, int take = 100, bool foldersOnly = false)
{
RenderFoldersOnly(foldersOnly);
@@ -31,24 +31,8 @@ public class DataTypeTreeControllerBase : FolderTreeControllerBase<DataTypeTreeI
{
}
[Obsolete("Please use the constructor taking all parameters. Scheduled for removal in Umbraco 19.")]
public DataTypeTreeControllerBase(IEntityService entityService, FlagProviderCollection flagProviders, IDataTypeService dataTypeService)
: this(
entityService,
flagProviders,
StaticServiceProvider.Instance.GetRequiredService<IEntitySearchService>(),
StaticServiceProvider.Instance.GetRequiredService<IIdKeyMap>(),
dataTypeService)
{
}
public DataTypeTreeControllerBase(
IEntityService entityService,
FlagProviderCollection flagProviders,
IEntitySearchService entitySearchService,
IIdKeyMap idKeyMap,
IDataTypeService dataTypeService)
: base(entityService, flagProviders, entitySearchService, idKeyMap) =>
: base(entityService, flagProviders) =>
_dataTypeService = dataTypeService;
protected override UmbracoObjectTypes ItemObjectType => UmbracoObjectTypes.DataType;
@@ -59,8 +43,8 @@ public class DataTypeTreeControllerBase : FolderTreeControllerBase<DataTypeTreeI
{
get
{
var ordering = Ordering.By(Infrastructure.Persistence.Dtos.NodeDto.NodeObjectTypeColumnName, Direction.Descending); // We need to override to change direction
ordering.Next = Ordering.By(Infrastructure.Persistence.Dtos.NodeDto.TextColumnName);
var ordering = Ordering.By(nameof(Infrastructure.Persistence.Dtos.NodeDto.NodeObjectType), Direction.Descending); // We need to override to change direction
ordering.Next = Ordering.By(nameof(Infrastructure.Persistence.Dtos.NodeDto.Text));
return ordering;
}
@@ -27,8 +27,6 @@ public class RootDataTypeTreeController : DataTypeTreeControllerBase
[HttpGet("root")]
[MapToApiVersion("1.0")]
[ProducesResponseType(typeof(PagedViewModel<DataTypeTreeItemResponseModel>), StatusCodes.Status200OK)]
[EndpointSummary("Gets a collection of data type items from the root of the tree.")]
[EndpointDescription("Gets a paginated collection of data type items from the root of the tree with optional filtering.")]
public async Task<ActionResult<PagedViewModel<DataTypeTreeItemResponseModel>>> Root(CancellationToken cancellationToken, int skip = 0, int take = 100, bool foldersOnly = false)
{
RenderFoldersOnly(foldersOnly);
@@ -1,30 +0,0 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Api.Common.ViewModels.Pagination;
using Umbraco.Cms.Api.Management.Services.Flags;
using Umbraco.Cms.Api.Management.ViewModels.Tree;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Services;
namespace Umbraco.Cms.Api.Management.Controllers.DataType.Tree;
[ApiVersion("1.0")]
public class SearchDataTypeTreeController : DataTypeTreeControllerBase
{
public SearchDataTypeTreeController(
IEntityService entityService,
FlagProviderCollection flagProviders,
IEntitySearchService entitySearchService,
IIdKeyMap idKeyMap,
IDataTypeService dataTypeService)
: base(entityService, flagProviders, entitySearchService, idKeyMap, dataTypeService)
{
}
[HttpGet("search")]
[MapToApiVersion("1.0")]
[ProducesResponseType(typeof(PagedViewModel<DataTypeTreeItemResponseModel>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedViewModel<DataTypeTreeItemResponseModel>>> Search(CancellationToken cancellationToken, string? query, int skip = 0, int take = 100, TreeItemKind itemKind = TreeItemKind.All)
=> await SearchTreeEntities(query, skip, take, itemKind);
}
@@ -24,8 +24,6 @@ public class SiblingsDataTypeTreeController : DataTypeTreeControllerBase
[HttpGet("siblings")]
[ProducesResponseType(typeof(SubsetViewModel<DataTypeTreeItemResponseModel>), StatusCodes.Status200OK)]
[EndpointSummary("Gets a collection of data type tree sibling items.")]
[EndpointDescription("Gets a paged collection of data type tree items that are siblings of the provided Id. The collection can be optionally filtered to return only folder, or folders and data types.")]
public async Task<ActionResult<SubsetViewModel<DataTypeTreeItemResponseModel>>> Siblings(CancellationToken cancellationToken, Guid target, int before, int after, bool foldersOnly = false)
{
RenderFoldersOnly(foldersOnly);
@@ -33,8 +33,6 @@ public class UpdateDataTypeController : DataTypeControllerBase
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[EndpointSummary("Updates a data type.")]
[EndpointDescription("Updates a data type identified by the provided Id with the details from the request model.")]
public async Task<IActionResult> Update(CancellationToken cancellationToken, Guid id, UpdateDataTypeRequestModel updateDataTypeViewModel)
{
IDataType? current = await _dataTypeService.GetAsync(id);
@@ -25,8 +25,6 @@ public class AllDictionaryController : DictionaryControllerBase
[HttpGet]
[MapToApiVersion("1.0")]
[ProducesResponseType(typeof(PagedViewModel<DictionaryOverviewResponseModel>), StatusCodes.Status200OK)]
[EndpointSummary("Gets a paginated collection of dictionary items.")]
[EndpointDescription("Gets a paginated collection of dictionary items with optional filtering by name.")]
public async Task<ActionResult<PagedViewModel<DictionaryOverviewResponseModel>>> All(
CancellationToken cancellationToken,
string? filter = null,

Some files were not shown because too many files have changed in this diff Show More