Compare commits

..
189 Commits
Author SHA1 Message Date
b836b44343 Table dates and User dates (#22169)
User-collection-table didn´t format and if you have da backoffice the time is still Am/pm

Co-authored-by: Lucas Bach Bisgaard <lucas.bisgaard@kraftvaerk.com>
Co-authored-by: Niels Lyngsø <nsl@umbraco.dk>
Co-authored-by: Mads Rasmussen <madsr@hey.com>
2026-06-26 09:24:35 +02:00
d8f4342a86 Fix detail data request manager failing when items hit 40, making document unusable (#23164)
Fix detail data request manager failing as soon as the number of items requested hits the UmbItemDataApiGetRequestController batch limit (40)

Co-authored-by: Paul Woodland <paul.woodland@pwnewmedia.com>
2026-06-26 09:09:11 +02:00
Niels LyngsøandGitHub 022439065f Block Workspace: avoid JS error if destroyed (#23200)
avoid JS error if Block Workspace Context was destroyed while awaiting a frame
2026-06-25 15:59:33 +02:00
065e567f11 Media: Add umb-media-thumbnail with configurable checkerboard background (closes #23177) (#23178)
* feat(media): add umb-thumbnail and configurable checkerboard background

Adds `umb-thumbnail` as the recommended alias of `umb-imaging-thumbnail`
(the original tag stays registered for backwards compatibility), and makes
the checkerboard background opt-out via the `--umb-thumbnail-background` CSS
custom property plus an `img` part for full styling control. Also fixes an
action-event listener leak in the thumbnail element, and adds a Storybook
story, an MDX guide, and component tests.

Closes #23177

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(media): address PR review on umb-thumbnail

- Rephrase the imaging-thumbnail JSDoc to a neutral alias statement instead of
  a "prefer" wording that read like an undeclared deprecation.
- Guard the thumbnail tests so a renamed private field fails loudly rather than
  producing vacuous assertions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(media): make umb-thumbnail canonical, deprecate umb-imaging-thumbnail

Invert the inheritance so the implementation lives on `UmbThumbnailElement`
(`umb-thumbnail`) and `UmbImagingThumbnailElement` (`umb-imaging-thumbnail`)
is the thin subclass. Removing the old tag is now just deleting one file.

The deprecated subclass emits a one-time `UmbDeprecation` warning (a
module-level guard avoids per-instance console spam) and carries a
`@deprecated` JSDoc, scheduled for removal in Umbraco 19.

Migrate the four internal consumers to `umb-thumbnail` so the deprecation
warning targets external code only, not our own.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(media): trim deprecated-alias thumbnail tests to a registration guard

The img part, checkerboard default and --umb-thumbnail-background override are
covered by thumbnail.element.test.ts and inherited from UmbThumbnailElement, so
re-asserting them on the umb-imaging-thumbnail subclass only tested inheritance.
Keep a single backwards-compat guard that the deprecated alias stays registered
and on the inheritance chain.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(media): rename canonical thumbnail to umb-media-thumbnail; alias keeps @deprecated, no runtime warning

Per review (Niels): the forward-looking name is `umb-media-thumbnail`
(`UmbMediaThumbnailElement`), leaving room for non-media thumbnails later. The
implementation, CSS custom property (`--umb-media-thumbnail-background`), story,
guide and internal consumers all use the new name.

`umb-imaging-thumbnail` stays registered as a thin alias and keeps its
`@deprecated` JSDoc (IDE signal) but no longer emits a runtime UmbDeprecation
warning — both tags fly for now. Docs and comments lead with umb-media-thumbnail.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 09:37:53 +00:00
58a1c15626 Deprecations: Annotate warnings with caller origin and suppress core noise in production (#23188)
* feat(core): annotate deprecation warnings with caller origin, suppress core noise in production

Deprecation warnings now state where the call most likely came from — Umbraco
core, an /App_Plugins package, or other custom code — by classifying the call
stack (first frame not under /umbraco/backoffice/ is the caller). This answers
the Codegarden feedback that you can't tell whose code triggered a warning.

In production builds, core-origin warnings are suppressed (a consumer can't act
on Umbraco's own code); package/external/unknown origins are always shown. The
production signal is the client build, not the server runtime mode — the latter
is unreliable since Umbraco Cloud defaults to BackofficeDevelopment. The new
umbIsProductionBuild() reads Vite's import.meta.env.PROD (substituted to true in
the shipped core bundle) and falls back to false outside a Vite build.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(core): cleaner deprecation output and drop the throw for stack capture

Read new Error().stack directly instead of throwing and catching — the stack is
populated on construction. Annotate the warning with the resolved origin on its
own line rather than a bracketed prefix, and rely on the browser's native
expandable stack on console.warn for the full clickable trace instead of
printing one ourselves.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(core): trim inline comments in deprecation utils

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(core): address PR review on deprecation origin

- Clarify umbIsProductionBuild docs: in Vite dev import.meta.env is defined
  (PROD false); the guard is for non-Vite contexts (tsc pass, web-test-runner).
- Strip query/fragment from parsed frame URLs so the external-origin label
  can't carry ?/# noise.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 09:29:21 +00:00
Jacob Overgaard 05f8158e4a Merge remote-tracking branch 'origin/release/17.5.0' into v17/dev 2026-06-23 14:29:24 +02:00
Jacob Overgaard 28b849f2d3 build(deps): bumps @umbraco-ui/uui to 1.18.1 2026-06-23 11:54:32 +02:00
ca7dcd5150 Published Cache: Guard against cache poisoning from a render-vs-publish race (#23169)
* Guard against cache poisoning from concurrency

* Resolve code review comments relating to tests.

* Avoid unnecessary second invalidation of memory cache generatio.

* Tighten the cache-generation guard.

---------

Co-authored-by: Andy Butland <abutland73@gmail.com>
2026-06-23 10:14:46 +02:00
Kenn JacobsenandGitHub ceef53d624 Examine: Queue cold boot Examine reindexing at start-up, not at first request (Closes #22883) (#23181)
* Queue cold boot Examine reindexing at start-up, not at first request

* Review comments from Claude
2026-06-23 08:40:24 +02:00
Andy ButlandandGitHub 5bb53172aa ModelsBuilder: Avoid ObjectDisposedException in InMemoryModelFactory during shutdown (#23171)
* Guard EnsureModels against disposed lock on shutdown.

* Addressed code review comment.
2026-06-23 07:13:49 +02:00
Andy ButlandandGitHub aa9473131b Content Types: Show the correct type name (Media/Member Type) in the Compositions dialog (closes #23102) (#23118)
* Display appropriate content type name in compositions dialog localised texts.

* Fix composition dialog translation typos and link references to the matching workspace

- fr: "sililaire" -> "similaire"
- it: "utlizzato" -> "utilizzato"
- es: remove duplicated "no puede no puede"

The reference list now builds its workspace edit href from the modal's
entityType instead of hardcoding document-type, so links resolve correctly
when the dialog is used for Media Types and Member Types.
2026-06-22 16:28:59 +01:00
Mads RasmussenandGitHub 35a3a2455c Entity Data Picker: Implement interaction memory + sync picker memory across all picker inputs (#23172)
* Integrate interaction memories into entity data picker

* Skip resetting unchanged data source API

Add an early-return guard in setDataSourceApi to avoid re-setting the same UmbPickerDataSource instance. Prevents rebuilding the modal token/route (which would close and reopen an open picker modal) on every re-render by only updating when the API actually changes.

* Add UmbEntityInputInteractionMemoryManager + implement across current inputs with memory

* clean up comment
2026-06-22 14:12:55 +00:00
Andy Butland 5a20452e7a Bump version to 17.5.0-rc3. 2026-06-19 18:44:26 +02:00
Andy Butland adeddeb148 Complete bump version to 17.5.0-rc2. 2026-06-19 15:51:10 +02:00
Lee KelleherandGitHub ab8b8b48d4 Block RTE: Implement unsupported block rendering (closes #23071) (#23126)
* Block RTE: Implement unsupported block rendering

* Fixes `.ProseMirror-selectednode` focus ring

* Markup tidy-up

* Adds test for `umb-unsupported-rte-block`

* Block RTE: Reflect unsupported state as a host attribute

Replaces @state() + toggleAttribute() with @property({ reflect: true })
so Lit manages the 'unsupported' attribute sync during the update cycle,
avoiding constructor-time attribute access flagged by the linter.

Also removes the now-inert uui-text/uui-font classes from the block
wrapper div (backing styles were removed with UmbTextStyles).

* Adds JSDoc comment to `unsupported` property

* Block RTE: Extract #observeBlockViewProps() to reduce constructor size

Moves the block-view-props observer setup out of the constructor into a
dedicated #observeBlockViewProps() method, following the same pattern as
#observeData(). Reduces constructor from 123 to 64 lines (threshold: 70).
2026-06-19 13:08:00 +01:00
Jacob Overgaard feb1689848 Merge branch 'release/17.5.0' into v17/dev 2026-06-19 13:56:04 +02:00
Jacob OvergaardandClaude Opus 4.8 a14b908574 test(backoffice): fix host type in extension-initializer-base test
The `loaded`-signal test (added in #23167) built its host with
`UmbControllerHostElementMixin(HTMLElement)`, mirroring the older
`UmbBaseExtensionInitializer` tests. But `UmbExtensionInitializerBase`
requires a full `UmbElement` host, so the test failed `tsc` (TS2345)
under the root tsconfig. The product build excludes `*.test.ts`, so it
slipped through CI but breaks `npm run compile`/the editor.

Use `UmbElementMixin(HTMLElement)`, matching what production callers pass
(app/backoffice/preview elements are all UmbElements).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 13:55:56 +02:00
Jacob Overgaard 925d6bc430 Merge remote-tracking branch 'origin/release/17.5.0' into v17/dev 2026-06-19 13:37:44 +02:00
Jacob OvergaardandClaude Opus 4.8 acfaf23e43 Merge external-login entrypoint-race fix into v17/dev
Reconciles app.element.ts with #23020 (parallelized public extensions).
Kept the boot gate (await the app-entry-point initializer before routing)
and restored a blocking inline `await registerPublicExtensions()` instead
of the parallelized deferred form — a marginally slower but more robust
boot, identical to the release/17.5.0 fix (no empty-first-pass timing
reliance). extension-initializer-base.ts, the unit test, the acceptance
test and playwright config merge cleanly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 13:35:21 +02:00
Niels LyngsøandGitHub 7c1b907410 Block Workspace: Support variant properties and make '$settings' a key-value-object (Closes #23095) (#23123)
* map settings to become a key-value-object

* implement type safety for block label ufm values

* added TODOs

* support variant value in Block Workspace Label
2026-06-19 11:26:18 +00:00
Niels Lyngsø 564ca0384b Squashed commit of the following:
commit cd132f44b1
Author: Niels Lyngsø <niels.lyngso@gmail.com>
Date:   Fri Jun 19 13:18:07 2026 +0200

    correct to use display: block;

commit a9ffa9f90b
Author: Andreas Lykke Borg <72602768+andreaslborg@users.noreply.github.com>
Date:   Mon Jun 15 20:56:51 2026 +0200

    Added css styling to block list and single to respect custom width
2026-06-19 13:25:17 +02:00
9f633416b1 External login: wait for app-entry-points before the login provider decision (#23167)
* External login: wait for app-entry-points before the login provider decision

The backoffice boot stopped waiting for app-entry-point extensions to settle
before deciding which auth provider to use (regression introduced in #22522).
On a slow connection an externally registered authProvider (e.g. Umbraco ID)
is not registered yet when the login screen renders, so the user is dropped on
the local login instead of being redirected to the external provider.

- extension-initializer-base: `loaded` re-arms to `undefined` while a pass is in
  flight and resolves to `true` unconditionally (including zero extensions), so
  `.asPromise()` gates correctly and never hangs on a default install (which has
  no app-entry-points) — the reason the await was removed in the first place.
- app.element: restore the awaited boot gate before routing.

Tests:
- Unit test for the `loaded` signal contract (zero extensions resolves; a late,
  slow extension is awaited).
- Playwright acceptance test that deploys an app-entry-point registering an
  authProvider after a delay and asserts it is offered on the login screen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(backoffice): guard the loaded-gate timing for permission loading

Add a test asserting the collection initializer's `loaded` does not open the
gate (`#loadedGuard` awaits it via `.asPromise()`, fronting private-extension
and user-permission loading) until the initially-registered extensions have
instantiated. Addresses the #22522 "user permissions resolved too late" concern
in writing; user-permission condition resolution itself lives in
UmbBaseExtensionInitializer (covered by base-extension-initializer.race.test.ts)
and is untouched by this change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(backoffice): harden loaded signal + narrow acceptance test glob (review)

Address PR review feedback:
- extension-initializer-base: only the latest processing pass settles `loaded`
  (monotonic pass id), so a slow earlier pass can't unblock waiters early when
  the async observer overlaps passes; and use `Promise.allSettled` so a throwing
  `instantiateExtension` can't leave `loaded` stuck at `undefined` (hanging the
  boot gate) — failures are logged rather than swallowed.
- playwright.config: narrow the project glob to `**/*.spec.ts` so Playwright
  doesn't try to load the App_Plugins `entry-point.js` ESM fixture as a test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 12:11:28 +01:00
Lee KelleherandGitHub 91381604dd UFM: Add umbMemberName component (closes #22147) (#23165)
* Adds UFM Member Name component

This is to support the standalone Member Picker values.

* fix(ufm): clear stale value on empty member picker; validate UDI-extracted GUIDs

* test(ufm): add umbMemberName parsing tests to marked-ufm.test.ts
2026-06-19 09:48:51 +00:00
Jacob OvergaardandClaude Opus 4.8 c566dd0a71 fix(backoffice): harden loaded signal + narrow acceptance test glob (review)
Address PR review feedback:
- extension-initializer-base: only the latest processing pass settles `loaded`
  (monotonic pass id), so a slow earlier pass can't unblock waiters early when
  the async observer overlaps passes; and use `Promise.allSettled` so a throwing
  `instantiateExtension` can't leave `loaded` stuck at `undefined` (hanging the
  boot gate) — failures are logged rather than swallowed.
- playwright.config: narrow the project glob to `**/*.spec.ts` so Playwright
  doesn't try to load the App_Plugins `entry-point.js` ESM fixture as a test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:35:26 +02:00
Jacob OvergaardandClaude Opus 4.8 ea78147657 test(backoffice): guard the loaded-gate timing for permission loading
Add a test asserting the collection initializer's `loaded` does not open the
gate (`#loadedGuard` awaits it via `.asPromise()`, fronting private-extension
and user-permission loading) until the initially-registered extensions have
instantiated. Addresses the #22522 "user permissions resolved too late" concern
in writing; user-permission condition resolution itself lives in
UmbBaseExtensionInitializer (covered by base-extension-initializer.race.test.ts)
and is untouched by this change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:19:39 +02:00
Andy ButlandandGitHub 0a5189e54a Content & Media: Enforce content type filters and allowed children/root rules when validating a create (#23163)
* Validate for content type filters on create document and media validation.

* Addressed code review feedback on tests.
2026-06-19 11:16:47 +02:00
Jacob OvergaardandClaude Opus 4.8 102e4aa80b External login: wait for app-entry-points before the login provider decision
The backoffice boot stopped waiting for app-entry-point extensions to settle
before deciding which auth provider to use (regression introduced in #22522).
On a slow connection an externally registered authProvider (e.g. Umbraco ID)
is not registered yet when the login screen renders, so the user is dropped on
the local login instead of being redirected to the external provider.

- extension-initializer-base: `loaded` re-arms to `undefined` while a pass is in
  flight and resolves to `true` unconditionally (including zero extensions), so
  `.asPromise()` gates correctly and never hangs on a default install (which has
  no app-entry-points) — the reason the await was removed in the first place.
- app.element: restore the awaited boot gate before routing.

Tests:
- Unit test for the `loaded` signal contract (zero extensions resolves; a late,
  slow extension is awaited).
- Playwright acceptance test that deploys an app-entry-point registering an
  authProvider after a delay and asserts it is offered on the login screen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:10:01 +02:00
Andy ButlandandGitHub 0bec947b8b Examine: Stream value sets to a single index during rebuild to reduce reindex peak memory (#23150)
* Stream value sets to a single index during rebuild to reduce reindex peak memory.

* Applied code review feedback.
2026-06-19 06:32:18 +02:00
Andy Butland d0e7ef0169 Merge branch 'release/17.5.0' into v17/dev 2026-06-17 19:31:46 +02:00
Jesper MadsenandJacob Overgaard e2cf205d34 Let the external login button show "sign in with {providername}" in languages (#23135) 2026-06-17 15:57:57 +02:00
Jacob Overgaard bf0270b244 build: deploy to npm through a template 2026-06-17 15:49:00 +02:00
Jacob OvergaardandClaude Opus 4.7 dc77f37129 Build: tag prerelease npm publishes with 'next' dist-tag (#22909)
* Build: tag prerelease npm publishes with 'next' dist-tag

Prereleases that flow through Deploy_Npm (e.g. 18.0.0-beta1) currently
land on the `latest` dist-tag, so a bare `npm install @umbraco-cms/backoffice`
resolves to an unstable build. Switch to `--tag next` when
NBGV_PrereleaseVersion is non-empty, leaving `latest` for stable releases.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Build: address review feedback on npm prerelease dist-tag

- Add Build to Deploy_Npm dependsOn so stageDependencies.Build.A.outputs
  resolves explicitly (mirrors the Upload_API_Docs pattern).
- Pass npmPrereleaseVersion via env: instead of inline macro expansion in
  bash, so an unset variable won't be interpreted as command substitution.

* Build: source npmPrereleaseVersion via dependencies, not dependsOn

Switches the variable mapping from stageDependencies (which needs Build
in dependsOn) to dependencies.Build.outputs[...], matching the pattern
the stage's condition already uses on line 941. Avoids drawing a
redundant parallel arrow from Build to Deploy_Npm in the ADO stage
graph — Build is already in the ancestor chain via Deploy_NuGet.

* Build: align Deploy_Npm with Umbraco Deploy publish pattern

- Use stageDependencies form in variables: (dependencies.* only works in conditions).
- Source NBGV_PrereleaseVersionNoLeadingHyphen for a cleaner check.
- Replace echo >> .npmrc with npm config set --location=project.
- Collapse if/else into a tag=latest|next shell variable; single npm publish *.tgz.
- Drop unnecessary env: passthrough and npm init -y.

Per Ronald's feedback on PR #22909 — mirrors the Deploy pipeline's release stage.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-17 15:48:53 +02:00
dbecec3451 Cache: Populate the domain cache eagerly during start-up (#23139)
* Populate the domain cache eagerly during start-up

* Added extension method to encapsulate and test logic for skipping startup seeding.

---------

Co-authored-by: Andy Butland <abutland73@gmail.com>
2026-06-16 13:19:09 +00:00
Andy ButlandandGitHub d92e6bbeff Tiptap: Preserve wrapping link when editing an image (closes #23013) (#23019)
* Retain surrounding link when editing image details in rich text editor.

* Add JSDocs.
2026-06-16 11:06:45 +01:00
e56ddcc3f9 Tiptap RTE: Fixes toolbar button active state not updating on collapsed-cursor mark toggle (closes #22907) (#22929)
* update tiptap event listneres

* Tiptap: Add regression test for toolbar button active state on collapsed-cursor toggle (closes #22907)

Tests verify the `transaction` listener wiring that fixes the stored-mark active-state bug.

---------

Co-authored-by: leekelleher <leekelleher@gmail.com>
2026-06-16 09:56:42 +00:00
fb8c3b19ce Backoffice: Fix core circular import (fetchAllPages) breaking the test check (#23133)
Backoffice: Move fetchAllPages into the repository module to break a core circular import

#22765 added the offset pagination helper `fetchAllPages` under
`@umbraco-cms/backoffice/utils`, but its contract is expressed entirely in
repository-owned types (`UmbDataSourceResponse<UmbPagedModel<T>>`). That made
`utils` import `repository` while `repository` already imports `utils`,
introducing a 17th core bidirectional module import and tripping
`check:module-dependencies` (threshold 16) — failing the `test` job on every
open PR.

Relocate the helper (and its test) into the `repository` module, which
legitimately owns those types, and export it from
`@umbraco-cms/backoffice/repository`. The sole consumer
(UmbLanguageCollectionRepository) already imports from that module. Core
bidirectional imports are back to 16.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-16 09:51:36 +01:00
Andy ButlandandGitHub 7a7aadffe5 User Groups: Clear the user group cache when a language is deleted (closes #23121) (#23131)
* Ensure user group cache is cleared on language delete.

* Apply code review feedback.
2026-06-16 08:47:46 +02:00
Andy ButlandandGitHub 0ac6e8500a Background Jobs: Recover cache-sync and server-touch jobs when a database call hangs (closes #23106) (#23119)
* Recover cache-sync job if database Sync() hangs.

* Apply also to TouchServerJob.

* Addressed code review comments.

* Added debug logging to allow monitorring of job runs.

* Added tests verifying that jobs resume after an inflight call completes.
2026-06-16 08:01:14 +02:00
4c35c0c2e9 Management API: Add endpoints to sort the children of a document or media item by a system field (#23077)
* Add endpoints for sorting documents and media by system fields.

* Addressed code review feedback.

* Persist sort-children-by-field with a single set-based update.

* Addressed second round of code review feedback.

* DRYed up similar code, improved comments.

* Split tests into individual class files

* Added test for combined sort of invariant and variant children.

* Addressed further code review feedback.

* Include test scenario from #23128 for SortChildren()

* Renamed children authorizer as it is generic, not specific for sorting - and updated XML docs accordingly

---------

Co-authored-by: kjac <kja@umbraco.dk>
2026-06-16 05:29:47 +00:00
Andy ButlandandGitHub de47e0b1f7 Content/Media: Reload content on Sort to avoid data loss with partially loaded entities (closes #23120) (#23128)
Ensure calling sort for content or media with partially loaded entities doesn't lose the unloaded data on persistence.
2026-06-15 19:31:34 +02:00
Andy Butland 969ae87798 Added TODO to adjust the ScheduledPublishingSettings.AlignToClock default to true. 2026-06-15 16:39:23 +02:00
Andy Butland fe3318ef79 Published Cache: Fix multi-site domains falling back to the first root node after restart (#23084)
* Prevent empty domain cache during concurrent initialization.

* Addressed code review comments and added further comment to the code.

* Use Lock object.
2026-06-15 16:19:24 +02:00
Andy ButlandandGitHub 828e359666 Languages: Page through to load all configured languages (#22765)
* Ensure all languages are retrieved handling rare (theoretical?) case where the number of languages exceeds the default page size.

* Addressed code review feedback.

* Addressed further code review feedback.
2026-06-15 16:17:30 +02:00
a5b7e0dac1 Scheduled publishing: Add configurable period and optional clock-aligned scheduling (#23127)
* Add configurable period for scheduled publishing task with optional clock alignment.

* Apply suggestions from code review

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Addressed code review comments.

* Clarified the maths, improved comments and test coverage.

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-15 13:48:34 +00:00
Andy ButlandandGitHub 86abc3528d Request Logging: Avoid forcing a session store load when resolving the session id for logging (closes #23082) (#23083)
* Guard read of session ID for log enrichment by presence of session cookie.

* Renamed tests.

* Add configurable option for session ID logging, retaining backward compatibility but giving options to skip session Id logging or use a cookie hash.
2026-06-15 06:47:59 +02:00
Andy Butland 7433641348 Dependencies: Update MessagePack to 3.1.7 to address security advisories (#23113)
Update MessagePack dependency to 3.1.7.
2026-06-15 06:35:25 +02:00
Andy ButlandandGitHub c45b12ec58 Dependencies: Update MessagePack to 3.1.7 to address security advisories (#23113)
Update MessagePack dependency to 3.1.7.
2026-06-15 06:34:57 +02:00
Andy ButlandandGitHub 22c4bc7835 Package migrations: Surface a failed unattended package migration as a boot failure instead of getting stuck on the upgrade screen (#23114)
* Surface a package migration exception as a boot failure, avoiding being stuck in an upgrading state.

* Addressed code review feedback.

* Fix failing integration tests.
2026-06-15 11:58:58 +09:00
Andy ButlandandGitHub 1e82376420 Skills: Add umb-release-notes skill for improving generated release notes (#23112)
* Add skill to help with improving the generated GitHub release notes.

* Addressed skill review feedback.
2026-06-15 11:41:57 +09:00
Zeegaan 764d4eb1d7 bump version 2026-06-11 14:02:32 +09:00
Mads RasmussenandGitHub aa854da3f4 Backoffice: Pre-expand tree to target entity when opening Duplicate and Move To modals (closes #22015) (#23063)
* Support tree expansion in generic Duplicate To modal

* Add expansion prop to tree picker modal types

* Apply expansion from data to picker context

* move to action: populate tree picker expansion with ancestors

* Pass tree expansion to duplicate document modal

* Extract ancestor fetching into private method

* Use UmbDocumentTreeRepository directly

* Exclude self from ancestor results

* make name more explicit

* Guard ancestor fetch and simplify expansion

* Only set treeExpansion when ancestors exist

* fix type issues

* Parallelize ancestor and pickable filter fetch

* Use getter for treeExpansion; remove unused imports
2026-06-09 16:53:37 +02:00
Niels LyngsøandGitHub 28cdbe5317 TipTap: Let the stylesheet load parallel to tiptap-extensions (#23024)
do not await stylesheets to be loaded before extensions
2026-06-09 11:14:49 +00:00
3dbd4baefe Backoffice Search: Batch the ancestors lookup for search results to avoid exceeding the maximum URL length (closes #23032) (#23048)
* Ensure requests to fetch ancestors after retrieving search results are batched to avoid a single query exceeding the maximum URL length.

* Guard against undefined ancestor entries from a failed batch

batchTryExecute resolves each chunk via tryExecute, which never rejects, so
a per-chunk failure comes back as a fulfilled result carrying an error and
leaves an undefined hole in the amalgamated data without surfacing an error.
Detect that before mapping and return an explicit error instead of throwing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Assert ancestor id uniqueness and silence direct-api lint rule

Strengthen the batching tests to assert every search-result id is requested
exactly once (Set size), not just that the total count matches. Add the
no-direct-api-import disable on the controller's api callback, matching the
existing url data sources, since the call is wrapped by the controller.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Addessed Codescene warnings.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 10:31:37 +02:00
Andy ButlandandGitHub fa5dd209c1 Tags: Fix null reference error in tags input on render (closes #23044) (#23049)
Fix intermittent null reference exception in tags element.
2026-06-09 10:29:27 +02:00
Andy ButlandandGitHub 4cc4acee62 Published Cache: Fix multi-site domains falling back to the first root node after restart (#23084)
* Prevent empty domain cache during concurrent initialization.

* Addressed code review comments and added further comment to the code.

* Use Lock object.
2026-06-07 15:35:38 +02:00
Andy Butland d0fc7dc8a0 Tiptap RTE: Ignore no-op transactions in onUpdate to prevent phantom dirty state (closes #22767) (#22781)
Ignore Tiptap no-op transactions in onUpdate to prevent phantom dirty state.
2026-06-05 15:56:23 +02:00
62663d9573 Runtime Cache: Fix IAppPolicyCache.ClearByKey intermittently failing to clear cached items (closes #23064) (#23068)
* Prevent ClearByKey leaving stale runtime cache items.

* Lowered loop timer.

* Clarified code comment.

* Improve assertions and comments in tests.

---------

Co-authored-by: Kenn Jacobsen <kja@umbraco.dk>
2026-06-05 09:23:26 +02:00
b582f9d2ef Dependencies: Upgrade Examine to 3.8.0 (#23075)
Upgrade Examine to 3.8.0

Co-authored-by: Simon Gibbs <sgibbs@qmu.ac.uk>
2026-06-05 06:59:11 +02:00
ad90db8b38 Performance: Coalesce concurrent tree data requests (Management API client) (#23021)
* perf(tree): coalesce concurrent identical tree data requests

The tree data request manager hit the network on every call, so multiple
concurrent consumers (sidebar tree, breadcrumb structure, pickers) each
fetched the same data independently — e.g. three identical tree/document/root
requests per document-workspace load.

Apply the existing UmbManagementApiInFlightRequestCache (already used by the
item and detail request managers) to the tree request manager via a shared
static cache, coalescing concurrent identical root/children/ancestors/siblings
calls into a single in-flight request, cleared on settle (in-flight only, so
no stale-cache risk). The document tree opts in; other trees are unchanged
until they pass a cache.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(tree): cover request coalescing; address review feedback

- Add focused tests: concurrent identical root requests share one call,
  the in-flight entry is cleared on settle, and no cache means no coalescing.
- Build the cache key lazily (only when a cache is wired) so non-opted-in
  trees keep the original lightweight path.
- Constrain the #coalesce generic to drop the cast on cache.set.
- Document the new inflightRequestCache arg; trim the comment to one line.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 10:40:58 +02:00
Erik-Jan WestendorpandAndy Butland 8e3b821a55 Localization: Add Dutch translations for create and delete actions (#23039)
Update nl.ts
2026-06-04 08:37:53 +02:00
Andy ButlandandGitHub 8ac989c4e3 Data Types: Tolerate invalid configuration when determining the editor value storage type (closes #23057) (#23058)
* Tolerate invalid data type configuration when getting the editor value storage type.

* Add logging in case of error.

* Resolve warning.

* Removed exception from warning (it's not useful).

* Log an error instead of a warning.
2026-06-04 14:38:21 +09:00
Andy Butland 89baa9482b Background Jobs: Resolve server role so recurring jobs run when no application URL is configured (#23033)
Resolve server role when no application URL is configured.
2026-06-04 06:40:00 +02:00
Andy ButlandandGitHub 3913a61b74 Background Jobs: Resolve server role so recurring jobs run when no application URL is configured (#23033)
Resolve server role when no application URL is configured.
2026-06-04 06:37:08 +02:00
Lee KelleherandGitHub 90bedcd42e Menu Structure: Guard against use-after-destroy in async structure request (#23055)
* Menu Structure: Guard against use-after-destroy in async structure request

When navigating to a trashed item, the IS_NOT_TRASHED condition initially
permits the standard menu structure context, which is then destroyed once the
workspace confirms the item is trashed. The in-flight async #requestStructure()
could resume after destruction and call setValue() on a completed subject,
throwing "_subject is undefined".

Guard the state mutations with the framework's existing _host-cleared-on-destroy
signal, and handle the previously fire-and-forget #requestStructure() promises so
a teardown mid-request is silently abandoned rather than surfacing as an uncaught
rejection. Applied to both the variant and non-variant menu structure base
contexts.

* Menu Structure: Make #requestStructure non-throwing instead of catching at call sites

Per PR review feedback: replace the blanket .catch(() => {}) wrappers with
early returns inside #requestStructure(). The _host guard already prevents
post-destroy state mutation; the throws only fire for can't-happen missing
observable states and were producing unhandled rejections with no caller
able to act on them.

* Added console warning, if the host is still available
2026-06-03 18:32:29 +02:00
Lee KelleherandGitHub c54189aa90 Block Grid: Guard validator against torn-down manager on navigation (#22852)
* Block Grid: Guard validator against torn-down manager on navigation

The form-control mixin's updated() hook runs validators when the element
re-renders during teardown. If navigation has already disposed _manager,
checkBlockTypeConfigurationValidity would throw "Cannot read properties
of undefined (reading 'getContentTypeKeyOfContentKey')".

Early-return as valid when the manager is gone and use optional chaining
on the per-entry lookup as a safety net.

* Removed optional chaining of `_manager`

As `_manager` has already been checked.

* Reverting the `_manager` optional chaining

As TypeScript compiler doesn't like it, (inside the `filter` callback).
2026-06-03 15:29:41 +02:00
88ec0a248f Media: Restore friendly naming of uploaded media items (closes #22989) (#22998)
* Update uploaded media file name to a friendly name.

* Correct test description for acronym handling.

The case JUST-A-FILE.jpg verifies all-uppercase words are preserved
as acronyms, not that lowercase words get lowercased.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Match server-side StripFileExtension semantics in toFriendlyName.

The TypeScript helper previously delegated to getFileExtension, which
diverges from the C# StripFileExtension on two edge cases:
- a trailing dot ("file.") is stripped by the server but not the client
- an "extension" containing whitespace is preserved by the server but
  stripped by the client

Inlined a stripFileExtension helper that mirrors the C# rules exactly,
making the "keep in sync" cross-reference accurate. Added tests for both
divergent cases and replaced the contrived leading/trailing whitespace
test with a realistic interior-whitespace case.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Add parity test for trailing-whitespace extension span.

Restores the '  spaced-name.jpg  ' case as a parity test against
StripFileExtension's "extension containing whitespace is preserved"
rule. Output is 'Spaced Name.Jpg' (Jpg title-cased, matching the
server's TextInfo.ToTitleCase behaviour on the now-unstripped extension).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Handle getContext rejection in ensureMediaNameFromFile.

getContext rejects on timeout when the dataset context never resolves;
callers used void ensureMediaNameFromFile(...) so an unhandled rejection
would bubble. Catch the rejection and treat it as an absent context.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 12:37:49 +01:00
Lee KelleherandGitHub 3e22733081 Document Recycle Bin: Checks user permission for Document "Read" (#23041)
* Adds conditions to Document Recycle Bin

that the user must have "Read" permission.

* Directly imports Media Recycle Bin condition

this will remove an extra fetch request.
2026-06-02 16:31:51 +02:00
Andy ButlandandGitHub 232077e820 EF Core: Retry transient SQLite lock errors during long-running operations (closes #22939) (#22969)
* Retry transient SQLite lock errors during long-running operations.

* Addressed code review comments.
2026-06-02 13:59:37 +02:00
Jacob Overgaard 943d1eeccd Merge branch 'release/17.5.0' into v17/dev 2026-06-02 12:44:29 +02:00
Jacob Overgaard b3666dad8b build(deps): bumps @umbraco-ui/uui to 1.18.0 2026-06-02 12:43:21 +02:00
Sven GeusensandAndy Butland 28a403361e Developer Experience: Improve cohost editor polyfill to work with dotnet watch (closes #22773) (#22999)
* Improve cohost polyfill

* Apply suggestions from code review

Co-authored-by: Andy Butland <abutland73@gmail.com>

* Move <target/> part of the polyfill to targets file.

---------

Co-authored-by: Andy Butland <abutland73@gmail.com>
2026-06-01 14:38:35 +02:00
38d73b3a41 Developer Experience: Improve cohost editor polyfill to work with dotnet watch (closes #22773) (#22999)
* Improve cohost polyfill

* Apply suggestions from code review

Co-authored-by: Andy Butland <abutland73@gmail.com>

* Move <target/> part of the polyfill to targets file.

---------

Co-authored-by: Andy Butland <abutland73@gmail.com>
2026-06-01 12:34:24 +00:00
Niels Lyngsø 5c0cb154f5 cherry picked #23027 2026-06-01 10:04:01 +02:00
4a621a13bc Performance: Parallelize independent boot API requests (server status/config + public extensions) (#23020)
* perf(core): parallelize independent boot API requests

UmbServerConnection.connect() awaited server status and configuration
sequentially even though they are independent reads; run them with
Promise.allSettled so both errors surface (the app cannot function
without either) while saving a round-trip.

During app startup, public (login) extension registration was awaited
before the auth flow; kick it off in parallel and await it only before
routing, where the login screen actually needs it.

Each serialized call costs a full management-API round-trip, which is
negligible locally but ~150 ms each on high-latency (e.g. Cloud) hosts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(core): only mark connection connected once both calls succeed

Move isConnected.setValue(true) out of #setStatus() into connect() after
the allSettled check, so the observable never reflects a partially
established connection when configuration fails but status succeeded.

Addresses review feedback on the parallelized connect().

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 08:20:34 +02:00
bc2048573c Link Picker: Render picked content/media as non-interactive when their workspace URL can't be resolved (closes #22955) (#22964)
* Entity refs render readonly when their workspace URL can't be resolved.
Also fixes name on remove dialog.

* Apply read-only on the picked content ref only in the non-routable link picker

* Address PR feedback: simplify document item resolver guard in the link picker, document the implicit uui-card-media disabled dependency in input-media, and cover the disabled card state with a test.

* Drop out of date comments.

* Simplify updates.

---------

Co-authored-by: leekelleher <leekelleher@gmail.com>
2026-05-28 13:23:48 +00:00
Andy ButlandandGitHub 424209ac06 References: Fix missing node name when content referenced by media or member (closes #22990) (#22965)
Fix missing node name when content referenced by media or member.
2026-05-28 13:34:25 +01:00
Andy Butland 5cf577bf57 Fix broken tiptap reference for storybook build. 2026-05-28 13:32:09 +02:00
ed4b207fe7 Backoffice: Render $index in block detail overlay label (closes #21154) (#22959)
* Render $index in block detail overlay label.

* Cache $index, resolve append sentinel, and cover with unit tests.

* refactor(block): use pipeline for index deduplication and clean up stale observer

* Rename function to remove the unnecessary umb prefix.

---------

Co-authored-by: leekelleher <leekelleher@gmail.com>
2026-05-28 11:15:21 +00:00
Andy ButlandandGitHub 40e027d0ea Content Type Editor: Fix empty Design tab when opened in a modal workspace (closes #22855) (#22954)
* Fix empty content type Design tab when opened in a modal workspace.

* Addressed code review comments.

* Fixed failing E2E tests.
2026-05-28 09:39:11 +00:00
Andy ButlandandGitHub a6f6bdf8bc Backoffice: Hide "Edit permissions" button in create modals from users without Settings section access (closes #22981) (#22984)
* Show the edit permissions for document type button only for users with settings access.

* Fix translation for message (the "Permissions" tab is not called "Structure").

* Addressed code review feedback.
2026-05-28 09:23:26 +01:00
Jacob OvergaardandClaude Opus 4.7 8e6a791de0 Backoffice: Drop redundant search manifest import from Storybook preview
`core/manifests.ts` already imports and spreads `core/search/manifests.ts`
into its aggregate (line 23 + 58), so importing `searchManifests`
separately in `.storybook/preview.js` and spreading it next to
`coreManifests` registered the same manifests twice. Remove the redundant
import and spread.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 08:47:38 +02:00
Jacob OvergaardandClaude Opus 4.7 5ffea3152b Backoffice: Repoint Storybook preview imports at umbraco-package.ts
PR #22957 deleted every package's `manifests.ts` and consolidated the
exports into `umbraco-package.ts`, but `.storybook/preview.js` still
imported from the old paths. The result was a Vite resolve error during
`npm run build-storybook` (first failure: "Could not resolve
../src/packages/block/manifests from .storybook/preview.js").

37 import paths swapped from `…/<pkg>/manifests` to
`…/<pkg>/umbraco-package`. The two packages that still expose their
manifests via a standalone `manifests.ts` — `core` and `core/search` —
are left untouched.

Verified by `npm run build-storybook` — succeeds.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 08:43:38 +02:00
Jacob OvergaardandClaude Opus 4.7 2043ff1dbd Tiptap: Fix dead manifests.js import in the input-tiptap story
PR #22995 added `input-tiptap.stories.ts` with an import from
`'../../manifests.js'`, but PR #22957 (already on release/17.5.0) had
deleted that file and moved the `manifests` array into
`umbraco-package.ts`. The merge into release/17.5.0 didn't catch the dead
import, so Storybook 404s on the story load.

Point the import at the new home — `manifests` is still exported by name,
so this is a one-line path fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 08:40:17 +02:00
Jacob Overgaard f1bc1db6ce Merge branch 'v17/dev' of https://github.com/umbraco/Umbraco-CMS into v17/dev 2026-05-28 08:27:15 +02:00
Jacob Overgaard c4d5b89fc5 Merge remote-tracking branch 'origin/release/17.5.0' into v17/dev 2026-05-28 08:27:04 +02:00
7597a8ad40 Sort Dialog: Show current language node names (closes #22872) (#22948)
* Display variant node name on sort children dialog.

* Preserve user sort order when patching variant names on culture change

Patch names in-place on the existing _tableItems rather than rebuilding
from _children, so a user's drag-sorted or column-ordered arrangement is
not silently reverted if the app culture changes while the modal is open.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Refactor to reduce cyclomatic complexity of #resolveName method.

* Resolve sort dialog variant names and icons via item data resolvers

Replace the inlined variant-name logic in the content sort dialog with the
shared UmbItemDataResolver abstraction, and add UmbMediaItemDataResolver so
media items resolve their active-culture name and icon the same way documents
do. Each content sort entity action now supplies its resolver through manifest
meta, flowing into the modal via a new content-specific modal data type and a
base-action _getModalData() hook. This also removes the previously hard-coded
document icon in the dialog.

* Disable load more when page of items is being retrieved.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: leekelleher <leekelleher@gmail.com>
2026-05-28 05:50:16 +00:00
d28507e2e5 Migrations: Convert all sibling RTE blocks (closes #22979) (#22980)
* Fix migration of embedded block data when blocks are direct siblings in the 13 RTE source code.

* Apply suggestions from code review to update comments.

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Address review: keep RteBlockHelper in original namespace; tidy docs and comment

- Move RteBlockHelper back to Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_15_0_0.LocalLinks
  to avoid a binary breaking change within the obsolete window (scheduled removal in v18).
  Kept as its own file rather than reverting it into LocalLinkRteProcessor.cs.
- Add a <remarks> note on ConvertBlockUdisToKeys explaining that blocks with malformed UDIs
  are dropped rather than preserved.
- Replace the opaque "fix recursive hiccup" comment in LocalLinkRteProcessor with one that
  describes what the line actually does.
- Move RteBlockHelperTests back to mirror the production namespace.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 07:45:01 +02:00
7b75324172 Migrations: Convert all sibling RTE blocks (closes #22979) (#22980)
* Fix migration of embedded block data when blocks are direct siblings in the 13 RTE source code.

* Apply suggestions from code review to update comments.

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Address review: keep RteBlockHelper in original namespace; tidy docs and comment

- Move RteBlockHelper back to Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_15_0_0.LocalLinks
  to avoid a binary breaking change within the obsolete window (scheduled removal in v18).
  Kept as its own file rather than reverting it into LocalLinkRteProcessor.cs.
- Add a <remarks> note on ConvertBlockUdisToKeys explaining that blocks with malformed UDIs
  are dropped rather than preserved.
- Replace the opaque "fix recursive hiccup" comment in LocalLinkRteProcessor with one that
  describes what the line actually does.
- Move RteBlockHelperTests back to mirror the production namespace.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 07:38:56 +02:00
172a3be5ac Repositories: Batch WHERE IN queries to avoid SQL Server 2100-parameter limit (#22987)
* Batch WHERE IN queries to avoid SQL Server 2100-parameter limit and add memory files.

* Drop past-incident references from SQL parameter-limit docs

The memory files should describe the current rule and safe patterns;
specific historical bugs belong in commit history, not CLAUDE.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Update comments from code review

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Addressed memory file feedback.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-28 10:44:15 +09:00
ca28195b7c Tiptap: Load enabled extensions in parallel and inline manifest APIs (#22995)
* Tiptap: Load enabled extensions in parallel and inline manifest APIs

Replace the for…of/await loop in umb-input-tiptap's #loadExtensions with
Promise.all over .map, so all enabled Tiptap extension APIs are fetched
in parallel. Configured-extension order in _extensions is preserved.

Inline the first-party Tiptap manifest API references: every
`api: () => import('./X.tiptap-api.js')` and the equivalent toolbar /
statusbar / kind references now use a static top-of-file import and
`api: ClassName`. The dynamic `await import('rich-text-essentials.tiptap-api.js')`
fallback in input-tiptap.element.ts is inlined for the same reason.

External (plugin-supplied) Tiptap extensions and the lazy modal/toolbar
UI element imports are unchanged.

Why: on Umbraco Cloud, opening a document workspace with a rich text
editor takes ~16 s uncached, of which ~14.6 s is a single serial
waterfall — 31 extension APIs fetched one after the other from a
for…of await loop, ~170 ms RTT stacked. Replacing the loop with
Promise.all collapses that to roughly one round-trip; eagerly bundling
the first-party manifests removes the dynamic chunk explosion that made
the waterfall so long in the first place. The toolbar APIs (~20 of them)
already load in a sub-100 ms parallel burst against the same server,
confirming HTTP/2 multiplexing handles bulk parallel requests fine.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Tiptap: Inline element references for toolbar/statusbar/modal/clipboard manifests

Extends the manifest-inlining pass to the remaining `element: () => import(...)`
and runtime API loader sites in the Tiptap package — toolbar/menu/action-button
kinds, the table & character-map & anchor modals, the colour-picker button, the
property-editor configuration UIs, both clipboard translators, the style-menu
kind, and the default toolbar API fallback in tiptap-toolbar.element.ts.

Result on the same Cloud test site (uncached, 17.5-rc):
  Tiptap chunk count: 71 → 4
  Total tiptap bytes: ~3.2 MB → ~3.1 MB (essentially unchanged)
  Phase 5 of the load — the serial extension chain — collapses to a single
  consolidated chunk fetch.

`input-tiptap.element.ts` and `property-editor-ui-tiptap.element.ts` are
intentionally not inlined into anything else: `<umb-input-tiptap>` is a public
element usable standalone (custom dashboards, workspace views), and the
property-editor shell loads via the property-editor UI loader. They remain
exported as their own modules.

CLAUDE.md updated to document the new convention for first-party Tiptap
extensions (direct class refs) and the carve-out for external plugin
extensions that may keep `() => import(...)` to ship their API code in a
separate chunk.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Tiptap: Move extension APIs and elements into a shared lazy boundary chunk

The previous PR collapsed ~70 Tiptap chunks into 3 by inlining first-party API
and element references directly into manifest files. That win came with a real
downside flagged in code review (lke / mra): the API/element implementation
bytes ended up in the manifest registration bundle, so every workspace —
including ones without an RTE — paid ~700 KB of Tiptap code on boot.

This commit keeps the chunk-coalescing win but restores the lazy boundary by
routing every first-party manifest's `api` / `element` reference through a
single shared bundle file `extensions/extension-apis.bundle.ts`. Each manifest
holds a dynamic-import thunk pointing at that one bundle, so:

- Rollup still emits a single chunk for all Tiptap extension code (no chunk
  explosion).
- The manifest registration bundle stays slim — it carries only metadata
  (alias / label / icon / group / kind / forExtensions) plus the thunks.
- The bundle is only fetched the first time `<umb-input-tiptap>` actually
  mounts.

Data-type configuration UIs (`extensions-configuration`,
`toolbar-configuration`, `statusbar-configuration`) read manifest metadata
via `umbExtensionsRegistry.byType(...)` only — they never call
`loadManifestApi` / `loadManifestElement`, so the data-type editor continues
to work without loading any Tiptap implementation code.

Property-editor UI elements (`tiptap-rte`, the three configuration UIs) also
revert to `() => import('./X.element.js')` so each loads on demand from its
own chunk rather than being inlined into the manifest bundle.

`umb-input-tiptap` no longer statically imports the Rich Text Essentials API;
it prepends the alias to the observed list instead, so essentials resolves
through the same lazy bundle as every other extension.

Added a test and stories file that mount `<umb-input-tiptap>` standalone (no
property-editor wrapper) to make the public usage pattern explicit.

Built and verified via `npm run build:for:cms`:
- `dist-cms/packages/tiptap/manifests.js`           48 KB  (eager at boot)
- `dist-cms/packages/tiptap/extension-apis.bundle-*.js` 84 KB  (lazy)
- `dist-cms/packages/tiptap/tiptap-toolbar-element-api-base-*.js` 654 KB
  (lazy dependency of the bundle)
- per-element property-editor UI chunks load on demand when settings open

`npm run check:circular`, `npm run compile`, `npx wtr src/packages/tiptap`
all pass.

Related to #21152, builds on #22995.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Tiptap: Don't mount <umb-input-tiptap> in the standalone test

Mounting the element via fixture() spins up an UmbTiptapRteContext that
consumes UMB_SERVER_CONTEXT. In the unit-test runtime no server context
provider exists, so the context request stays pending. When @open-wc's
fixture tears down at end-of-file the request rejects with
"host disconnected" — surfaced as an unhandled promise rejection that
web-test-runner counts as a fatal runner error, exiting 1 even though every
individual test passed. The rejection happened to be in flight while a
block-grid clipboard test was active in CI, which is why the failure surfaced
there rather than in the tiptap test file itself.

Drop the manifest-registration assertion too — pulling the package-level
`manifests.ts` aggregator triggers a transitive 404 on the
`@umbraco-cms/backoffice/tiptap` importmap entry in the wtr environment.

The class-export + custom-element-registration checks are enough to prove
standalone exportability. The Storybook stories still cover the visual
end-to-end load path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 14:32:20 +00:00
Jacob Overgaard 4c1fde9e0c Merge branch 'release/17.5.0' into v17/dev 2026-05-27 14:28:03 +02:00
Mads RasmussenandJacob Overgaard f0013330e6 Backoffice: Embed package root manifests into umbraco-package.ts to reduce startup requests (#22957)
* Consolidate block package into index export

* keep umbraco-package.ts and embed manifests instead

* Inline package manifests into umbraco-package

* update docs
2026-05-27 14:26:56 +02:00
Mads RasmussenandJacob Overgaard bd2c985187 Backoffice: Embed implementations directly in core manifests to reduce startup network requests (#22944)
* Use direct imports in core manifests

* Extract theme aliases into constants file

---------

Co-authored-by: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com>
2026-05-27 14:26:34 +02:00
Mads RasmussenandJacob Overgaard 9711a5d012 Backoffice: Swap relative imports to @umbraco-cms/backoffice module imports in core packages (#22942)
Use @umbraco-cms/backoffice imports

Replace numerous relative/internal import paths with centralized '@umbraco-cms/backoffice' package entry points across core modules.This consolidates exports, simplifies import paths.
2026-05-27 14:24:57 +02:00
Andy ButlandandGitHub 808cba2747 Members: Default Approved to true when creating a member (closes #22991) (#22993)
Default new members created via the backoffice to approved.
2026-05-27 06:59:24 +00:00
Mads Rasmussen da0117f240 Merge branch 'v17/dev' of https://github.com/umbraco/Umbraco-CMS into v17/dev 2026-05-26 09:50:48 +02:00
Jacob OvergaardandGitHub 61d3e4c53d Backoffice: Add Cache-Control headers to cache-busted backoffice assets (AB#68478) (#22951)
* Backoffice: Add Cache-Control headers to cache-busted backoffice assets (AB#68478)

Adds a UseUmbracoBackOfficeCacheHeaders middleware that sets
Cache-Control: public, max-age=31536000, immutable on responses served
from the cache-busted backoffice path (/umbraco/backoffice/<hash>/*).
The hash in the URL is derived from the Umbraco version, so the URL
itself invalidates on every release - making 'immutable' safe regardless
of whether individual filenames contain a content hash.

In debug mode the cache-bust hash changes per request, so the header is
set to 'no-cache' to avoid filling the browser disk cache with single-use
entries.

Design is non-destructive to consumer customisation, addressing the
review feedback on the v14 attempt (#14475):

- Does not touch StaticFileOptions; consumer
  services.Configure<StaticFileOptions>(...) and OnPrepareResponse
  callbacks continue to work unchanged.
- Sets the header via Response.OnStarting with a ContainsKey guard, so
  any synchronous Cache-Control set upstream wins; consumer OnStarting
  callbacks registered later fire first (LIFO) and also win.
- Skips non-2xx responses to avoid long-lived caching of error responses.

Related: GH #21152, PR #22896.

* Backoffice: Correct rationale for no-cache in debug mode

Reword the XML doc on UseUmbracoBackOfficeCacheHeaders to reflect that
IBackOfficePathGenerator is a singleton, so the cache-bust hash is
computed once at startup even in debug mode (per Copilot review on
#22951). The reason for no-cache is not "hash changes per request" but
that built assets may change in place during dev iteration; no-cache
allows fast 304 revalidation while no-store would force full
re-downloads.

No functional change.

* Backoffice: Add unit tests for UseUmbracoBackOfficeCacheHeaders

Covers six scenarios via a minimal in-process pipeline composed with
Microsoft.AspNetCore.TestHost:

- Production: 200 under hash prefix gets immutable header
- Debug: 200 under hash prefix gets no-cache
- Non-2xx under prefix: header not set (status gate)
- Path outside prefix: header not set (path gate)
- Consumer synchronous override: ContainsKey guard skips, consumer wins
- Consumer OnStarting override: LIFO ordering lets consumer win

Adds Microsoft.AspNetCore.TestHost to Umbraco.Tests.UnitTests (standard
Microsoft package, version pinned in tests/Directory.Packages.props).

* Backoffice: Extract cache-headers logic into IMiddleware class

Matches the existing Umbraco middleware convention (BootFailedMiddleware,
PreviewAuthenticationMiddleware, UmbracoRequestMiddleware, etc.) per
Kenn's note: prefer UseMiddleware<T>() with a DI-resolved class over
inline builder.Use lambdas.

The new UmbracoBackOfficeCacheHeadersMiddleware:
- Implements IMiddleware; registered as a singleton in AddWebComponents
- Computes prefix and header value once in the constructor (both
  dependencies are singletons themselves, so this is stable)
- Behaviour is unchanged from the inline version

The UseUmbracoBackOfficeCacheHeaders extension method becomes a thin
UseMiddleware<T>() wrapper. Tests updated to register the middleware in
the TestServer DI container so it can be resolved through UseMiddleware.

* Backoffice: Document IMiddleware convention in Web.Common CLAUDE.md

Adds an explicit "Convention" note before the middleware list so future
contributors (and AI assistants) default to the IMiddleware class +
AddSingleton + UseMiddleware<T>() pattern rather than inline
builder.Use(async ...) lambdas. Also lists the new
UmbracoBackOfficeCacheHeadersMiddleware in the folder structure and
middleware reference.

* Backoffice: Tighten middleware convention note with full corroboration

Lists every IMiddleware implementer in the codebase (10/10) and calls
out the two known inline-lambda exceptions (CspNonceExtensions,
WebApplicationExtensions) so the rule reads as the established
convention rather than an absolute, while still steering new work
toward IMiddleware + AddSingleton + UseMiddleware<T>().

* Backoffice: Register cache-headers middleware in AddBackOfficeCore

DI scope validation runs in Development/CI and pre-checks every
singleton's dependency graph can be constructed. The middleware was
registered in AddWebComponents (which runs for every Umbraco bootstrap),
but its IBackOfficePathGenerator dependency is only registered by
AddBackOffice(). The previous CI run on this branch surfaced the
problem in four Delivery-only/Website-only bootstrap tests
(CoreWithDeliveryApi_BootsSuccessfully, DeliveryOnlyScenario_BootsSuccessfully,
etc.) with "Unable to resolve service for type 'IBackOfficePathGenerator'
while attempting to activate 'UmbracoBackOfficeCacheHeadersMiddleware'".

Move the registration alongside IBackOfficePathGenerator in
AddBackOfficeCore (Api.Management), which is the same scope as the
backoffice itself. This also matches the wire-up gate in
UmbracoApplicationBuilder.cs that only calls UseUmbracoBackOfficeCacheHeaders
when IBackOfficeEnabledMarker is registered.

CLAUDE.md updated with the rule ("register the middleware next to its
dependencies' registration") and a pitfall note about DI scope validation.

* Backoffice: Address review feedback from AndyButland (PR #22951)

- Move UseUmbracoBackOfficeCacheHeadersTests from Umbraco.Tests.UnitTests
  to Umbraco.Tests.Integration. It uses HostBuilder + TestServer to
  exercise the real HTTP pipeline, which is integration-shaped rather
  than unit-shaped. Drop Microsoft.AspNetCore.TestHost from UnitTests
  (Mvc.Testing in Integration provides it transitively) and from
  tests/Directory.Packages.props.
- Soften the misleading "no trailing slash" comment in
  UmbracoBackOfficeCacheHeadersMiddleware — we trim anyway, so the
  comment is now framed as defensive normalisation.
- Trim the dense middleware convention note in Web.Common/CLAUDE.md to
  one paragraph (rule + the two known inline-lambda exceptions). Move
  the DI-scope-validation pitfall narrative out of CLAUDE.md and into a
  three-line code comment next to the AddSingleton call in
  AddBackOfficeCore where it actually applies.

* Backoffice: HTTP verb gate, 304 inclusion, namespace + unused using (PR #22951 review)

Three more from AndyButland's review:

1. Verb gate + 304 inclusion in UmbracoBackOfficeCacheHeadersMiddleware.
   Restrict the path-prefix match to GET and HEAD so POST/PUT/DELETE
   responses and OPTIONS (CORS preflight) responses don't get tagged as
   immutable. Include 304 alongside 2xx in the status gate so
   intermediate caches (CDN/proxy) receive the Cache-Control directive on
   revalidation responses too. Extended the test suite with four new
   cases: NotModifiedResponseUnderPrefix_SetsImmutable,
   HeadRequestUnderPrefix_SetsImmutable,
   OptionsRequestUnderPrefix_DoesNotSetHeader,
   PostRequestUnderPrefix_DoesNotSetHeader. All 10 tests pass.
2. Test namespace updated to Umbraco.Cms.Tests.Integration.* to match
   the convention used by ~629 other files in Umbraco.Tests.Integration
   (vs the 2 outliers I copied from).
3. Drop unused 'using Umbraco.Extensions;' from the test file.

* Backoffice: Extract conditional checks to satisfy CodeScene complexity gate

CodeScene flagged InvokeAsync with "Complex Conditional" (advisory rule,
code health impact 9.69) after the verb + 304 additions in the prior
commit. Extract the two checks into IsCacheableAssetRequest and
ShouldSetCacheControl helper methods. No behaviour change; tests still
green (10/10, 149 ms).
2026-05-26 08:31:10 +02:00
51d70877d1 QA: Stabilise rollback content versioning E2E test (#22975)
* Stabilise rollback E2E test by waiting for document reload before asserting.

* Condense rollback wait comment per code-review feedback.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Addressed code review feedback.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 12:21:03 +09:00
Andy ButlandandGitHub 2dcfe68208 Backoffice search: Preserve Lucene score order through content-picker lookups (closes #22862) (#22977)
* Preserve Lucene score order through content-picker lookups.

* Removed unnecessary tests.  Addressed code review comments.
2026-05-26 12:15:21 +09:00
ce06c4ba4d Management API: ensure the order from the search endpoints taking a collection of keys is preserved (#22973)
* ensure the order from the search endpoints taking a collection of keys is preserved

* Align cosmetic changes to ensure later merge up doesn't run into conflicts.

---------

Co-authored-by: Lan Nguyen Thuy <lnt@umbraco.dk>
Co-authored-by: Andy Butland <abutland73@gmail.com>
2026-05-25 17:32:17 +00:00
Andy Butland a91de6e677 Fix StoryBook build failure following updates in #22957. 2026-05-25 10:20:45 +02:00
faf3824a0a Backoffice: Embed implementations directly in core manifests to reduce startup network requests (#22944)
* Use direct imports in core manifests

* Extract theme aliases into constants file

---------

Co-authored-by: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com>
2026-05-25 10:07:24 +02:00
Andy Butland 58ed9899be Merge branch 'release/17.5.0' into v17/dev 2026-05-25 08:01:04 +02:00
Engiber LozadaandGitHub bc7bd9a32a Body Layout: Replace overflow: auto with uui-scroll-container (#22950)
* replace overflow: auto with uui-scroll-container in layout components

* Remove stale comment
2026-05-25 07:57:22 +02:00
8160ede4b6 Background Jobs: Refine RecurringBackgroundJobBase API (#22966)
* Add IgnoredDelayChanged event to allow updates during back-off

* Make Period and IgnoredDelay settable on RecurringBackgroundJobBase with auto-raising events

* Address PR review: handle CTS race, restore negative-IgnoredDelay guard, clarify setter remarks

- Swallow ObjectDisposedException in OnIgnoredDelayChanged for the shutdown race where an in-flight handler reads the to-be-disposed CTS via Interlocked.Exchange before Dispose disposes it.
- Restore "skip back-off when IgnoredDelay <= TimeSpan.Zero (and not Timeout.InfiniteTimeSpan)" guard in IgnoreAndWaitAsync to defend against direct IRecurringBackgroundJob implementations / property overrides returning a negative value that would otherwise tight-loop via ComputeNextDelay clamping to zero.
- Add regression test for the negative-IgnoredDelay skip path.
- Mirror the constructor "stored without raising" remark on the Period and IgnoredDelay setter doc comments.

* Dispose newly-installed CTS when shutdown race wins the rotate-and-cancel

* Clarify XML docs.

* Introduce helper for cancellation source rotate and cancel.

---------

Co-authored-by: Andy Butland <abutland73@gmail.com>
2026-05-25 05:42:41 +00:00
Mads RasmussenandGitHub e06a583f1a Backoffice: Embed package root manifests into umbraco-package.ts to reduce startup requests (#22957)
* Consolidate block package into index export

* keep umbraco-package.ts and embed manifests instead

* Inline package manifests into umbraco-package

* update docs
2026-05-23 10:09:03 +02:00
Andy Butland 61cc37ad1d Revert "Entity refs render readonly when their workspace URL can't be resolved."
This reverts commit dc7b34eb58.
2026-05-23 09:42:42 +02:00
Andy Butland dc7b34eb58 Entity refs render readonly when their workspace URL can't be resolved.
Also fixes name on remove dialog.
2026-05-23 09:35:42 +02:00
Andy Butland dfe98ffa3b Disable failing link to selected content from link picker launched from RTE.
Also fixes name on remove dialog.
2026-05-23 09:16:47 +02:00
Andreas Lykke BorgandAndy Butland 6b8e8935fd Accessibility: Added missing labels to webhook details and headers (#22918)
* Added missing labels to webhoot details and headers

* Changed toggle label to aria-label to remove visible text
2026-05-22 19:04:41 +02:00
Andy ButlandandSven Geusens 53c74efd35 Migrations: Append data-anchor value to href when missing in local link migration (closes #22860) (#22936)
* Support anchor fragments that are included in the data attribute but missing in the href when migrating local links.

* Addressed code review feedback.
2026-05-22 12:04:12 +02:00
Jacob Overgaard 1c058a32d9 Merge branch 'release/17.5.0' into v17/dev 2026-05-22 11:22:13 +02:00
Andy ButlandandGitHub 12f838277c Migrations: Append data-anchor value to href when missing in local link migration (closes #22860) (#22936)
* Support anchor fragments that are included in the data attribute but missing in the href when migrating local links.

* Addressed code review feedback.
2026-05-22 11:20:05 +02:00
Jacob OvergaardandClaude Opus 4.7 1ae2a780dd Workspace Actions: Restore waiting state for buttons with additional options (closes #18670, #20593) (#22554)
* Workspace Actions: Restore waiting state for buttons with additional options

The waiting state was suppressed whenever a workspace action reported
hasAdditionalOptions() (e.g. Save and publish on multi-variant sites),
so users saw the button jump straight from idle to the success tick
with no in-flight feedback.

Always set 'waiting' on click (unless the action is a link). The
variant-picker modal still opens on top of the button, so the spinner
is effectively invisible during selection — but it becomes visible
as soon as the modal closes and the publish request is in flight.

Fixes #22551

* Workspace Actions: Spin button only while real work is in flight

Replace the eager always-set-waiting behaviour from the previous commit
with an opt-in `isPending` signal so the spinner appears only while
actual work (validation + HTTP) is happening - not while the variant
picker modal is open, and never as a spurious success tick when the
user cancels the modal.

Changes:

- Add optional `isPending: Observable<boolean>` to UmbWorkspaceAction
  and a default UmbBooleanState + protected setPending() on the base
  class. Optional + backwards compatible for external implementers.

- Add optional `onActionStarting` callback (via a shared
  UmbWorkspaceActionExecutionOptions type) to
  UmbPublishableWorkspaceContext.saveAndPublish and
  UmbSaveableWorkspaceContext.requestSave. The document publishing
  context and content detail workspace base invoke the callback at the
  join point right after the variant picker resolves (or is skipped
  for the single-variant case), so it never fires when the modal is
  cancelled.

- Wire the document save and save-and-publish actions to clear pending
  at the start of execute() and pass an onActionStarting callback that
  flips it true when work begins.

- Update the workspace action element to observe api.isPending: when
  the observable is present the waiting state is driven by the
  observable (and the success tick is suppressed if the action
  resolves without ever signalling pending - i.e. a cancellation).
  When the observable is absent the element falls back to the legacy
  eager-waiting behaviour. Failures always surface the failed tick.

Fixes #22551

* Reduce cyclomatic complexity of #onClick and _handleSave

CodeScene Code Health Review flagged two complexity issues:

- UmbWorkspaceActionElement.#onClick reached cyclomatic complexity 9
  (threshold is < 9). Extracted the api-execution branch into a new
  private #runApiAction helper so #onClick collapses to a simple
  link-vs-action dispatch.
- _handleSave was already over the threshold (14); my optional-chain
  callback invocation pushed it to 16. Moved the
  `executionOptions?.onActionStarting?.()` call into a #notifyActionStarting
  helper so the call site is a plain method call and contributes zero
  cyclomatic complexity to _handleSave.

No behavioural change.

* Reduce cyclomatic complexity of #handleSaveAndPublish

Same fix as the previous commit's #notifyActionStarting extraction in
content-detail-workspace-base: move the optional-chain callback
invocation into a private helper so #handleSaveAndPublish stays at its
pre-PR cyclomatic complexity (15) instead of degrading to 17.

No behavioural change.

* DRY: extract notifyWorkspaceActionStarting into a shared utility

Both UmbDocumentPublishingWorkspaceContext.#handleSaveAndPublish and
UmbContentDetailWorkspaceContextBase._handleSave had identical private
optional-chain callback off the host method's cyclomatic complexity.

Replace both with a single exported notifyWorkspaceActionStarting()
utility co-located with UmbWorkspaceActionExecutionOptions. This:

- Removes a duplication point between the two contexts.
- Gives future workspace context implementations a ready-made way to
  honour the optional callback without re-inventing the helper or
  paying the cyclomatic-complexity cost at the call site.

No behavioural change.

* Rename isPending -> isExecuting to mirror the execute() method

Niels suggested correlating the observable's name with the action's
`execute()` method, so the symbol set is now:

- isExecuting (observable on UmbWorkspaceAction interface)
- _isExecuting / setExecuting (UmbWorkspaceActionBase)
- #observeIsExecuting / #executionStarted (workspace-action element)
- isExecutingObserver (observer alias)

Pure rename; no behavioural change.

* Address Copilot review: lazy isExecuting, observer scope, finally reset

Five Copilot findings on PR #22554. Three real regressions + two
contract violations, all addressed:

1. UmbWorkspaceActionBase always exposing `isExecuting` made every
   existing subclass appear to opt in to the new modal-aware flow,
   suppressing waiting/success states for actions that never call
   setExecuting(true). Made `_isExecuting`/`isExecuting` lazy: only
   created on the first setExecuting() call. Opt-in subclasses call
   `setExecuting(false)` in their constructor so the observable is
   exposed before the workspace-action element reads it. Subclasses
   that don't opt in keep `isExecuting` undefined and the element
   falls back to legacy eager waiting feedback.

2. Element observation of `isExecuting` now lives inside #runApiAction
   so it tracks whichever api is actually invoked (`_actionApi ?? #api`),
   correctly handling subclasses like UmbSaveAndPreviewWorkspaceActionElement
   that swap in a different api at runtime. The shared observer alias
   replaces any previous observation on re-clicks.

3. UmbSaveWorkspaceAction and UmbDocumentSaveAndPublishWorkspaceAction
   now wrap their execute() body in try/finally and reset
   setExecuting(false) on completion so the observable honours the
   "true while execute() is performing real work, false otherwise"
   contract instead of getting stuck at true between executions.

No behavioural change for actions that already worked correctly before
this PR; the regression-prone "always exposed" behaviour is gone.

* Address Claude review: Elements gap, type placement, tests + cleanup

Three follow-ups on top of c15eb2d0bc:

1. Elements gap — UmbElementSaveAndPublishWorkspaceAction +
   UmbElementPublishingWorkspaceContext now wire through the same
   onActionStarting/notifyWorkspaceActionStarting handshake as the
   Document equivalents, so multi-variant Elements (Forms, Commerce, etc.)
   get the spinner-after-modal behaviour rather than no spinner at all.

2. Type placement — moved UmbWorkspaceActionExecutionOptions out of
   publishable-workspace-context.interface.ts into its own file so the
   saveable interface no longer has a directional dependency on the
   publishable one. Both peer contexts now import from the same neutral
   location.

3. Unit tests — added blackbox coverage for notifyWorkspaceActionStarting
   (no-op on undefined options/callback, invokes when present) and the
   UmbWorkspaceActionBase.setExecuting lazy-opt-in contract (undefined
   until first call, observable then exposed, value flips, sequential
   emissions, stable reference across calls).

Code-review cleanup applied on the same pass:

- Dropped the redundant `setExecuting(false)` at the start of execute()
  in the save and save-and-publish actions; the finally block plus
  UmbBooleanState's value-dedup already cover idempotency on retries.
- Removed an overlong block comment on `_isExecuting`; the JSDoc on
  setExecuting already documents the lazy/opt-in contract for subclasses.
- Trimmed an internal motivation comment from notify-workspace-action-
  starting.function.ts that referenced cyclomatic complexity.
- Extracted a tiny makeAction() helper in the controller test to remove
  the `{ meta: {} as never }` repetition.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Workspace Actions: Align Save button state with Save & Publish (Andy review feedback)

Two related fixes addressing the variant-Save inconsistency Andy reported:

- Element catch block now only sets `failed` once `#executionStarted` is
  true. Pre-flight rejections (user cancelling a variant-picker modal,
  context-missing throws, etc.) leave the button idle, matching the
  silent-cancel path used by `#handleSaveAndPublish`. Legacy actions
  that don't opt in to `isExecuting` are unaffected because they set
  `#executionStarted = true` eagerly on click.

- `UmbDocumentWorkspaceContext._handleSave` and
  `UmbElementWorkspaceContext._handleSave` now accept and forward the
  `UmbWorkspaceActionExecutionOptions` argument to `super._handleSave`.
  The previous overrides dropped the parameter, so the
  `onActionStarting` callback supplied by `UmbSaveWorkspaceAction` never
  fired - which is why Save showed no waiting/success indicator even
  on a successful submit.

Result: Save and Save-and-publish now behave identically -
cancel = no indicator, submit = waiting then success - for both
invariant and multi-variant documents and elements.

* Docs: Document the modal-aware execution feedback contract for workspace actions

New 'Button state when the action opens a modal' subsection in
docs/workspaces.md explaining the three-piece contract:
UmbWorkspaceActionExecutionOptions + notifyWorkspaceActionStarting +
UmbWorkspaceActionBase.setExecuting. Covers third-party authoring of
modal-aware buttons, the cancel/pre-flight idle behaviour, and the
silent-parameter-drop pitfall on _handleSave overrides.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 11:00:49 +02:00
Jacob OvergaardandClaude Opus 4.7 04f0e229c7 Backoffice: Coalesce small Rollup chunks across all workspaces (AB#67983) (#22896)
* Backoffice: Coalesce small Rollup chunks across all workspaces (AB#67983)

Set experimentalMinChunkSize=10_000 as the default in the shared Vite
helper. Every workspace inherits the coalescing automatically; the
threshold can still be overridden per workspace (pass 0 to disable).

Impact on dist-cms output:
- packages/core .js files: 981 -> 272 (-72%)
- All workspaces combined .js files: 2194 -> 1401 (-36%)
- Welcome dashboard .js requests: 510 -> 497 (-2.5%)
- packages/ufm requests in particular: 23 -> 12 (-48%)
- Gzipped bundle total: -1.2%
- Raw bytes: +1.4% (small overhead from merged chunks; gzip wins it back)

All entry chunks are preserved, so every public
@umbraco-cms/backoffice/<sub> import keeps resolving without changes
to package.json exports or tsconfig paths.

Further consolidation (collapsing core's per-subpath entries into a
single bundle with stubs) was prototyped but hits a TDZ cycle between
the eager entry and its dynamic-import descendants. Tracked for v18,
not part of this change.

* Backoffice: Normalise umbraco-package + manifests shapes (AB#67983)

Aligns the two outliers with the conventions used by the other 38
first-party packages:

- documents/umbraco-package.ts now uses the lazy bundle pattern
  (type: 'bundle', js: () => import('./manifests.js')) instead of
  eagerly importing manifests at module evaluation. The bundle
  initializer auto-loads the manifests at boot, so behaviour is
  unchanged.
- umbraco-news/manifests.ts now exports `manifests: Array<...>`
  instead of a bare `dashboard` object. The bundle initializer
  enumerates exports regardless of name, so behaviour is unchanged.

Preparatory cleanup so future build-time manifest aggregation can
treat every workspace uniformly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 09:35:44 +02:00
5d76706553 Backoffice: Coalesce small Rollup chunks across all workspaces (AB#67983) (#22896)
* Backoffice: Coalesce small Rollup chunks across all workspaces (AB#67983)

Set experimentalMinChunkSize=10_000 as the default in the shared Vite
helper. Every workspace inherits the coalescing automatically; the
threshold can still be overridden per workspace (pass 0 to disable).

Impact on dist-cms output:
- packages/core .js files: 981 -> 272 (-72%)
- All workspaces combined .js files: 2194 -> 1401 (-36%)
- Welcome dashboard .js requests: 510 -> 497 (-2.5%)
- packages/ufm requests in particular: 23 -> 12 (-48%)
- Gzipped bundle total: -1.2%
- Raw bytes: +1.4% (small overhead from merged chunks; gzip wins it back)

All entry chunks are preserved, so every public
@umbraco-cms/backoffice/<sub> import keeps resolving without changes
to package.json exports or tsconfig paths.

Further consolidation (collapsing core's per-subpath entries into a
single bundle with stubs) was prototyped but hits a TDZ cycle between
the eager entry and its dynamic-import descendants. Tracked for v18,
not part of this change.

* Backoffice: Normalise umbraco-package + manifests shapes (AB#67983)

Aligns the two outliers with the conventions used by the other 38
first-party packages:

- documents/umbraco-package.ts now uses the lazy bundle pattern
  (type: 'bundle', js: () => import('./manifests.js')) instead of
  eagerly importing manifests at module evaluation. The bundle
  initializer auto-loads the manifests at boot, so behaviour is
  unchanged.
- umbraco-news/manifests.ts now exports `manifests: Array<...>`
  instead of a bare `dashboard` object. The bundle initializer
  enumerates exports regardless of name, so behaviour is unchanged.

Preparatory cleanup so future build-time manifest aggregation can
treat every workspace uniformly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 09:28:31 +02:00
Mads RasmussenandGitHub 1fdcb835bc Devops: Report bidirectional import detection for both Core and Packages modules (#22938)
report bidirectional imports for core modules
2026-05-22 07:29:37 +02:00
Mads RasmussenandGitHub 6b3bdb59b7 Backoffice: Swap relative imports to @umbraco-cms/backoffice module imports in core packages (#22942)
Use @umbraco-cms/backoffice imports

Replace numerous relative/internal import paths with centralized '@umbraco-cms/backoffice' package entry points across core modules.This consolidates exports, simplifies import paths.
2026-05-22 07:24:39 +02:00
65ab1c0b2b Background Jobs: Rewrite RecurringHostedServiceBase with SemaphoreSlim and add signalling support (#22331)
* Compute next delay to compensate for time drift

* Use SemaphoreSlim to properly handle exceptions, cancellation tokens and triggering immediate executions

* Add RecurringBackgroundJobBase to contain default values and hide obsoleted method

* Add NextExecutionStrategy parameter to adjust the schedule after triggered executions

* Add TriggerExecution methods to RecurringBackgroundJobHostedServiceRunner

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Handle cancellation (application shutdown) and publish RecurringBackgroundJobCanceledNotification

* Match hosted services by Type instead of type name string

* Extract shared helper for TriggerExecution tests

* Clear trigger state when initial delay is interrupted

* Clear _nextExecutionSkipOnOvershoot unconditionally

* Combine ComputeNextDelay tests

* Consolidate trigger state into an immutable record for thread safety

* Use ConcurrentDictionary for thread-safe hosted service lookup

* Remove hosted services from dictionary on stop

* Fix API compatibility errors

* Removed unneeded using.

* Register RecurringBackgroundJobHostedServiceRunner as resolvable singleton

* Remove failed hosted service from dictionary when StartAsync throws

* Use semaphore signaling instead of Task.Delay in trigger tests

Use semaphore signaling instead of Task.Delay in trigger tests 2

* Inject TimeProvider into RecurringHostedServiceBase for deterministic testing

Fix timeprovider

* Use DelayCalculator.GetDelay instead of RecurringHostedServiceBase.GetDelay

* Fix Exception_In_PerformExecuteAsync_Does_Not_Kill_Loop test

* Avoid disposing period-change CTS while wait loop may still reference it

* Configure IEventMessagesFactory mock to return real EventMessages

* Clarify TriggerExecution(TimeSpan) docs and add ChangePeriod test

* Validate period is positive and use GetOrAdd to avoid creating unused hosted services

* Set up Period and Delay on mock job to satisfy constructor validation

* Ensure PeriodChanged event is unsubscribed again

* Fix trigger state race, simplify ReleaseSignal, and add canceled notification test

Fix trigger state

* Use Interlocked for _period reads/writes and implement thread-safe dispose pattern

* Remove hosted service from dictionary before stopping to prevent triggering during shutdown

* Replace Task.Yield with semaphore timeouts in negative assertions

* Tidy RecurringBackgroundJobBase docs and runner error handling

* Wait IgnoredDelay after ignored execution to prevent tight looping when Period is short or zero

* Add IRecurringBackgroundJobTrigger<TJob> for opt-in job triggering

* Register IRecurringBackgroundJobTrigger as open generic and drop AddTriggerableRecurringBackgroundJob

* Fix and add parameter validation

* Allow Timeout.InfiniteTimeSpan as Period for manual-trigger-only recurring jobs

* Migrate built-in jobs to RecurringBackgroundJobBase and require ITriggerableRecurringBackgroundJob in runner trigger overloads

* Support infinite Delay and honor TriggerExecution(TimeSpan) issued during the initial delay

* Handle edge case of backoff via InfiniteTimeSpan.

* Refactored large method.

* Added clarifying documentation.

* Suppress ExecutionContext flow when starting the recurring background loop, restoring previous timer behaviour.

* Relocate Suppress ExecutionContext flow to avoid package validation error.

* Align IRecurringBackgroundJobTrigger generic type constraint with AddRecurringBackgroundJob

* Rename ApplyTriggerState to ComputeNextDelayFromTriggerState

* Allow Timeout.InfiniteTimeSpan as IgnoredDelay to fully disable a job for the remaining application lifecycle

* Fix generic type constraint

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Andy Butland <abutland73@gmail.com>
2026-05-21 19:39:21 +02:00
a54769758b Background Jobs: Rewrite RecurringHostedServiceBase with SemaphoreSlim and add signalling support (#22331)
* Compute next delay to compensate for time drift

* Use SemaphoreSlim to properly handle exceptions, cancellation tokens and triggering immediate executions

* Add RecurringBackgroundJobBase to contain default values and hide obsoleted method

* Add NextExecutionStrategy parameter to adjust the schedule after triggered executions

* Add TriggerExecution methods to RecurringBackgroundJobHostedServiceRunner

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Handle cancellation (application shutdown) and publish RecurringBackgroundJobCanceledNotification

* Match hosted services by Type instead of type name string

* Extract shared helper for TriggerExecution tests

* Clear trigger state when initial delay is interrupted

* Clear _nextExecutionSkipOnOvershoot unconditionally

* Combine ComputeNextDelay tests

* Consolidate trigger state into an immutable record for thread safety

* Use ConcurrentDictionary for thread-safe hosted service lookup

* Remove hosted services from dictionary on stop

* Fix API compatibility errors

* Removed unneeded using.

* Register RecurringBackgroundJobHostedServiceRunner as resolvable singleton

* Remove failed hosted service from dictionary when StartAsync throws

* Use semaphore signaling instead of Task.Delay in trigger tests

Use semaphore signaling instead of Task.Delay in trigger tests 2

* Inject TimeProvider into RecurringHostedServiceBase for deterministic testing

Fix timeprovider

* Use DelayCalculator.GetDelay instead of RecurringHostedServiceBase.GetDelay

* Fix Exception_In_PerformExecuteAsync_Does_Not_Kill_Loop test

* Avoid disposing period-change CTS while wait loop may still reference it

* Configure IEventMessagesFactory mock to return real EventMessages

* Clarify TriggerExecution(TimeSpan) docs and add ChangePeriod test

* Validate period is positive and use GetOrAdd to avoid creating unused hosted services

* Set up Period and Delay on mock job to satisfy constructor validation

* Ensure PeriodChanged event is unsubscribed again

* Fix trigger state race, simplify ReleaseSignal, and add canceled notification test

Fix trigger state

* Use Interlocked for _period reads/writes and implement thread-safe dispose pattern

* Remove hosted service from dictionary before stopping to prevent triggering during shutdown

* Replace Task.Yield with semaphore timeouts in negative assertions

* Tidy RecurringBackgroundJobBase docs and runner error handling

* Wait IgnoredDelay after ignored execution to prevent tight looping when Period is short or zero

* Add IRecurringBackgroundJobTrigger<TJob> for opt-in job triggering

* Register IRecurringBackgroundJobTrigger as open generic and drop AddTriggerableRecurringBackgroundJob

* Fix and add parameter validation

* Allow Timeout.InfiniteTimeSpan as Period for manual-trigger-only recurring jobs

* Migrate built-in jobs to RecurringBackgroundJobBase and require ITriggerableRecurringBackgroundJob in runner trigger overloads

* Support infinite Delay and honor TriggerExecution(TimeSpan) issued during the initial delay

* Handle edge case of backoff via InfiniteTimeSpan.

* Refactored large method.

* Added clarifying documentation.

* Suppress ExecutionContext flow when starting the recurring background loop, restoring previous timer behaviour.

* Relocate Suppress ExecutionContext flow to avoid package validation error.

* Align IRecurringBackgroundJobTrigger generic type constraint with AddRecurringBackgroundJob

* Rename ApplyTriggerState to ComputeNextDelayFromTriggerState

* Allow Timeout.InfiniteTimeSpan as IgnoredDelay to fully disable a job for the remaining application lifecycle

* Fix generic type constraint

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Andy Butland <abutland73@gmail.com>
2026-05-21 19:36:04 +02:00
Andreas Lykke BorgandAndy Butland 1616997409 Image Cropper: Improve contrast of append label in crop options editor (closes #22878) (#22917)
Improve contrast of input append label in image crops editor
2026-05-21 19:06:01 +02:00
Andreas Lykke BorgandGitHub 609b74b475 Image Cropper: Improve contrast of append label in crop options editor (closes #22878) (#22917)
Improve contrast of input append label in image crops editor
2026-05-21 19:05:33 +02:00
Engiber LozadaandAndy Butland 63289e22cb Block Grid: Fix inline create button width not updating on workspace resize (closes #22527) (#22928)
* Add ResizeObserver for inline create buttons

* Disconnect layout resize observer on destroy

* Initialize ResizeObserver and simplify cleanup

* Remove optional chaining on layout observer disconnect
2026-05-21 18:12:23 +02:00
Engiber LozadaandGitHub 82b2991a18 Block Grid: Fix inline create button width not updating on workspace resize (closes #22527) (#22928)
* Add ResizeObserver for inline create buttons

* Disconnect layout resize observer on destroy

* Initialize ResizeObserver and simplify cleanup

* Remove optional chaining on layout observer disconnect
2026-05-21 18:11:24 +02:00
Andy Butland 721cf53d40 Fix styling of redirect tracker enabled/disabled icon. 2026-05-21 17:59:27 +02:00
Andy Butland f8ba3d8cfc Merge branch 'release/17.5.0' into v17/dev 2026-05-21 17:41:20 +02:00
Andy ButlandandLan Nguyen Thuy 7f832d261d Reset password: Add inline validation messaging for password pattern requirements (#22880)
* add custom validation for password input in reset password

* update remove invalid listeners in disconnectedCallback

---------

Co-authored-by: Lan Nguyen Thuy <lnt@umbraco.dk>
2026-05-21 17:34:05 +02:00
Mads RasmussenandGitHub f79e9586b4 Collections: Replace direct filter pass-through in collection server data sources (#22921)
Pass explicit skip/take to collection services
2026-05-21 09:18:36 +01:00
Mads RasmussenandGitHub c74a58246f Entity Data Picker: Fix "Not Found" in remove dialog for entities without a top-level name (#22915)
* Add item data resolver support to picker data sources

* add js docs

* remove duplicated fallback logic

* wip unit tests of requestItemName method

* Use DocumentVariantStateModel in mock documents to fix compiler

* Update input-entity-data.context.ts

* Update input-entity-data.context.test.ts
2026-05-21 09:13:45 +01:00
nikolajlauridsen 06b15157cf Merge branch 'release/17.4.2' into release/17.5.0
# Conflicts:
#	src/Umbraco.Web.UI.Client/package-lock.json
#	src/Umbraco.Web.UI.Client/package.json
#	tests/Umbraco.Tests.AcceptanceTest/package-lock.json
#	tests/Umbraco.Tests.AcceptanceTest/package.json
#	version.json
2026-05-21 09:18:11 +02:00
nikolajlauridsen 82f7830d26 Merge branch 'release/17.4.2' into v17/dev
# Conflicts:
#	src/Umbraco.Web.UI.Client/package-lock.json
#	src/Umbraco.Web.UI.Client/package.json
#	tests/Umbraco.Tests.AcceptanceTest/package-lock.json
#	tests/Umbraco.Tests.AcceptanceTest/package.json
#	version.json
2026-05-21 09:16:39 +02:00
8aaac65f83 Content Editing: Fix save composition values on invariant content and save of default segment (closes #22800, #22865) (#22846)
* Fix edit of a variant property composed to an invariant document.

* Collapse multi-line guard comment to a single line per project policy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Add unit test coverage for invariant content with a culture-variant composition property.

Adds a third mock document/document-type pair representing an invariant
content type whose flattened property list contains a culture-variant
property (the runtime shape produced when a variant composition is applied
to an invariant content type) and a setPropertyValue test asserting the
value is stored as a culture/segment-invariant entry.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Remove null guard for segment variant documents, as null segment is the default segment.

* update mock data and tests to include real compositions

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Mads Rasmussen <madsr@hey.com>
2026-05-20 10:52:09 +02:00
8d5826c61f Content Editing: Fix save composition values on invariant content and save of default segment (closes #22800, #22865) (#22846)
* Fix edit of a variant property composed to an invariant document.

* Collapse multi-line guard comment to a single line per project policy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Add unit test coverage for invariant content with a culture-variant composition property.

Adds a third mock document/document-type pair representing an invariant
content type whose flattened property list contains a culture-variant
property (the runtime shape produced when a variant composition is applied
to an invariant content type) and a setPropertyValue test asserting the
value is stored as a culture/segment-invariant entry.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Remove null guard for segment variant documents, as null segment is the default segment.

* update mock data and tests to include real compositions

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Mads Rasmussen <madsr@hey.com>
2026-05-20 10:43:02 +02:00
Lee KelleherandGitHub 5a73f63cfd feat(components): adds umb-entity-frame component + Storybook stories (#22844)
* feat(components): adds `umb-entity-frame` component + Storybook stories

* fix(components): address review feedback for `umb-entity-frame`

- Remove `pointer-events: auto` from `.tab` so the overlay is truly passive
  (was intercepting events above the parent and causing hover flicker when
  toggled via opacity).
- Replace `--uui-color-surface` tab text with `--uui-color-selected-contrast`
  (the proper paired contrast token) and expose
  `--umb-entity-frame-contrast-color` so consumers can override when supplying
  a non-default `--umb-entity-frame-color`. Fixes contrast in dark and
  high-contrast themes.
- Add `aria-hidden="true"` to `.tab`; the frame is purely decorative and the
  parent owns the real semantics.
- Add a unit test verifying slot content takes precedence over the `label`
  property.

* Removed `aria-hidden` from the label tab

As will need to be used with assistive technologies.
2026-05-20 08:39:49 +00:00
e463cd3a0c Log Viewer: Defensively handle corrupt log files (closes #22820) (#22826)
* Defensively handle log file corruptions by amalgamating errors per file and reporting as warning.

* Addressed code review comments.

* Use local reference to Newtonsoft.Json so it's clear we are only using it for exception handling.

---------

Co-authored-by: Kenn Jacobsen <kja@umbraco.dk>
2026-05-20 07:22:54 +00:00
2901be793a Redirect URL Tracker: Remove ability to toggle from the UI (#22830)
* Remove the ability to enable or disable the redirect tracker from the UI.

* Addressed code review feedback.

* Update OpenApi.json.

* Regenerate backend SDK from updated OpenApi.json

* Update UI to use lozenge status indicator rather than an imperative action.

* Further UX tweak.

---------

Co-authored-by: Laura Neto <12862535+lauraneto@users.noreply.github.com>
2026-05-20 09:20:37 +02:00
Andy Butland 0b86312f52 Children/Descendants: improve traversal performance (closes #22646) (#22742)
* Add benchmark test for measuring improvements to children and descendant retrieval.

* Remove unnecessary sort from retrieval of children.

* Return the result of the filtered collection of children/decendants without materialising.

* Lazily build property wrappers when materializing IPublishedContent.

* Cache the ordered children list on NavigationNode.

* Cache descendants per parent on the navigation snapshot.

* Add synchronous fast path for retrieved of cached content.

* Additional unit tests.

* Add TODO to make UpdateSortOrder internal.

* Addressed code review feedback.

* Further unit tests.

* Future-proofed code comments.
2026-05-19 18:01:12 +02:00
Andy ButlandandGitHub f4592111fa Children/Descendants: improve traversal performance (closes #22646) (#22742)
* Add benchmark test for measuring improvements to children and descendant retrieval.

* Remove unnecessary sort from retrieval of children.

* Return the result of the filtered collection of children/decendants without materialising.

* Lazily build property wrappers when materializing IPublishedContent.

* Cache the ordered children list on NavigationNode.

* Cache descendants per parent on the navigation snapshot.

* Add synchronous fast path for retrieved of cached content.

* Additional unit tests.

* Add TODO to make UpdateSortOrder internal.

* Addressed code review feedback.

* Further unit tests.

* Future-proofed code comments.
2026-05-19 18:00:34 +02:00
Andy Butland 4c909d8ce8 Merge branch 'release/17.4.1' into release/17.5.0 2026-05-19 17:54:19 +02:00
Andy Butland 12c699d5bd Merge branch 'release/17.4.1' into v17/dev 2026-05-19 17:47:47 +02:00
Jacob Overgaard 1637d9b158 Merge branch 'release/17.5.0' into v17/dev 2026-05-19 10:55:58 +02:00
Jacob Overgaard 7737cd3d40 Localization: Honor DefaultUILanguage on initial load (closes #22808) (#22822)
* Localization: Honor DefaultUILanguage on initial load (closes #22808)

Closes #22808.

Previously, the configured DefaultUILanguage was silently overridden to
'en' at startup because the UmbLocalizationRegistry constructor called
loadLanguage(UMB_DEFAULT_LOCALIZATION_CULTURE) unconditionally. The
configured locale rendered into <html lang="..."> by Razor never had a
chance to flow through to the active language.

Changes:

- localization.registry.ts: stop forcing the active language to 'en'
  in the constructor. Initial state is canonicalised from
  document.documentElement.lang, falling back to 'en' for empty or
  malformed input. The extension filter now always includes the
  default culture alongside the active locale so 'en' translations
  remain available as a key-level fallback regardless of which
  language is active. A synchronous tap mirrors the active locale to
  document.lang and the manager when the state changes, so a fresh
  element rendered between loadLanguage() and the async translation
  load picks up the right language immediately.

- localization.manager.ts: drop the MutationObserver on
  document.documentElement and rely on the registry as the single
  channel for language changes. setActiveLanguage accepts a `silent`
  option so the synchronous tap can update fields without firing a
  consumer notification (translations may still be loading). A new
  notifyLanguageChanged() method is fired by the registry once
  translations are in place.

- app.element.ts: subscribe to umbLocalizationRegistry.currentLanguage
  in connectedCallback and mirror it onto the host element's lang
  attribute, so myApp.lang reflects the source of truth rather than a
  stale snapshot of <html lang>.

- auth.element.ts (login app): same lang subscription, plus after the
  slim backoffice controller registers extensions, prefer the
  visitor's navigator.language if a matching localization extension
  exists (falls through baseName -> language -> en automatically).

Tests: new initialization tests for the registry, manager
setActiveLanguage tests, and the controller tests refactored to use
the new explicit setActiveLanguage API instead of writing directly to
document.documentElement.lang.

* Login: Only override DefaultUILanguage with navigator.language when default has no translation

If the admin sets DefaultUILanguage to a language we have a translation for,
respect that choice over the visitor's browser language. Falling back to
navigator.language only when the configured default isn't available avoids
silently ignoring the admin's explicit setting (e.g., DefaultUILanguage='da-DK'
on a site whose visitor's browser is 'en-GB' should still show Danish).

* Simplify: split setActiveLanguage from notifyLanguageChanged

Drop the silent option in favor of two intent-revealing methods:
setActiveLanguage updates the active language and direction without
side-effects; notifyLanguageChanged tells all connected controllers
to re-render against the current state. Callers compose them based
on what they need (the registry's pipeline updates language sync
then flushes notifications after async translation load).

Also extracts baseLocaleOf() helper, simplifies the navigator.language
match logic in the login app's #applyPreferredLanguage, and removes
narration-style comments in the new code.

* Restore deprecated UmbLocalizationManager.updateAll for backward compat

The old MutationObserver-driven updateAll() field was technically part
of the manager's public surface. Restore it as a deprecated alias that
reads document.lang/dir and forwards to setActiveLanguage + notifyLanguageChanged,
with a runtime UmbDeprecation warning pointing consumers at the new API.

* Fix deprecation removal version to v20 + correct baseLocaleOf JSDoc

Per the deprecation policy in CLAUDE.md (current major + 2): a method
deprecated in v18 must remain through v19 before removal, so the
earliest removal is v20, not v19.

Also corrects the baseLocaleOf JSDoc — Intl.Locale.baseName can include
script subtags (e.g. 'zh-Hant-TW'), not just language and region.

* Scope the active language to the host element, drop navigator.language

- Razor now sets `lang` on `<umb-app>` and `<umb-auth>` from
  DefaultUILanguage. The element passes its lang through on connect, so
  the host owns its own scope — future multi-backoffice scenarios (e.g.
  signing into two Umbraco Cloud sites in the same document) get their
  own language without fighting over a global `<html lang>`.

- The registry no longer reads or writes `document.documentElement.lang`.
  Host elements drive it via `loadLanguage()`; `<html lang>` stays as
  whatever Razor rendered.

- Removed the navigator.language preference detection in the login app.
  Not in scope for the bug fix and adds behavior the admin can't opt out
  of. The existing current-user-locale flow already handles per-user
  preference after login.

- Tests updated to assert on `umbLocalizationManager.documentLanguage`
  instead of `document.documentElement.lang`.

* Set <html lang="en"> to match the static (noscript) text in the templates

The page's `<html lang>` should describe the language of the document's
own innate content. Both Index.cshtml files only contain English static
text (the noscript fallback), so the page-level lang is now "en".

The dynamic UI inside <umb-app> / <umb-auth> carries its own `lang`
attribute (from DefaultUILanguage), which overrides for that subtree —
correct per the HTML spec for language inheritance.

* Drop deprecated UmbLocalizationManager.updateAll

It was public as an artifact of being an arrow function so it could be
passed to a MutationObserver without binding — not because it was
intended as part of the public API. External usage is effectively
zero, and the new explicit setActiveLanguage + notifyLanguageChanged
covers anyone who did reach for it.

* Docs: document active-language-on-host pattern in package-development.md

After PR #22822, the active UI language is driven by the shell elements
(<umb-app>, <umb-auth>) via their own lang attribute, not by <html lang>.
Document that so future contributors don't reach for the global.

* Collapse setActiveLanguage + notifyLanguageChanged into one method

The silent-write path is just `manager.documentLanguage = ...` — no new
method needed; the field is already public and was always writable. The
notify path keeps setActiveLanguage, which now both sets and notifies.

Net: one new public method on the manager instead of two.

* Inline the active-language write in the registry, drop setActiveLanguage

The previous version added setActiveLanguage on the manager as
a 'cleaner API' than direct field writes. But the manager's fields
(documentLanguage, documentDirection, connectedControllers) have
always been public, the registry is the only caller, and the wrapper
was just more public surface to maintain through the eventual
manager/registry collapse.

Net: -70 lines across the test file, no new public methods on the
manager, the registry pipeline writes the fields and iterates the
controllers directly where it would have called setActiveLanguage.

* Document that documentLanguage/Direction are read-only for consumers

Note in JSDoc that the only supported way to change the active language
is umbLocalizationRegistry.loadLanguage(). The fields stay writable for
the registry pipeline (cross-module internal); the comment is here so
the next contributor doesn't reach for them as a shortcut and end up
with the manager state out of sync with what's actually loaded.
2026-05-19 10:52:55 +02:00
139ac6ad72 Blueprints: Fix UdiEntityTypeHelper.ToUmbracoObjectType() for document blueprint containers (#22875)
* Blueprints: Fix UdiEntityTypeHelper.ToUmbracoObjectType() for document blueprint containers

Adds the missing DocumentBlueprintContainer case so the conversion is
symmetric with FromUmbracoObjectType().

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Add missing case for MemberTypeContainer.

* Use reflection to ensure other future missed cases are surfaced without having to explicitly extend the tests.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Andy Butland <abutland73@gmail.com>
2026-05-19 09:13:52 +02:00
cd4521bd77 Blueprints: Fix UdiEntityTypeHelper.ToUmbracoObjectType() for document blueprint containers (#22875)
* Blueprints: Fix UdiEntityTypeHelper.ToUmbracoObjectType() for document blueprint containers

Adds the missing DocumentBlueprintContainer case so the conversion is
symmetric with FromUmbracoObjectType().

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Add missing case for MemberTypeContainer.

* Use reflection to ensure other future missed cases are surfaced without having to explicitly extend the tests.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Andy Butland <abutland73@gmail.com>
2026-05-19 09:11:09 +02:00
80e2764eda Migrations: Add auto upgrade coordination for load-balanced setups (#22815)
* Add auto upgrade coordination for load balanced setups

* Add tests

* Apply suggestions from code review

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* fix(infrastructure): move TryBecomeLeaderAsync inside try/catch in UnattendedUpgradeBackgroundService

Ensures DB exceptions thrown during migration coordination set BootFailed
rather than faulting the background service silently.

* Fix feedback

* Update src/Umbraco.Infrastructure/Install/MigrationCoordinator.cs

Co-authored-by: Andy Butland <abutland73@gmail.com>

* Recheck state

* fix(tests): update concurrent race test for post-claim DetermineRuntimeLevel check

The winner now calls DetermineRuntimeLevel() once from the post-claim check
and must see Upgrading; the loser polls twice before seeing Run. Transition
the mock on the second call instead of the first.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Andy Butland <abutland73@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 12:06:34 +02:00
d9bb17de2a Relation Type: Migrate custom table collection view to generic table (#22837)
* migrate relation type table collection view to table kind

* update page locator

* Request relations when workspace unique is set

* fix types

* split models

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Use constant for relation type collection alias + remove redundant fields

* Add observer keys in relation-type workspace view

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-15 08:26:42 +00:00
72fdf281fd Backoffice: Provide entity context via UMB_ENTITY_CONTEXT in menu components (#22835)
* Use UmbEntityContext for entity actions

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-15 09:26:07 +02:00
Andy ButlandandGitHub b24c9ba8ac Log Viewer: Updated the saved log viewer searches for new installs to reference Umbraco.Cms instead of Umbraco.Core. (#22843)
* Updated the saved log viewer searches for new installs to reference Umbraco.Cms instead of Umbraco.Core.

* Update mock and default data too.
2026-05-15 08:29:09 +09:00
2377e9a555 Cache: Add scope-level cache version tier to reduce DB hits in bulk operations (#22563)
* Cache cacheversion on scope

* Add tests

* Cache: Use ConcurrentDictionary for the inner per-scope version map

The inner Dictionary<string, Guid> was not thread-safe. Replacing it
with ConcurrentDictionary<string, Guid> removes the hidden assumption
that the root scope is only accessed from a single thread at a time.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Update src/Umbraco.Core/Cache/IRepositoryCacheVersionAccessor.cs

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update tests/Umbraco.Tests.UnitTests/Umbraco.Web.Common/Cache/RepositoryCacheVersionAccessorTests.cs

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/Umbraco.Web.Common/Cache/RepositoryCacheVersionAccessor.cs

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Skips failing test so we can run nightly. THIS NEEDS TO BE REVERTED

* Revert "Skips failing test so we can run nightly. THIS NEEDS TO BE REVERTED"

This reverts commit c34d1736c336b3fcf7803b44e88f6018fa45c275.

* Only write version once pr. scope

* Add tests

* Remove unnececary locks

* Fix thread-safety: replace HashSet with ConcurrentHashSet and use GetOrAdd to eliminate TOCTOU races

* Add unit tests for RepositoryCacheVersionService

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Andreas Zerbst <andr317c@live.dk>
2026-05-15 08:28:06 +09:00
Andy Butland f964a18b5b Merge branch 'release/17.5.0' of https://github.com/umbraco/Umbraco-CMS into release/17.5.0 2026-05-14 19:10:30 +02:00
Lee KelleherandAndy Butland 2369f00544 Mocks: Add missing signalR property to mock server configuration response (#22849)
Mocks: Add missing signalR property to mock server configuration response

The GetServerConfigurationResponse type was updated in #22700 to require
a signalR.skipNegotiation property, but the MSW mock handler was not
updated to match, causing a tsc compilation error.
2026-05-14 19:08:11 +02:00
Lee KelleherandGitHub 0f438c551c Mocks: Add missing signalR property to mock server configuration response (#22849)
Mocks: Add missing signalR property to mock server configuration response

The GetServerConfigurationResponse type was updated in #22700 to require
a signalR.skipNegotiation property, but the MSW mock handler was not
updated to match, causing a tsc compilation error.
2026-05-14 17:05:55 +00:00
Sebastiaan Janssen d822518dd3 Core: Preserve path case in ShadowFileSystem (#22838)
* Core: Preserve path case in ShadowFileSystem

ShadowFileSystem stored staged files at their original case via _sfs.AddFile
but tracked them under a lowercased key (NormPath calling ToLowerInvariant).
On Complete(), Inner.AddFile(kvp.Key, _sfs.GetFullPath(kvp.Key)) reconstructed
the staged file's path from the lowercased key, so on case-sensitive file
systems (Linux) File.Move failed with FileNotFoundException whenever a path
contained any uppercase character.

Drop the ToLowerInvariant from NormPath and switch the tracking dictionary
to StringComparer.OrdinalIgnoreCase. Lookups remain case-insensitive
(matching Windows semantics) while the stored key now matches what was
written to disk. IsChild/IsDescendant updated to OrdinalIgnoreCase
StartsWith for consistency.

Added regression test reproducing the original FileNotFoundException with
Views/PageNotFound.cshtml.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Rename regression test to follow Can_ naming convention

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Track canonical staged path per shadow node

The case-insensitive node dictionary preserved only the first inserted
key, so re-staging a logical path with a different case (e.g. AddFile
"Views/Foo.cshtml" then "views/foo.cshtml") wrote a phantom second file
to _sfs on Linux while Complete still resolved the original key — leaving
orphaned shadow files and committing stale content.

Track the original-case staged path on each ShadowNode and route all
_sfs operations (AddFile, OpenFile, GetFullPath, GetLastModified,
GetCreated, GetSize, MoveFile, Complete) through that canonical path.
Inner.AddFile on commit still uses the stored dictionary key, so the
destination case in the inner file system is unchanged.

Expanded the regression test to also exercise OpenFile, GetSize and
AddFile against a different-cased path, and to assert that the staged
file is written exactly once.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Make ShadowNode.CanonicalPath non-nullable

Every node now carries the original-case path it tracks, set at construction.
This removes the defensive 'sf.CanonicalPath ?? path' fallbacks at the read
sites (OpenFile, GetFullPath, GetLastModified, GetCreated, GetSize, Complete)
which were unreachable but noise.

The GetCanonicalPath helper is gone; AddFile and MoveFile now use the existing
node variable inline ('sf?.CanonicalPath ?? path' — node can legitimately be
null when staging a path for the first time).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Address Copilot review: normalize delete key, cross-platform test

DeleteDirectory(recursive=false) stored the deletion marker under the
caller-supplied path (which can contain backslashes) instead of the
normalized key, so a follow-up NormPath-based lookup could miss the
deletion and IsChild scans could become inconsistent. Use normPath.

The shadow-second-file assertion in the regression test used
File.Exists on a different-cased path; that returns true on
case-insensitive file systems (Windows / default macOS) regardless of
the actual stored case, so the assertion was platform-dependent.
Replaced it with a directory-count check that's cross-platform.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Add cross-platform regression test so any reversion would be caught on a non-case sensitive file system.

* Cleaned up warnings, obsoletions and comments in the existing tests.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Andy Butland <abutland73@gmail.com>
(cherry picked from commit abfa8cb144)
2026-05-14 10:42:20 +02:00
abfa8cb144 Core: Preserve path case in ShadowFileSystem (#22838)
* Core: Preserve path case in ShadowFileSystem

ShadowFileSystem stored staged files at their original case via _sfs.AddFile
but tracked them under a lowercased key (NormPath calling ToLowerInvariant).
On Complete(), Inner.AddFile(kvp.Key, _sfs.GetFullPath(kvp.Key)) reconstructed
the staged file's path from the lowercased key, so on case-sensitive file
systems (Linux) File.Move failed with FileNotFoundException whenever a path
contained any uppercase character.

Drop the ToLowerInvariant from NormPath and switch the tracking dictionary
to StringComparer.OrdinalIgnoreCase. Lookups remain case-insensitive
(matching Windows semantics) while the stored key now matches what was
written to disk. IsChild/IsDescendant updated to OrdinalIgnoreCase
StartsWith for consistency.

Added regression test reproducing the original FileNotFoundException with
Views/PageNotFound.cshtml.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Rename regression test to follow Can_ naming convention

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Track canonical staged path per shadow node

The case-insensitive node dictionary preserved only the first inserted
key, so re-staging a logical path with a different case (e.g. AddFile
"Views/Foo.cshtml" then "views/foo.cshtml") wrote a phantom second file
to _sfs on Linux while Complete still resolved the original key — leaving
orphaned shadow files and committing stale content.

Track the original-case staged path on each ShadowNode and route all
_sfs operations (AddFile, OpenFile, GetFullPath, GetLastModified,
GetCreated, GetSize, MoveFile, Complete) through that canonical path.
Inner.AddFile on commit still uses the stored dictionary key, so the
destination case in the inner file system is unchanged.

Expanded the regression test to also exercise OpenFile, GetSize and
AddFile against a different-cased path, and to assert that the staged
file is written exactly once.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Make ShadowNode.CanonicalPath non-nullable

Every node now carries the original-case path it tracks, set at construction.
This removes the defensive 'sf.CanonicalPath ?? path' fallbacks at the read
sites (OpenFile, GetFullPath, GetLastModified, GetCreated, GetSize, Complete)
which were unreachable but noise.

The GetCanonicalPath helper is gone; AddFile and MoveFile now use the existing
node variable inline ('sf?.CanonicalPath ?? path' — node can legitimately be
null when staging a path for the first time).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Address Copilot review: normalize delete key, cross-platform test

DeleteDirectory(recursive=false) stored the deletion marker under the
caller-supplied path (which can contain backslashes) instead of the
normalized key, so a follow-up NormPath-based lookup could miss the
deletion and IsChild scans could become inconsistent. Use normPath.

The shadow-second-file assertion in the regression test used
File.Exists on a different-cased path; that returns true on
case-insensitive file systems (Windows / default macOS) regardless of
the actual stored case, so the assertion was platform-dependent.
Replaced it with a directory-count check that's cross-platform.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Add cross-platform regression test so any reversion would be caught on a non-case sensitive file system.

* Cleaned up warnings, obsoletions and comments in the existing tests.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Andy Butland <abutland73@gmail.com>
2026-05-14 10:36:47 +02:00
Andy Butland 21ab470d88 Merge branch 'release/17.4.0' into release/17.5.0 2026-05-14 08:31:39 +02:00
Andy Butland 5dd8378c57 Merge branch 'release/17.4.0' into v17/dev 2026-05-14 08:30:01 +02:00
Mads RasmussenandGitHub 8e7440580a User: Delete unused custom table collection view (#22839)
delete unused user table code
2026-05-13 17:36:03 +02:00
Mads RasmussenandGitHub 358d435948 Member Group: Migrate custom table collection view to generic table kind (#22833)
* Migrate member group custom table to use table kind

* Use data-mark collection view selector for member group view
2026-05-13 17:04:22 +02:00
Andreas Lykke BorgandGitHub dcf1595e74 Accessibility: Added missing labels to code block copy button and embedded media URL input (#22825)
* Added label to copy button

* Added label to url input in editor

* Changed term to url

* Removed unnecessary readonly #localize

* Added copied translation key
2026-05-13 14:17:29 +00:00
0add0f5b18 Backoffice: Preserve user-supplied property editor UI group names (closes #22189) (#22196)
* Preserve user-supplied property editor UI group names.

* Add support for localised property editor groups, and use localised values for all core property editors.

* Fixed check to look for '#' as the first character of the provided group name.

* danish translation

---------

Co-authored-by: Niels Lyngsø <niels.lyngso@gmail.com>
Co-authored-by: Niels Lyngsø <nsl@umbraco.dk>
2026-05-13 11:29:51 +02:00
Ronald BarendseandAndy Butland 4921ab9257 SignalR: Mark ServerEventSender as a distributed cache notification handler (#22818)
* Mark ServerEventSender as distributed cache notification handler

* Batch and deduplicate notifications in ServerEventSender

* Introduce IDistributedCacheAsyncNotificationHandler<T> and use it in ServerEventSender

* Add ServerEventSender unit tests and address PR review feedback
2026-05-13 08:29:22 +02:00
Ronald BarendseandGitHub e07f188bd4 SignalR: Mark ServerEventSender as a distributed cache notification handler (#22818)
* Mark ServerEventSender as distributed cache notification handler

* Batch and deduplicate notifications in ServerEventSender

* Introduce IDistributedCacheAsyncNotificationHandler<T> and use it in ServerEventSender

* Add ServerEventSender unit tests and address PR review feedback
2026-05-13 08:26:50 +02:00
Ronald Barendseandleekelleher 63bae5958a User Permission: Re-export fallback condition config type and global augmentation (#22794)
(cherry picked from commit 55fec1dc2a)
2026-05-12 17:15:28 +01:00
Ronald BarendseandGitHub 55fec1dc2a User Permission: Re-export fallback condition config type and global augmentation (#22794) 2026-05-12 17:14:48 +01:00
Andy Butlandandleekelleher 35d726ad31 Sort Children: Show loading state on Sort button (closes #22651) (#22813)
* Add submit button state to sort dialog.

* Guard against re-entrant submit in sort-children-of modal.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Set failed button state when sort-children-of submit throws.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
(cherry picked from commit dfe93c5639)
2026-05-12 17:06:44 +01:00
dfe93c5639 Sort Children: Show loading state on Sort button (closes #22651) (#22813)
* Add submit button state to sort dialog.

* Guard against re-entrant submit in sort-children-of modal.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Set failed button state when sort-children-of submit throws.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 17:04:03 +01:00
Nhu DinhandGitHub 22a7a9577b Build: Updated nightly E2E test pipeline schedule in v17 (#22803)
Updated nightly E2E test pipeline schedule
2026-05-12 15:36:27 +07:00
Kenn JacobsenandAndy Butland fc9ca861b0 Content: Ensure correct variant change tracking when unpublishing variant content (#22799)
* Ensure correct change tracking when unpublishing

* Update src/Umbraco.Core/Services/PublishStatus/PublishStatusService.cs

Co-authored-by: Andy Butland <abutland73@gmail.com>

* Add comment

---------

Co-authored-by: Andy Butland <abutland73@gmail.com>
2026-05-12 10:01:20 +02:00
c784858b32 Content: Ensure correct variant change tracking when unpublishing variant content (#22799)
* Ensure correct change tracking when unpublishing

* Update src/Umbraco.Core/Services/PublishStatus/PublishStatusService.cs

Co-authored-by: Andy Butland <abutland73@gmail.com>

* Add comment

---------

Co-authored-by: Andy Butland <abutland73@gmail.com>
2026-05-12 09:58:20 +02:00
Sven Geusensandmole 68194e1a27 Distributed background jobs: Improve gracefull shutdown behaviour (#22796)
* Dont fail silently on missing ambientscope

This makes it in line with other methods in the repo

* Pass on Cancellationtoken to the job to support gracefull job shutdown

(cherry picked from commit 5ae17ace6a)
2026-05-12 09:51:25 +02:00
Sven GeusensandGitHub 5ae17ace6a Distributed background jobs: Improve gracefull shutdown behaviour (#22796)
* Dont fail silently on missing ambientscope

This makes it in line with other methods in the repo

* Pass on Cancellationtoken to the job to support gracefull job shutdown
2026-05-12 09:28:00 +02:00
Andy ButlandandGitHub cd476ab6ed Tiptap RTE: Ignore no-op transactions in onUpdate to prevent phantom dirty state (closes #22767) (#22781)
Ignore Tiptap no-op transactions in onUpdate to prevent phantom dirty state.
2026-05-11 14:13:21 +01:00
Niels Lyngsø 4b66c114c4 Revert "fix validation filter"
This reverts commit 0bdb1bb1ed.
2026-05-10 20:17:36 +02:00
Niels Lyngsø 0bdb1bb1ed fix validation filter 2026-05-10 20:16:23 +02:00
1c1787c445 Auth: Un-deprecate getLatestToken and route per-request fetches through it (#22736)
* Auth: un-deprecates getLatestToken and routes per-request fetches through it

getLatestToken is the only public API for "wait for any in-flight refresh,
trigger one if the access token has expired, then return". External and
internal consumers were warned off it without an equivalent replacement:
configureClient only helps @hey-api/openapi-ts clients, and consumers using
axios/ky/native fetch had no other gate.

- Removes the @deprecated JSDoc + UmbDeprecation.warn() call so the public
  surface no longer prints a console warning per call.
- Uses getLatestToken.bind(this) for the auth callback inside configureClient
  and the token callback inside getOpenApiConfiguration so both paths share
  the same #ensureTokenReady gate.
- Replaces the hard-coded `Authorization: Bearer [redacted]` in unlinkLogin
  and #makeLinkTokenRequest with `Bearer ${await getLatestToken()}` so those
  fetches participate in the refresh coordination rather than firing with a
  potentially-revoked cookie.

Also wires the UmbracoExtension template's entrypoint to call
authContext.configureClient(client), matching the v18 template change. The
framework awaits onInit, so this guarantees the API client is fully
configured before any element in the extension can use it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Auth: tightens UmbAuthContext correctness and accepts any hey-api client

Pulls in a batch of non-breaking improvements to UmbAuthContext that came
out of an audit on the back of the un-deprecation work in this PR:

Public surface:
- configureClient(client) now accepts a new structural UmbApiClient type
  (exported from @umbraco-cms/backoffice/http-client). Each @hey-api/openapi-ts
  generation produces a fully-bound Client<…>; the backoffice's umbHttpClient
  and an extension's regenerated client are structurally identical but TS
  treats them as distinct generic instantiations. The widened parameter lets
  extensions wire their own client without `as never` casts at call sites.
  bindDefaultInterceptors keeps its strict typeof umbHttpClient parameter
  (preserving autocomplete inside interceptor callbacks); the cast happens
  once, internally.

Correctness:
- The auth context now holds a single UmbApiInterceptorController, lazy-
  initialised on first configureClient() call. Previously each call
  instantiated a new controller, which re-provided the UmbAuthSignalerContext
  on the host and stacked listeners — visible the moment an extension also
  called configureClient. One controller for the lifetime of the host, all
  configured clients share it.
- completeAuthorizationRequest checks sessionStorage before asking
  window.opener for the PKCE verifier. The previous order hung for the full
  postMessage timeout whenever oauth_complete loaded with a non-OAuth
  window.opener (which is set for ANY window.open target). The opener
  postMessage timeout is also dropped from 5s to 1.5s — a real popup parent
  responds within milliseconds; longer is just wait time for the unrelated-
  opener case.
- The cross-tab 'authorized' BroadcastChannel handler now routes through
  #setSessionLocally so the timestamp math stays in one place. The
  'sessionUpdate' handler still applies pre-computed timestamps directly
  (peer broadcast already did the math) but does so inside the
  #inSessionUpdateCallback guard, so a synchronous session$ observer can no
  longer trigger a spurious /token refresh on top of a peer's update.
- #ensureTokenReady drops its query-then-request pattern. Now always queues
  behind the umb:token-refresh lock with a no-op callback — if the lock is
  free it acquires immediately, if held it waits. Eliminates the race window
  between query() and request().
- destroy() invokes #popupCleanup before tearing down so an in-flight popup
  flow's window-level message listener and closed-poll interval don't leak
  past the context's lifetime. The cleanup helper itself now resolves the
  popup-flow Promise — every termination path (authorized, popup closed,
  superseded by a new flow, context destroyed) is observable to the awaiter
  instead of hanging forever.

Cleanup:
- makeAuthorizationRequest is annotated Promise<void> so the redirect and
  popup branches share an explicit return type.
- unlinkLogin wraps the parsed problem-details payload in a real Error (with
  the original payload exposed on `.cause`) so callers using `instanceof
  Error` or expecting a stack trace get sane behaviour.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 10:58:19 +02:00
Andy Butland a434ad7b33 Published Content: Fix Fallback.ToAncestors with no match throwing exception at property level (closes #22759) (#22763)
* Fix Fallback.ToAncestors regression at property level.

* Further unit tests.
2026-05-08 10:19:00 +02:00
Andy Butland 3714ebbb29 Published Content: Fix Fallback.ToAncestors with no match throwing exception at property level (closes #22759) (#22763)
* Fix Fallback.ToAncestors regression at property level.

* Further unit tests.
2026-05-08 10:18:09 +02:00
Andy Butland 1214771847 Bump version to 17.6.0-rc. 2026-05-08 10:07:34 +02:00
Lee KelleherandGitHub 396497a921 Documents: Alias DocumentVariantStateModel API model for backoffice client (#22716)
* Client: Aliased `DocumentVariantStateModel` for documents and document-blueprints packages

Hoist `UmbDocumentVariantState` and `UmbDocumentBlueprintVariantState` aliases (re-exporting `DocumentVariantStateModel`) into dedicated `variant-state.ts` leaf files. Internal package modules, mocks and the core split-view selector now consume the alias instead of referencing `DocumentVariantStateModel` directly, mirroring the structure on `v18/dev` to reduce upstream-merge conflicts.

* Revert mock data changes

to prevent importing the whole "document" module.

* Tweaked the `DocumentVariantStateModel` import for mock data

Otherwise this is problematic for cherry-picked commits for v18.0.

* Missed one!
2026-05-08 08:04:07 +00:00
Niels Lyngsø bf32f9e5a6 update package-lock 2026-05-08 09:01:20 +02:00
Niels Lyngsø 3220739faa upgrade to UI LIbrary 1.17.3 2026-05-08 08:59:49 +02:00
Niels Lyngsø ff565b95e0 update package-lock 2026-05-08 08:54:28 +02:00
Niels Lyngsø 93a1f82b05 Merge branch 'release/17.4.0'
# Conflicts:
#	src/Umbraco.Web.UI.Client/package-lock.json
#	src/Umbraco.Web.UI.Client/package.json
#	tests/Umbraco.Tests.AcceptanceTest/package-lock.json
#	tests/Umbraco.Tests.AcceptanceTest/package.json
#	version.json
2026-05-08 08:53:57 +02:00
Andreas ZerbstandGitHub 54ded689e8 E2E: QA: Add .prettierrc.json to acceptance tests for formatting consistency (#22751)
Add .prettierrc.json to acceptance tests for formatting consistency
2026-05-08 09:19:26 +07:00
Andy ButlandandGitHub 2292b7479d Color Picker: Refresh stored label when data type label changes (closes #22741) (#22761)
* Update stored color label if changed on save of document with color picker.

* Clarify intent of change event dispatch in label sync

* Make comparison case insensitive.

* Added unit tests for new behaviour.
2026-05-07 21:13:05 +02:00
Andy ButlandandGitHub 9b1fc50de3 Dictionary: Order SQL before FetchOneToMany to prevent duplicate items in collection view (closes #22640) (#22750)
* Order SQL before FetchOneToMany in dictionary entry retrieval to prevent duplicate items in collection view.

* Used PrimaryKey instead of UniqueId to take advantage of the clustered index.
2026-05-07 14:29:43 +00:00
3052b57203 E2E: QA: add acceptance tests for content versioning (#22702)
* Added tests

* Updated

* Cleaned up

* Fixes based on comments

* Apply suggestions from code review

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Added helpers for verifying document

* Removed redundant method

* Cleaned up

* Reverted deletion of constants

* Undo revert

* Fixes based on comments

* updated command

* Added removed method

* Update smokeTest command in package.json

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-07 10:13:29 +00:00
aba8e3eb7a Icons: developer icon manager (#22437)
* extend icons with information from theseaurus

* implement new icon search logic

* clean-up data

* icon manager

* sorting with a backup of the name

* refactor into a controller

* improve multi word group search

* embed lucide data

* rename tech into technology

* remove paper from dollar

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* improve search

* related should not show up in search

* update threshold

* separate name words

* also consider full icon name match

* better comment

* other approach for full name matches

* full icon name search if query contains a -

* fix test

* remove related code

* updates to related

* make its own package

* revert changes

* update tsconfig

* package-lock

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-07 10:11:05 +00:00
Mads RasmussenandGitHub 040a0d5e49 Document Workspace: Add CRUD and property value tests for document workspace context (#22621)
* temp mock set

* test getPropertyValue

* Extend document workspace context tests to cover read/write property values

* move context files into context folder

* Add document CRUD tests, mock handler & interceptor

* temp mock error interceptor

* Return 404 when document not found

* Use undefined for entity unique state until initialized

* Fix import paths for document workspace editor

* Add test utils and extend document workspace tests

* Update document-workspace-context.test-utils.ts

* Match invariant variant when variantId missing

* Ensure finishPropertyValueChange runs on exit

Wrap setPropertyValue implementation in a try/finally and move finishPropertyValueChange into the finally block so cleanup always runs even if an error is thrown. No other functional changes — code was re-indented and organized but behavior remains the same except for guaranteed cleanup on error.

* Require variantId for culture/segment-variant props

* fix types

* fix mock modal typescript error

* Distinguish unloaded vs root entity unique

* use the real current user context

* hide mock set in UI

* rename mock set

* Move initiatePropertyValueChange into try

* Use 'satisfies' for UmbMockDataSet assertions

* Preserve requested unique on failed load

* Treat missing variantId as invariant

* Reset update lock on destroy

* remove unused group + user

* Guard _current.unmute and remove destroy override

* Add tests for element data manager

* Guard subject access and add destroy test

* Throw when calling methods after destroy
2026-05-07 09:36:05 +02:00
2943 changed files with 100637 additions and 127419 deletions
+135
View File
@@ -0,0 +1,135 @@
---
name: umb-release-notes
description: Improve a set of auto-generated GitHub release notes for an Umbraco CMS release. Cross-checks the notes against every PR carrying the release label, adds any that are missing, re-files every PR under the most appropriate category, and strips purely-internal entries. Use whenever the user asks to tidy up, improve, complete, or recategorize release notes for a given version, or mentions a release-notes text file plus a version number.
argument-hint: <version> <path-to-generated-notes-file>
---
# Umbraco CMS - Improve Release Notes
Takes a file of auto-generated GitHub release notes and produces an improved version that:
1. **Is complete** — every merged PR carrying the `release/<version>` label appears.
2. **Is well-categorized** — every PR sits under the most appropriate heading.
3. **Is free of noise** — purely-internal entries of no value to a reader are removed.
The result is written to a **new** file alongside the input, so the user can diff the two.
**Run autonomously.** Do NOT use `AskUserQuestion` once the required arguments (version and input file path) are available — only ask if one of them is missing from `$ARGUMENTS` and cannot be inferred (see Arguments). Beyond that, make the categorization calls yourself using the rules below; if a handful are genuinely borderline, place them anyway and note the borderline ones in your closing summary so the user can override.
## Arguments
`$ARGUMENTS` contains two values:
1. **Version** — e.g. `17.5.0`, `18.1.0`. The GitHub label to search is `release/<version>` (so version `17.5.0` → label `release/17.5.0`).
2. **Input file path** — full path to the text file holding the auto-generated notes (e.g. `C:\Temp\release-17.5.0-rc.md`).
If either is missing, ask the user once for the missing value, then proceed.
## Prerequisites
Run `gh auth status`. If it fails, tell the user to authenticate `gh` (e.g. `gh auth login`) and stop — the skill needs the GitHub CLI to query PRs. The repo is always `umbraco/Umbraco-CMS`.
## Procedure
### 1. Read the input notes
Read the input file. Note its structure — it is GitHub's generated format:
- A leading HTML comment (`<!-- Release notes generated ... -->`).
- A `## What's Changed` heading followed by `### <emoji> <Category>` sub-headings, each with `* <title> by @<author> in <url>` bullets.
- A trailing `## New Contributors` section and a `**Full Changelog**: ...` line.
Extract the set of PR numbers already present (parse the `/pull/<number>` from each bullet). Preserve each existing bullet's **exact text** (title, author, URL) when you re-emit it — only its category placement may change.
### 2. Fetch every labelled PR
```bash
gh pr list --repo umbraco/Umbraco-CMS --label "release/<version>" --state closed --limit 1000 \
--json number,title,author,labels,mergedAt \
--jq '.[] | select(.mergedAt != null) | "\(.number)\t\(.author.login)\t\([.labels[].name] | join(", "))\t\(.title)"' | sort -n
```
This is the authoritative list of what the release *should* contain. Each row gives number, author, labels, title.
**Guard against silent truncation.** `gh pr list` caps at `--limit` without warning, so a large release could drop the overflow and the skill would still look "complete". Count the returned rows and compare against the limit:
```bash
gh pr list --repo umbraco/Umbraco-CMS --label "release/<version>" --state closed --limit 1000 --json number --jq 'length'
```
If this equals 1000, the limit was hit — raise `--limit` and re-fetch before continuing. Do **not** proceed on a truncated list.
### 3. Reconcile
- **Missing labelled PRs** (labelled but not in the input file): these must be **added**. Build a bullet as `* <title> by @<author> in https://github.com/umbraco/Umbraco-CMS/pull/<number>`.
- **Author handle.** `<author>` in the template is the raw `.author.login` value — the bullet supplies the leading `@`, so do not prepend another. `gh`'s `.author.login` already returns bot accounts with the `[bot]` suffix as part of the login — Dependabot comes back as `dependabot[bot]`, not `dependabot` or `app/dependabot` (the `app/` form only appears in git committer metadata and CODEOWNERS, never in `gh`'s JSON). So the login is already in the right shape; use it verbatim (e.g. `.author.login` of `dependabot[bot]` renders as `@dependabot[bot]`, matching what GitHub's generator wrote for the existing bullets). The only thing to guard against is accidentally stripping or altering the `[bot]` suffix.
- **PRs in the file but not labelled**: keep them. The generated notes span a commit range (see the `Full Changelog` compare link), so they legitimately include backports / earlier-version PRs that lack the current label. For any of these you need to categorize, fetch its labels with:
```bash
gh pr view <number> --repo umbraco/Umbraco-CMS --json number,title,labels \
--jq '"\(.number)\t\([.labels[].name] | join(", "))\t\(.title)"'
```
Do **not** invent or alter the `New Contributors` section — carry it over verbatim. You cannot reliably recompute first-time contributors, so leave it as the generator produced it (mention this in the summary).
### 4. Categorize every PR
Use exactly these headings, in this order. Omit any heading that ends up with no entries.
| Heading | What goes here | Primary signal |
|---|---|---|
| `### 🙌 Notable Changes` | **Don't recategorize existing entries.** Label-driven — but still add any missing PR carrying this label here. | label `category/notable` |
| `### 💥 Breaking Changes` | **Don't recategorize existing entries.** Label-driven — but still add any missing PR carrying this label here. | label `category/breaking` |
| `### 📦 Dependencies` | Dependency bumps | label `dependencies`; or dependabot author |
| `### 🚀 New Features` | New user- or developer-facing capability | label `type/feature` / `category/feature`; or title introduces/adds a genuinely new capability |
| `### 🚤 Performance` | Performance improvements | label `category/performance`; or `Performance:` title prefix |
| `### 🌈 Accessibility Improvements` | A11y improvements (labels, contrast, keyboard) | label `category/accessibility` / `accessibility`; or clear a11y intent (e.g. "improve contrast", "missing labels") |
| `### 🐛 Bug Fixes` | Fixes to broken/incorrect behaviour | default for anything describing a fix |
| `### 🧪 Testing` | Test additions/changes only | label `category/test-automation` / `area/test`; or `E2E`/`QA`/"acceptance tests"/"unit test coverage"/"add tests" titles |
| `### 🛡️ Code Quality, Documentation and Refactoring` | Refactors, deprecations, API tidy-ups, XML/MD documentation, knowledge-base (`MD`) updates | label `category/refactor`; or titles about refactoring, deprecating, renaming, documenting, constants extraction, MD/CLAUDE.md content |
| `### 🧑‍💻 Developer Experience` | Things that improve the experience of developers building on or contributing to Umbraco — dev tooling, build/watch ergonomics, test mocks/harnesses, backoffice dev utilities | `Developer Experience` title prefix; dev tooling; mock/harness changes |
**Rules:**
- **Notable and Breaking are off-limits for recategorization** — never move a PR that is *already in the input file* into or out of these sections; they are driven purely by their labels and the generator placed them correctly. This does **not** exempt them from completeness: a PR discovered as missing in step 3 that carries `category/notable` or `category/breaking` must still be **added** under the matching section.
- Label signals beat title wording, except a `Performance:`/`Developer Experience:` title prefix is decisive for its section.
- A PR with both `type/feature` and `category/refactor` whose title clearly describes a refactor (e.g. "swap relative imports", "re-export type") belongs under Code Quality, not New Features.
- "Add ... tests"/"unit test coverage" → Testing, even if it also touches docs. If a PR adds XML documentation *and* tests, lead with where the title's emphasis lies (documentation → Code Quality; test coverage → Testing).
- When a PR is genuinely 50/50, pick the more reader-useful heading and list it in your closing summary as borderline.
### 5. Remove purely-internal noise
Drop entries that have **no value to anyone reading release notes** — pure repository plumbing with no shipped impact. Examples:
- Branch/merge maintenance ("Fix main branch after merge issue").
- CI/pipeline fixes that don't change the product.
- Reverts of changes that never shipped in a release.
**Keep** anything that ships in the product or genuinely helps developers building on Umbraco — that includes documentation/MD updates, dev tooling, and test mocks (those go to Code Quality or Developer Experience, they are *not* noise). When unsure whether something is noise, keep it and flag it in the summary rather than silently dropping it. List every removal in your closing summary.
### 6. Write the output
Write to a new file in the **same folder** as the input, named by appending ` - with updates` before the extension:
- Input `C:\Temp\release-17.5.0-rc.md` → Output `C:\Temp\release-17.5.0-rc - with updates.md`
Preserve the leading HTML comment, the `## What's Changed` heading, the `## New Contributors` section, and the `**Full Changelog**` line exactly. Only the `### <category>` groupings and their bullets change.
### 7. Report
Give a concise summary:
- Count of PRs added (with their numbers), and which categories they landed in.
- Notable recategorizations (PRs moved out of the catch-all Bug Fixes into Features/Performance/Testing/etc.).
- Every entry removed, with the one-line reason.
- Any borderline calls the user may want to override.
- The output file path.
## Verification
Before reporting done, confirm:
- Every PR number from step 2 is present in the output (except any you deliberately removed in step 5 — and those must be in the removal list).
- No PR appears under more than one heading.
- Notable and Breaking sections are byte-for-byte unchanged from the input.
- The header comment, New Contributors, and Full Changelog lines are intact.
-1
View File
@@ -59,5 +59,4 @@
# Generated files - hidden by default in GitHub diffs
src/Umbraco.Web.UI.Client/src/packages/core/backend-api/** linguist-generated
src/Umbraco.Web.UI.Login/src/api/** linguist-generated
templates/UmbracoExtension/Client/src/api/** linguist-generated
src/Umbraco.Cms.Api.Management/OpenApi.json linguist-generated
-1
View File
@@ -120,4 +120,3 @@ trace.zip
/tests/Umbraco.Tests.Integration/appsettings-schema.*.json
/tests/Umbraco.Tests.Integration/umbraco-package-schema.json
/src/Umbraco.Cms/appsettings-schema.json
.playwright-mcp/
+23 -18
View File
@@ -46,8 +46,7 @@ Enterprise-grade CMS built on .NET 10.0. This repository contains 21 production
- **ASP.NET Core** - Web framework
- **Entity Framework Core** - Modern ORM
- **OpenIddict** - OAuth 2.0/OpenID Connect authentication
- **Microsoft.AspNetCore.OpenApi** - OpenAPI document generation
- **Swashbuckle.AspNetCore.SwaggerUI** - Swagger UI for API documentation
- **Swashbuckle** - OpenAPI/Swagger documentation
- **Lucene.NET** - Full-text search via Examine
- **ImageSharp** - Image processing
@@ -367,27 +366,16 @@ public interface IMyService
### Centralized Package Management
**NuGet package versions** are centralized in `Directory.Packages.props`. There are two `Directory.Packages.props` files in the source tree, with multi-level merging enabled so the test file inherits from the root:
| File | Scope |
|------|-------|
| `Directory.Packages.props` (root) | Production source code packages — referenced by all `src/**` projects |
| `tests/Directory.Packages.props` | Test-only packages (NUnit, Moq, Bogus, BenchmarkDotNet, etc.) — adds entries on top of the inherited root file |
When updating dependencies, decide which file the package belongs in:
- A package used only by test projects → `tests/Directory.Packages.props`
- A package used by any production project (or by both production and tests) → root `Directory.Packages.props`
**All NuGet package versions** are centralized in `Directory.Packages.props`. Individual projects do NOT specify versions.
```xml
<!-- Individual projects reference WITHOUT version -->
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
<PackageReference Include="Swashbuckle.AspNetCore" />
<!-- Versions defined in Directory.Packages.props -->
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0" />
<PackageVersion Include="Swashbuckle.AspNetCore" Version="6.5.0" />
```
**Opt-out**: `src/Umbraco.Web.UI/Umbraco.Web.UI.csproj` sets `<ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally>` and specifies versions inline (for `Microsoft.EntityFrameworkCore.Design`, `Microsoft.Build.Tasks.Core`, `Microsoft.ICU.ICU4C.Runtime`, etc.). Update those versions directly in that csproj when bumping. Two further `Directory.Packages.props` files exist under `templates/` for the project/extension templates and have their own version sets — keep `Microsoft.AspNetCore.OpenApi` aligned between the root file and `templates/UmbracoExtension/`.
### Build Configuration
- `Directory.Build.props` - Shared properties (target framework, company, copyright)
@@ -431,8 +419,7 @@ All APIs use **OpenIddict** (OAuth 2.0/OpenID Connect):
APIs use `Asp.Versioning.Mvc`:
- Management API: `/umbraco/management/api/v{version}/*`
- Delivery API: `/umbraco/delivery/api/v{version}/*`
- OpenAPI docs: `/umbraco/openapi/management.json`, `/umbraco/openapi/delivery.json`
- Swagger UI: `/umbraco/openapi/`
- OpenAPI/Swagger docs per version
### Updating `OpenApi.json` (Management API)
@@ -448,6 +435,14 @@ When a PR changes Management API controllers or models, the `OpenApi.json` file
The backoffice is published to npm as `@umbraco-cms/backoffice`. Runtime dependencies are provided via importmap; npm peerDependencies provide types only. For full details on dependency hoisting, version range logic, and plugin development, see `/src/Umbraco.Web.UI.Client/CLAUDE.md` → "npm Package Publishing".
### SQL Server 2100-parameter limit
Any `WHERE IN (@0, @1, ...)` built from a runtime-sized collection risks hitting SQL Server's 2100-parameter ceiling and throwing `SqlException` 8003 in production.
Batch with `IEnumerable<T>.InGroupsOf(Constants.Sql.MaxParameterCount)` or `Database.FetchByGroups(...)` whenever the collection size is driven by user data — not just when it currently fits. Watch for products of two scaling dimensions (documents × languages, properties × versions) and config-tunable batch sizes whose defaults are safe but ceilings aren't.
Full guidance, safe patterns and decision rule: see `/src/Umbraco.Infrastructure/CLAUDE.md` → "Avoiding the SQL Server 2100-parameter limit".
### Known Limitations
1. **Circular Dependencies**: Avoided via `Lazy<T>` or event notifications
@@ -544,6 +539,16 @@ Allowed, but cheap to write and cheaper to leave behind. Keep them short and tra
---
## 9. Testing Practices
### Tests for a bug fix must fail before the fix
Verify any test you add for a bug fix actually catches the bug: either write the failing test first (TDD), or temporarily revert the production change and confirm the test fails before re-applying. A test that passes both ways proves nothing. Watch for coincidental passes — default seed/sort orders can make a buggy path produce the right answer for the test's specific inputs; construct inputs so the broken and fixed behaviours give visibly different results.
For integration tests that exercise caching or cache refreshers, see `tests/Umbraco.Tests.Integration/CLAUDE.md` — the harness disables caching by default, which can produce false greens.
---
## Quick Reference
### Essential Commands
+2 -12
View File
@@ -40,8 +40,8 @@
<!-- Package Validation -->
<PropertyGroup>
<GenerateCompatibilitySuppressionFile>false</GenerateCompatibilitySuppressionFile>
<EnablePackageValidation>false</EnablePackageValidation> <!-- TODO (V18): Set to true once this version is released. -->
<PackageValidationBaselineVersion>18.0.0</PackageValidationBaselineVersion>
<EnablePackageValidation>true</EnablePackageValidation>
<PackageValidationBaselineVersion>17.0.0</PackageValidationBaselineVersion>
<EnableStrictModeForCompatibleFrameworksInPackage>true</EnableStrictModeForCompatibleFrameworksInPackage>
<EnableStrictModeForCompatibleTfms>true</EnableStrictModeForCompatibleTfms>
</PropertyGroup>
@@ -64,14 +64,4 @@
</_ProjectReferencesWithVersions>
</ItemGroup>
</Target>
<!-- Workaround for https://github.com/umbraco/Umbraco-CMS/issues/23018
Due to the amount of XML documentation in this solution, the OpenAPI XML documentation source generator produces
too many lines of code causing a StackOverflowException when running on IIS. For that reason we disable the analyzer.
See https://learn.microsoft.com/en-us/aspnet/core/fundamentals/openapi/openapi-comments?view=aspnetcore-10.0#disabling-xml-documentation-support -->
<Target Name="DisableCompileTimeOpenApiXmlGenerator" BeforeTargets="CoreCompile" Condition="'$(IsPackable)' != 'false' or '$(IsTestProject)' == 'true'">
<ItemGroup>
<Analyzer Remove="@(Analyzer)" Condition="'%(Filename)' == 'Microsoft.AspNetCore.OpenApi.SourceGenerators'" />
</ItemGroup>
</Target>
</Project>
+37 -40
View File
@@ -8,37 +8,33 @@
<ItemGroup>
<GlobalPackageReference Include="Nerdbank.GitVersioning" Version="3.9.50" />
<GlobalPackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.556" />
<!-- TODO (V18): Bump Umbraco.Code to 3.0.0 stable before release of 18.0.0 -->
<GlobalPackageReference Include="Umbraco.Code" Version="3.0.0-beta" />
<GlobalPackageReference Include="Umbraco.Code" Version="2.4.0" />
<GlobalPackageReference Include="Umbraco.GitVersioning.Extensions" Version="0.2.0" />
</ItemGroup>
<!-- Microsoft packages -->
<ItemGroup>
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation" Version="10.0.7" />
<!-- When updating this version, also update templates/UmbracoExtension/Umbraco.Extension.csproj -->
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.7" />
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="5.3.0" />
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="5.3.0" />
<PackageVersion Include="Microsoft.Data.Sqlite" Version="10.0.7" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.7" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.7" />
<PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.7" />
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.7" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.7" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="10.0.7" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.7" />
<PackageVersion Include="Microsoft.Extensions.FileProviders.Embedded" Version="10.0.7" />
<PackageVersion Include="Microsoft.Extensions.FileProviders.Physical" Version="10.0.7" />
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.7" />
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.7" />
<PackageVersion Include="Microsoft.Extensions.Identity.Core" Version="10.0.7" />
<PackageVersion Include="Microsoft.Extensions.Identity.Stores" Version="10.0.7" />
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.7" />
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.7" />
<PackageVersion Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.7" />
<PackageVersion Include="Microsoft.Extensions.Options.DataAnnotations" Version="10.0.7" />
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation" Version="10.0.6" />
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="4.14.0" />
<PackageVersion Include="Microsoft.Data.Sqlite" Version="10.0.6" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.6" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.6" />
<PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.6" />
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.6" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.6" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="10.0.6" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.6" />
<PackageVersion Include="Microsoft.Extensions.FileProviders.Embedded" Version="10.0.6" />
<PackageVersion Include="Microsoft.Extensions.FileProviders.Physical" Version="10.0.6" />
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.6" />
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.6" />
<PackageVersion Include="Microsoft.Extensions.Identity.Core" Version="10.0.6" />
<PackageVersion Include="Microsoft.Extensions.Identity.Stores" Version="10.0.6" />
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.6" />
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.6" />
<PackageVersion Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.6" />
<PackageVersion Include="Microsoft.Extensions.Options.DataAnnotations" Version="10.0.6" />
<PackageVersion Include="Microsoft.Extensions.Caching.Hybrid" Version="10.5.0" />
<PackageVersion Include="System.Linq.Async" Version="7.0.1" />
<PackageVersion Include="System.Linq.Async" Version="7.0.0" />
</ItemGroup>
<!-- Umbraco packages -->
<ItemGroup>
@@ -46,16 +42,16 @@
</ItemGroup>
<!-- Third-party packages -->
<ItemGroup>
<PackageVersion Include="Asp.Versioning.Mvc" Version="10.0.0" />
<PackageVersion Include="Asp.Versioning.Mvc.ApiExplorer" Version="10.0.0" />
<PackageVersion Include="Asp.Versioning.Mvc" Version="8.1.1" />
<PackageVersion Include="Asp.Versioning.Mvc.ApiExplorer" Version="8.1.1" />
<PackageVersion Include="Dazinator.Extensions.FileProviders" Version="2.0.0" />
<PackageVersion Include="Examine" Version="3.7.1" />
<PackageVersion Include="Examine.Core" Version="3.7.1" />
<PackageVersion Include="Examine" Version="3.8.0" />
<PackageVersion Include="Examine.Core" Version="3.8.0" />
<PackageVersion Include="HtmlAgilityPack" Version="1.12.4" />
<PackageVersion Include="JsonPatch.Net" Version="3.3.0" />
<PackageVersion Include="K4os.Compression.LZ4" Version="1.3.8" />
<PackageVersion Include="MailKit" Version="4.16.0" />
<PackageVersion Include="Markdig" Version="1.1.3" />
<PackageVersion Include="Markdig" Version="0.45.0" />
<PackageVersion Include="Markdown" Version="2.2.1" />
<PackageVersion Include="MessagePack" Version="3.1.7" />
<PackageVersion Include="MiniProfiler.AspNetCore.Mvc" Version="4.5.4" />
@@ -63,24 +59,25 @@
<PackageVersion Include="ncrontab" Version="3.4.0" />
<PackageVersion Include="NPoco" Version="6.2.0" />
<PackageVersion Include="NPoco.SqlServer" Version="6.2.0" />
<PackageVersion Include="OpenIddict.Abstractions" Version="7.5.0" />
<PackageVersion Include="OpenIddict.AspNetCore" Version="7.5.0" />
<PackageVersion Include="OpenIddict.EntityFrameworkCore" Version="7.5.0" />
<PackageVersion Include="OpenIddict.Abstractions" Version="7.4.0" />
<PackageVersion Include="OpenIddict.AspNetCore" Version="7.4.0" />
<PackageVersion Include="OpenIddict.EntityFrameworkCore" Version="7.4.0" />
<PackageVersion Include="Serilog" Version="4.3.1" />
<PackageVersion Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageVersion Include="Serilog.AspNetCore" Version="9.0.0" />
<PackageVersion Include="Serilog.Enrichers.Process" Version="3.0.0" />
<PackageVersion Include="Serilog.Enrichers.Thread" Version="4.0.0" />
<PackageVersion Include="Serilog.Expressions" Version="5.0.0" />
<PackageVersion Include="Serilog.Extensions.Hosting" Version="10.0.0" />
<PackageVersion Include="Serilog.Extensions.Hosting" Version="9.0.0" />
<PackageVersion Include="Serilog.Formatting.Compact" Version="3.0.0" />
<PackageVersion Include="Serilog.Formatting.Compact.Reader" Version="4.0.0" />
<PackageVersion Include="Serilog.Settings.Configuration" Version="10.0.0" />
<PackageVersion Include="Serilog.Settings.Configuration" Version="9.0.0" />
<PackageVersion Include="Serilog.Sinks.Async" Version="2.1.0" />
<PackageVersion Include="Serilog.Sinks.File" Version="7.0.0" />
<PackageVersion Include="Serilog.Sinks.Map" Version="2.0.0" />
<PackageVersion Include="SixLabors.ImageSharp" Version="3.1.12" />
<PackageVersion Include="SixLabors.ImageSharp.Web" Version="3.2.0" />
<PackageVersion Include="Swashbuckle.AspNetCore.SwaggerUI" Version="10.1.7" />
<!-- When updating this version, also update templates/UmbracoExtension/Umbraco.Extension.csproj -->
<PackageVersion Include="Swashbuckle.AspNetCore" Version="10.1.7" />
</ItemGroup>
<!-- Transitive pinned versions (only required because our direct dependencies have vulnerable versions of transitive dependencies) -->
<ItemGroup>
@@ -93,6 +90,6 @@
<PackageVersion Include="System.Text.RegularExpressions" Version="4.3.1" />
<!-- Examine (via Microsoft.AspNetCore.DataProtection 8.0.4) references a vulnerable version of the following: -->
<!-- TODO: Remove this pinned dependency when Examine updates its Microsoft.AspNetCore.DataProtection reference. -->
<PackageVersion Include="System.Security.Cryptography.Xml" Version="10.0.7" />
<PackageVersion Include="System.Security.Cryptography.Xml" Version="10.0.6" />
</ItemGroup>
</Project>
</Project>
+10 -16
View File
@@ -4,11 +4,11 @@ pr: none
trigger: none
schedules:
- cron: '0 6 * * *'
displayName: Daily 6AM build (v18/dev)
- cron: '0 3 * * *'
displayName: Daily 3AM build (v17/dev)
branches:
include:
- v18/dev
- v17/dev
parameters:
- name: skipIntegrationTests
@@ -199,37 +199,31 @@ stages:
SA_PASSWORD: UmbracoAcceptance123!
strategy:
matrix:
# Windows is split into 5 parts (ManagementApi split in two to avoid memory pressure on LocalDb); Linux into 4.
WindowsPart1Of5:
# We split the tests into 4 parts for each OS to reduce the time it takes to run them on the pipeline
WindowsPart1Of4:
vmImage: "windows-latest"
Tests__Database__DatabaseType: LocalDb
Tests__Database__SQLServerMasterConnectionString: N/A
# Filter tests that are part of the Umbraco.Infrastructure namespace but not part of the Umbraco.Infrastructure.Service namespace
testFilter: "(FullyQualifiedName~Umbraco.Infrastructure) & (FullyQualifiedName!~Umbraco.Infrastructure.Service)"
WindowsPart2Of5:
WindowsPart2Of4:
vmImage: "windows-latest"
Tests__Database__DatabaseType: LocalDb
Tests__Database__SQLServerMasterConnectionString: N/A
# Filter tests that are part of the Umbraco.Infrastructure.Service namespace
testFilter: "(FullyQualifiedName~Umbraco.Infrastructure.Service)"
WindowsPart3Of5:
WindowsPart3Of4:
vmImage: "windows-latest"
Tests__Database__DatabaseType: LocalDb
Tests__Database__SQLServerMasterConnectionString: N/A
# Filter tests that are not part of the Umbraco.Infrastructure and ManagementApi namespace.
testFilter: "(FullyQualifiedName!~Umbraco.Infrastructure) & (FullyQualifiedName!~ManagementApi)"
WindowsPart4Of5:
WindowsPart4Of4:
vmImage: "windows-latest"
Tests__Database__DatabaseType: LocalDb
Tests__Database__SQLServerMasterConnectionString: N/A
# ManagementApi, heavier sub-namespaces. Trailing dots prevent "User." from matching "UserGroup." etc.
testFilter: "FullyQualifiedName~ManagementApi & (FullyQualifiedName~ManagementApi.Element. | FullyQualifiedName~ManagementApi.User. | FullyQualifiedName~ManagementApi.Document. | FullyQualifiedName~ManagementApi.DataType. | FullyQualifiedName~ManagementApi.DocumentType. | FullyQualifiedName~ManagementApi.MediaType. | FullyQualifiedName~ManagementApi.Template.)"
WindowsPart5Of5:
vmImage: "windows-latest"
Tests__Database__DatabaseType: LocalDb
Tests__Database__SQLServerMasterConnectionString: N/A
# ManagementApi, remainder (complement of Part4). vstest filters do not support group
testFilter: "FullyQualifiedName~ManagementApi & FullyQualifiedName!~ManagementApi.Element. & FullyQualifiedName!~ManagementApi.User. & FullyQualifiedName!~ManagementApi.Document. & FullyQualifiedName!~ManagementApi.DataType. & FullyQualifiedName!~ManagementApi.DocumentType. & FullyQualifiedName!~ManagementApi.MediaType. & FullyQualifiedName!~ManagementApi.Template."
# Filter tests that are part of the ManagementApi namespace.
testFilter: "(FullyQualifiedName~ManagementApi)"
LinuxPart1Of4:
vmImage: "ubuntu-latest"
Tests__Database__DatabaseType: SqlServer
+70 -42
View File
@@ -13,8 +13,7 @@ Shared infrastructure for Umbraco CMS REST APIs (Management and Delivery).
### Key Technologies
- **ASP.NET Core** - Web framework
- **Microsoft.AspNetCore.OpenApi** - OpenAPI document generation
- **Swashbuckle.AspNetCore.SwaggerUI** - Swagger UI for browsing API documentation
- **Swashbuckle** - OpenAPI/Swagger documentation generation
- **OpenIddict** - OAuth 2.0/OpenID Connect authentication
- **Asp.Versioning** - API versioning
- **System.Text.Json** - Polymorphic JSON serialization
@@ -28,18 +27,14 @@ Shared infrastructure for Umbraco CMS REST APIs (Management and Delivery).
```
Umbraco.Cms.Api.Common/
├── OpenApi/ # OpenAPI transformers and schema generators
│ ├── UmbracoSchemaIdGenerator.cs # Generates schema IDs (e.g., "PagedUserModel")
│ ├── UmbracoOperationIdTransformer.cs # Generates operation IDs
── SortTagsAndPathsTransformer.cs # Sorts OpenAPI tags and paths
│ ├── TagActionsByGroupNameTransformer.cs # Tags operations by controller group
│ ├── FixFileReturnTypesTransformer.cs # Fixes file return type schemas
│ ├── RequireNonNullablePropertiesSchemaTransformer.cs # Schema nullability
│ └── OpenApiRouteTemplatePipelineFilter.cs # Adds OpenAPI endpoints
├── OpenApi/ # Schema/Operation ID handlers for Swagger
│ ├── SchemaIdHandler.cs # Generates schema IDs (e.g., "PagedUserModel")
│ ├── OperationIdHandler.cs # Generates operation IDs
── SubTypesHandler.cs # Polymorphism support
├── Serialization/ # JSON type resolution
│ └── UmbracoJsonTypeInfoResolver.cs
├── Configuration/ # Options configuration
│ ├── ConfigureUmbracoOpenApiOptionsBase.cs
│ ├── ConfigureUmbracoSwaggerGenOptions.cs
│ └── ConfigureOpenIddict.cs
├── DependencyInjection/ # Service registration
│ ├── UmbracoBuilderApiExtensions.cs
@@ -52,8 +47,9 @@ Umbraco.Cms.Api.Common/
### Design Patterns
1. **Builder Pattern** - `ProblemDetailsBuilder` for fluent error responses
2. **Options Pattern** - All configuration via `IConfigureOptions<T>`
1. **Strategy Pattern** - `ISchemaIdHandler`, `IOperationIdHandler` (extensible via inheritance)
2. **Builder Pattern** - `ProblemDetailsBuilder` for fluent error responses
3. **Options Pattern** - All configuration via `IConfigureOptions<T>`
---
@@ -65,12 +61,25 @@ See "Quick Reference" section at bottom for common commands.
## 3. Key Patterns
### Schema ID Generation (OpenApi/UmbracoSchemaIdGenerator.cs)
### Virtual Handlers for Extensibility
Static utility class that generates OpenAPI schema IDs following Umbraco's naming conventions:
Handlers are intentionally virtual to allow consuming APIs to override:
```csharp
// Add "Model" suffix to avoid TypeScript name clashes
// NOTE: Left unsealed on purpose, so it is extendable.
public class SchemaIdHandler : ISchemaIdHandler
{
public virtual bool CanHandle(Type type) { }
public virtual string Handle(Type type) { }
}
```
**Why**: Management and Delivery APIs can customize schema/operation ID generation.
### Schema ID Sanitization (OpenApi/SchemaIdHandler.cs:24-29, 32)
```csharp
// Add "Model" suffix to avoid TypeScript name clashes (lines 24-29)
if (name.EndsWith("Model") == false)
{
// because some models names clash with common classes in TypeScript (i.e. Document),
@@ -78,12 +87,10 @@ if (name.EndsWith("Model") == false)
name = $"{name}Model";
}
// Remove invalid characters to prevent OpenAPI generation errors
// Remove invalid characters to prevent OpenAPI generation errors (line 32)
return Regex.Replace(name, @"[^\w]", string.Empty);
```
**Generic Type Handling**: `PagedViewModel<RelationItemViewModel>` becomes `PagedRelationItemModel`
### Polymorphic Deserialization (Serialization/UmbracoJsonTypeInfoResolver.cs:29-35)
```csharp
@@ -109,12 +116,9 @@ if (type.IsInterface is false)
dotnet test tests/Umbraco.Tests.Integration/
# Verify OpenAPI generation
# 1. Run the application: dotnet run --project src/Umbraco.Web.UI
# 2. Navigate to /umbraco/openapi/ for Swagger UI
# 1. Run Management API
# 2. Navigate to /umbraco/swagger/
# 3. Check schema IDs and operation IDs
# OpenAPI JSON documents available at:
# - /umbraco/openapi/management.json (Management API)
# - /umbraco/openapi/delivery.json (Delivery API)
```
**Focus areas when testing**:
@@ -203,24 +207,49 @@ catch (NotSupportedException exception)
**Issue**: Type names like `Document` clash with TypeScript built-ins.
**Solution**: `UmbracoSchemaIdGenerator` adds "Model" suffix to all schema names.
**Solution**: Add "Model" suffix (OpenApi/SchemaIdHandler.cs:24-29)
### Generic Type Handling
**Issue**: `PagedViewModel<T>` needs flattened schema name.
**Solution**: `UmbracoSchemaIdGenerator.Generate()` flattens generic types:
- `PagedViewModel<RelationItemViewModel>` becomes `PagedRelationItemModel`
**Solution** (OpenApi/SchemaIdHandler.cs:41-50):
```csharp
private string HandleGenerics(string name, Type type)
{
if (!type.IsGenericType)
return name;
// use attribute custom name or append the generic type names
// turns "PagedViewModel<RelationItemViewModel>" into "PagedRelationItem"
return $"{name}{string.Join(string.Empty, type.GenericTypeArguments.Select(SanitizedTypeName))}";
}
```
---
## 7. Extending This Library
### Adding Custom OpenAPI Transformers
### Adding a Custom OpenAPI Handler
OpenAPI transformers are scoped per-document. To customize a document, implement `IOpenApiDocumentTransformer`, `IOpenApiOperationTransformer`, or `IOpenApiSchemaTransformer` and register with your OpenAPI options.
1. **Implement interface**:
```csharp
public class MySchemaIdHandler : SchemaIdHandler
{
public override bool CanHandle(Type type)
=> type.Namespace?.StartsWith("MyProject") is true;
For schema ID generation, use the static `UmbracoSchemaIdGenerator.Generate(Type)` method.
public override string Handle(Type type)
=> $"My{base.Handle(type)}";
}
```
2. **Register in consuming API**:
```csharp
builder.Services.AddSingleton<ISchemaIdHandler, MySchemaIdHandler>();
```
**Note**: Handlers registered later take precedence in the selector.
### Customizing Problem Details
@@ -240,9 +269,13 @@ return BadRequest(problemDetails);
## 8. Project-Specific Notes
### Per-Document Transformer Scoping
### Why Virtual Handlers?
With Microsoft.AspNetCore.OpenApi, transformers are configured per OpenAPI document. This means custom transformers only apply to the documents they're registered with, not globally. Each API (Management, Delivery) configures its own transformers via `ConfigureUmbracoOpenApiOptionsBase` subclasses.
**Decision**: Make `SchemaIdHandler`, `OperationIdHandler`, etc. virtual.
**Why**: Management API and Delivery API have different schema ID requirements. Virtual methods allow override without rewriting the entire handler.
**Example**: Management API might prefix all schemas with "Management", Delivery API with "Delivery".
### Performance: Subtype Caching
@@ -271,13 +304,9 @@ With Microsoft.AspNetCore.OpenApi, transformers are configured per OpenAPI docum
- Version: See `Directory.Packages.props`
- Uses ASP.NET Core Data Protection for token encryption
**Microsoft.AspNetCore.OpenApi**:
- OpenAPI 3.1.1 document generation
- Custom transformers: `SchemaIdTransformer`, `OperationIdTransformer`, `MimeTypeDocumentTransformer`, `ServerTransformer`
**Swashbuckle.AspNetCore.SwaggerUI**:
- Swagger UI for browsing and testing API endpoints
- Accessed at `/umbraco/openapi/`
**Swashbuckle**:
- OpenAPI 3.0 document generation
- Custom filters: `EnumSchemaFilter`, `MimeTypeDocumentFilter`, `RemoveSecuritySchemesDocumentFilter`
**Asp.Versioning**:
- API versioning via `ApiVersion` attribute
@@ -289,7 +318,7 @@ With Microsoft.AspNetCore.OpenApi, transformers are configured per OpenAPI docum
### Usage Pattern
Consuming APIs call `builder.AddUmbracoOpenApi().AddUmbracoOpenIddict()`
Consuming APIs call `builder.AddUmbracoApiOpenApiUI().AddUmbracoOpenIddict()`
---
@@ -317,8 +346,7 @@ dotnet list src/Umbraco.Cms.Api.Common/Umbraco.Cms.Api.Common.csproj package --v
| Class | Purpose | File |
|-------|---------|------|
| `ProblemDetailsBuilder` | Build RFC 7807 error responses | Builders/ProblemDetailsBuilder.cs |
| `UmbracoSchemaIdGenerator` | Generate OpenAPI schema IDs | OpenApi/UmbracoSchemaIdGenerator.cs |
| `UmbracoOperationIdTransformer` | Generate operation IDs | OpenApi/UmbracoOperationIdTransformer.cs |
| `SchemaIdHandler` | Generate OpenAPI schema IDs | OpenApi/SchemaIdHandler.cs |
| `UmbracoJsonTypeInfoResolver` | Polymorphic JSON serialization | Serialization/UmbracoJsonTypeInfoResolver.cs |
| `UmbracoBuilderAuthExtensions` | Configure OpenIddict | DependencyInjection/UmbracoBuilderAuthExtensions.cs |
| `HideBackOfficeTokensHandler` | Secure cookie-based token storage | DependencyInjection/HideBackOfficeTokensHandler.cs |
@@ -1,47 +0,0 @@
using System.Reflection;
using Asp.Versioning;
using Microsoft.AspNetCore.Mvc.Abstractions;
using Microsoft.AspNetCore.Mvc.ApiExplorer;
using Microsoft.AspNetCore.Mvc.Controllers;
using Umbraco.Cms.Api.Common.OpenApi;
namespace Umbraco.Cms.Api.Common.Configuration;
/// <summary>
/// Configures the OpenAPI options for the Default API.
/// </summary>
internal class ConfigureDefaultApiOptions : ConfigureUmbracoOpenApiOptionsBase
{
/// <inheritdoc />
protected override string ApiName => DefaultApiConfiguration.ApiName;
/// <inheritdoc />
protected override string ApiTitle => "Default API";
/// <inheritdoc />
protected override string ApiVersion => "Latest";
/// <inheritdoc />
protected override string ApiDescription => "All endpoints not defined under specific APIs";
/// <inheritdoc />
protected override bool ShouldInclude(ApiDescription apiDescription)
{
// Exclude controllers with ExcludeFromDefaultOpenApiDocumentAttribute
if (apiDescription.ActionDescriptor is ControllerActionDescriptor controllerActionDescriptor
&& controllerActionDescriptor.ControllerTypeInfo.GetCustomAttribute<ExcludeFromDefaultOpenApiDocumentAttribute>() is not null)
{
return false;
}
// Include if explicitly mapped to this document
if (base.ShouldInclude(apiDescription))
{
return true;
}
// Include endpoints not explicitly assigned to another document
ApiVersionMetadata apiVersionMetadata = apiDescription.ActionDescriptor.ApiVersionMetadata;
return string.IsNullOrEmpty(apiVersionMetadata.Name);
}
}
@@ -1,98 +0,0 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Mvc.Abstractions;
using Microsoft.AspNetCore.Mvc.ApiExplorer;
using Microsoft.AspNetCore.Mvc.Controllers;
using Microsoft.AspNetCore.OpenApi;
using Microsoft.Extensions.Options;
using Microsoft.OpenApi;
using Umbraco.Cms.Api.Common.OpenApi;
using Umbraco.Extensions;
namespace Umbraco.Cms.Api.Common.Configuration;
/// <summary>
/// Base class for configuring OpenAPI options for Umbraco APIs.
/// </summary>
internal abstract class ConfigureUmbracoOpenApiOptionsBase : IConfigureNamedOptions<OpenApiOptions>
{
/// <summary>
/// Gets the name/identifier of the API to configure.
/// </summary>
protected abstract string ApiName { get; }
/// <summary>
/// Gets the name/identifier of the API to configure.
/// </summary>
protected abstract string ApiTitle { get; }
/// <summary>
/// Gets the version of the API to configure.
/// </summary>
protected abstract string ApiVersion { get; }
/// <summary>
/// Gets the description of the API to configure.
/// </summary>
protected abstract string ApiDescription { get; }
/// <inheritdoc />
public void Configure(OpenApiOptions options) => Configure(Options.DefaultName, options);
/// <inheritdoc />
public void Configure(string? name, OpenApiOptions options)
{
if (name != ApiName)
{
return;
}
ConfigureOpenApi(options);
}
/// <summary>
/// Configure the OpenAPI options for the specified API.
/// </summary>
/// <param name="options">The <see cref="OpenApiOptions"/> instance to configure.</param>
protected virtual void ConfigureOpenApi(OpenApiOptions options)
{
options.AddDocumentTransformer((document, _, _) =>
{
document.Info = new OpenApiInfo
{
Title = ApiTitle,
Version = ApiVersion,
Description = ApiDescription,
};
document.Servers?.Clear();
return Task.CompletedTask;
});
options.ShouldInclude = ShouldInclude;
options.CreateSchemaReferenceId = UmbracoSchemaIdGenerator.CreateSchemaReferenceId;
options.AddOperationTransformer<UmbracoOperationIdTransformer>();
// Tag actions by group name and cleanup unused tags (caused by the tag changes)
options
.AddOperationTransformer<TagActionsByGroupNameTransformer>()
.AddDocumentTransformer<TagActionsByGroupNameTransformer>()
.AddDocumentTransformer<SortTagsAndPathsTransformer>();
}
/// <summary>
/// Determines whether the specified API description should be included in this OpenAPI document.
/// </summary>
/// <param name="apiDescription">The API description to evaluate.</param>
/// <returns><c>true</c> if the endpoint should be included; otherwise, <c>false</c>.</returns>
protected virtual bool ShouldInclude(ApiDescription apiDescription)
{
if (apiDescription.ActionDescriptor is ControllerActionDescriptor controllerActionDescriptor
&& controllerActionDescriptor.HasMapToApiAttribute(ApiName))
{
return true;
}
ApiVersionMetadata apiVersionMetadata = apiDescription.ActionDescriptor.ApiVersionMetadata;
return apiVersionMetadata.Name == ApiName;
}
}
@@ -0,0 +1,94 @@
using Microsoft.AspNetCore.Mvc.ApiExplorer;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Microsoft.OpenApi;
using Swashbuckle.AspNetCore.SwaggerGen;
using Umbraco.Cms.Api.Common.OpenApi;
using Umbraco.Cms.Core.DependencyInjection;
namespace Umbraco.Cms.Api.Common.Configuration;
/// <summary>
/// Configures Swagger/OpenAPI generation options for Umbraco APIs.
/// </summary>
public class ConfigureUmbracoSwaggerGenOptions : IConfigureOptions<SwaggerGenOptions>
{
private readonly IOperationIdSelector _operationIdSelector;
private readonly ISchemaIdSelector _schemaIdSelector;
private readonly ISubTypesSelector _subTypesSelector;
private readonly IDocumentInclusionSelector _documentInclusionSelector;
/// <summary>
/// Initializes a new instance of the <see cref="ConfigureUmbracoSwaggerGenOptions"/> class.
/// </summary>
/// <param name="operationIdSelector">The operation ID selector.</param>
/// <param name="schemaIdSelector">The schema ID selector.</param>
/// <param name="subTypesSelector">The sub-types selector for polymorphism support.</param>
/// <param name="documentInclusionSelector">The document inclusion selector.</param>
public ConfigureUmbracoSwaggerGenOptions(
IOperationIdSelector operationIdSelector,
ISchemaIdSelector schemaIdSelector,
ISubTypesSelector subTypesSelector,
IDocumentInclusionSelector documentInclusionSelector)
{
_operationIdSelector = operationIdSelector;
_schemaIdSelector = schemaIdSelector;
_subTypesSelector = subTypesSelector;
_documentInclusionSelector = documentInclusionSelector;
}
/// <summary>
/// Initializes a new instance of the <see cref="ConfigureUmbracoSwaggerGenOptions"/> class.
/// </summary>
/// <param name="operationIdSelector">The operation ID selector.</param>
/// <param name="schemaIdSelector">The schema ID selector.</param>
/// <param name="subTypesSelector">The sub-types selector for polymorphism support.</param>
[Obsolete("Please use the constructor with all parameters. Scheduled for removal in Umbraco 19.")]
public ConfigureUmbracoSwaggerGenOptions(
IOperationIdSelector operationIdSelector,
ISchemaIdSelector schemaIdSelector,
ISubTypesSelector subTypesSelector)
: this(
operationIdSelector,
schemaIdSelector,
subTypesSelector,
StaticServiceProvider.Instance.GetRequiredService<IDocumentInclusionSelector>())
{
}
/// <inheritdoc/>
public void Configure(SwaggerGenOptions swaggerGenOptions)
{
swaggerGenOptions.SwaggerDoc(
DefaultApiConfiguration.ApiName,
new OpenApiInfo
{
Title = "Default API",
Version = "Latest",
Description = "All endpoints not defined under specific APIs",
});
swaggerGenOptions.CustomOperationIds(description => _operationIdSelector.OperationId(description));
swaggerGenOptions.DocInclusionPredicate(_documentInclusionSelector.Include);
swaggerGenOptions.TagActionsBy(api =>
api.GroupName is null
? []
: new[] { api.GroupName });
swaggerGenOptions.OrderActionsBy(ActionOrderBy);
swaggerGenOptions.SchemaFilter<EnumSchemaFilter>();
swaggerGenOptions.CustomSchemaIds(_schemaIdSelector.SchemaId);
swaggerGenOptions.SelectSubTypesUsing(_subTypesSelector.SubTypes);
swaggerGenOptions.SupportNonNullableReferenceTypes();
}
/// <summary>
/// Generates a sort key for API actions.
/// </summary>
/// <param name="apiDesc">The API description.</param>
/// <returns>A string used to sort API operations in the documentation.</returns>
/// <remarks>
/// See https://github.com/domaindrivendev/Swashbuckle.AspNetCore#change-operation-sort-order-eg-for-ui-sorting.
/// </remarks>
private static string ActionOrderBy(ApiDescription apiDesc)
=> $"{apiDesc.GroupName}_{apiDesc.ActionDescriptor.AttributeRouteInfo?.Template ?? apiDesc.ActionDescriptor.RouteValues["controller"]}_{(apiDesc.ActionDescriptor.RouteValues.TryGetValue("action", out var action) ? action : null)}_{apiDesc.HttpMethod}";
}
@@ -1,75 +0,0 @@
using Microsoft.AspNetCore.Http.Json;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
namespace Umbraco.Cms.Api.Common.DependencyInjection;
/// <summary>
/// Extension methods for replacing the internal Microsoft.AspNetCore.OpenApi schema service registration.
/// </summary>
internal static class OpenApiSchemaServiceExtensions
{
/// <summary>
/// The full name of the internal Microsoft type whose registration is replaced.
/// Used for a stringly-typed <see cref="ServiceDescriptor"/> lookup because the type is not publicly accessible.
/// </summary>
internal const string OpenApiSchemaServiceFullName = "Microsoft.AspNetCore.OpenApi.OpenApiSchemaService";
/// <summary>
/// Replaces the internal Microsoft <c>OpenApiSchemaService</c> registration for the specified document so that schema
/// generation uses the named <see cref="JsonOptions"/> rather than the default HTTP JSON options.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="documentName">The OpenAPI document key (matches the keyed singleton registered by <c>AddOpenApi(documentName)</c>).</param>
/// <param name="jsonOptionsName">The named <see cref="JsonOptions"/> to use during schema generation for this document.</param>
/// <returns>The same <see cref="IServiceCollection"/> for chaining.</returns>
/// <remarks>
/// Workaround for <see href="https://github.com/dotnet/aspnetcore/issues/66340">dotnet/aspnetcore#66340</see>.
/// </remarks>
public static IServiceCollection ReplaceOpenApiSchemaService(
this IServiceCollection services,
string documentName,
string jsonOptionsName)
=> services.ReplaceOpenApiSchemaService(
documentName,
sp => sp.GetRequiredService<IOptionsMonitor<JsonOptions>>().Get(jsonOptionsName));
/// <summary>
/// Replaces the internal Microsoft <c>OpenApiSchemaService</c> registration for the specified document so that schema
/// generation uses the <see cref="JsonOptions"/> instance produced by the supplied factory. Use this overload when
/// the options need to be resolved from the service provider, computed at the last moment, or built in a way that
/// doesn't fit the named-options lookup.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="documentName">The OpenAPI document key.</param>
/// <param name="jsonOptionsFactory">Factory invoked when the schema service is first resolved. Receives the resolving <see cref="IServiceProvider"/> and returns the <see cref="JsonOptions"/> to use.</param>
/// <returns>The same <see cref="IServiceCollection"/> for chaining.</returns>
/// <remarks>
/// Workaround for <see href="https://github.com/dotnet/aspnetcore/issues/66340">dotnet/aspnetcore#66340</see>.
/// </remarks>
public static IServiceCollection ReplaceOpenApiSchemaService(
this IServiceCollection services,
string documentName,
Func<IServiceProvider, JsonOptions> jsonOptionsFactory)
{
ServiceDescriptor descriptor = services.FirstOrDefault(sd =>
sd.ServiceType.FullName == OpenApiSchemaServiceFullName
&& Equals(sd.ServiceKey, documentName))
?? throw new InvalidOperationException(
$"Could not find a registration for {OpenApiSchemaServiceFullName} keyed with '{documentName}'. "
+ $"Ensure AddOpenApi(\"{documentName}\") has been called before {nameof(ReplaceOpenApiSchemaService)}, "
+ "or check whether the internal Microsoft.AspNetCore.OpenApi registration shape has changed.");
services.Remove(descriptor);
services.AddKeyedSingleton(
descriptor.ServiceType,
documentName,
(sp, key) => ActivatorUtilities.CreateInstance(
sp,
descriptor.ServiceType,
key,
Options.Create(jsonOptionsFactory(sp))));
return services;
}
}
@@ -1,51 +0,0 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Swashbuckle.AspNetCore.SwaggerUI;
using Umbraco.Cms.Api.Common.OpenApi;
using Umbraco.Extensions;
namespace Umbraco.Cms.Api.Common.DependencyInjection;
/// <summary>
/// Extension methods for <see cref="IServiceCollection"/> to configure OpenAPI services.
/// </summary>
public static class OpenApiServiceCollectionExtensions
{
/// <summary>
/// Adds an OpenAPI document to the OpenAPI UI document selector dropdown.
/// </summary>
/// <param name="services">The <see cref="IServiceCollection"/> instance.</param>
/// <param name="documentName">The name/identifier of the OpenAPI document.</param>
/// <param name="documentTitle">The title to display in the UI dropdown. Defaults to <paramref name="documentName"/> if not specified.</param>
/// <returns>The <see cref="IServiceCollection"/> instance.</returns>
public static IServiceCollection AddOpenApiDocumentToUi(
this IServiceCollection services,
string documentName,
string? documentTitle = null)
=> services.AddOpenApiDocumentToUi(documentName, () => documentTitle);
/// <summary>
/// Adds an OpenAPI document to the OpenAPI UI document selector dropdown, resolving the title lazily so
/// callers (such as builder-pattern helpers) can defer it until SwaggerUI options are resolved.
/// </summary>
/// <param name="services">The <see cref="IServiceCollection"/> instance.</param>
/// <param name="documentName">The name/identifier of the OpenAPI document.</param>
/// <param name="documentTitleFactory">Factory invoked when SwaggerUI options are resolved. Returning <c>null</c> falls back to <paramref name="documentName"/>.</param>
/// <returns>The <see cref="IServiceCollection"/> instance.</returns>
internal static IServiceCollection AddOpenApiDocumentToUi(
this IServiceCollection services,
string documentName,
Func<string?> documentTitleFactory)
{
services.AddOptions<SwaggerUIOptions>()
.Configure<IOptions<UmbracoOpenApiOptions>>((swaggerUiOptions, openApiOptions) =>
{
var openApiRoute = openApiOptions.Value.RouteTemplate.Replace("{documentName}", documentName).EnsureStartsWith("/");
swaggerUiOptions.SwaggerEndpoint(openApiRoute, documentTitleFactory() ?? documentName);
swaggerUiOptions.ConfigObject.Urls = swaggerUiOptions.ConfigObject.Urls.OrderBy(x => x.Name);
});
return services;
}
}
@@ -1,14 +1,9 @@
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Umbraco.Cms.Api.Common.Configuration;
using Umbraco.Cms.Api.Common.OpenApi;
using Umbraco.Cms.Api.Common.Serialization;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.Hosting;
using Umbraco.Cms.Web.Common.ApplicationBuilder;
using IHostingEnvironment = Umbraco.Cms.Core.Hosting.IHostingEnvironment;
namespace Umbraco.Cms.Api.Common.DependencyInjection;
@@ -21,51 +16,26 @@ public static class UmbracoBuilderApiExtensions
/// Adds Umbraco API OpenAPI/Swagger UI services to the builder.
/// </summary>
/// <param name="builder">The Umbraco builder.</param>
internal static void AddUmbracoOpenApi(this IUmbracoBuilder builder)
/// <returns>The Umbraco builder for method chaining.</returns>
public static IUmbracoBuilder AddUmbracoApiOpenApiUI(this IUmbracoBuilder builder)
{
if (builder.Services.Any(x => !x.IsKeyedService && x.ImplementationType == typeof(UmbracoJsonTypeInfoResolver)))
if (builder.Services.Any(x => !x.IsKeyedService && x.ImplementationType == typeof(OperationIdSelector)))
{
return;
return builder;
}
builder.Services.AddOptions<UmbracoOpenApiOptions>()
.Configure<IHostingEnvironment, IWebHostEnvironment>((options, hostingEnv, webHostEnv) =>
{
options.Enabled = webHostEnv.IsProduction() is false;
var backOfficePath = hostingEnv.GetBackOfficePath().TrimStart(Constants.CharArrays.ForwardSlash);
options.RouteTemplate = $"{backOfficePath}/openapi/{{documentName}}.json";
options.UiRoutePrefix = $"{backOfficePath}/openapi";
});
builder.AddUmbracoOpenApiDocument<ConfigureDefaultApiOptions>(DefaultApiConfiguration.ApiName, "Default API");
builder.Services.AddSwaggerGen();
builder.Services.ConfigureOptions<ConfigureUmbracoSwaggerGenOptions>();
builder.Services.AddSingleton<IUmbracoJsonTypeInfoResolver, UmbracoJsonTypeInfoResolver>();
builder.Services.Configure<UmbracoPipelineOptions>(options => options.AddFilter(new OpenApiRouteTemplatePipelineFilter("UmbracoApiCommon")));
}
builder.Services.AddSingleton<IOperationIdSelector, OperationIdSelector>();
builder.Services.AddSingleton<IOperationIdHandler, OperationIdHandler>();
builder.Services.AddSingleton<ISchemaIdSelector, SchemaIdSelector>();
builder.Services.AddSingleton<ISchemaIdHandler, SchemaIdHandler>();
builder.Services.AddSingleton<ISubTypesSelector, SubTypesSelector>();
builder.Services.AddSingleton<ISubTypesHandler, SubTypesHandler>();
builder.Services.AddSingleton<IDocumentInclusionSelector, DocumentInclusionSelector>();
builder.Services.Configure<UmbracoPipelineOptions>(options => options.AddFilter(new SwaggerRouteTemplatePipelineFilter("UmbracoApiCommon")));
/// <summary>
/// Adds and configures an Umbraco OpenAPI document with shared transformers.
/// </summary>
/// <param name="builder">The Umbraco builder.</param>
/// <param name="apiName">The name/identifier of the API.</param>
/// <param name="apiTitle">The title of the API.</param>
/// <param name="jsonOptionsName">
/// Optional named <c>JsonOptions</c> to use for schema generation instead of the default HTTP JSON options.
/// When specified, replaces the internal <c>OpenApiSchemaService</c> registration for this document.
/// </param>
/// <typeparam name="TConfigureOptions">The type used to configure the OpenAPI options.</typeparam>
internal static void AddUmbracoOpenApiDocument<TConfigureOptions>(
this IUmbracoBuilder builder,
string apiName,
string apiTitle,
string? jsonOptionsName = null)
where TConfigureOptions : ConfigureUmbracoOpenApiOptionsBase
{
builder.Services.AddOpenApi(apiName);
builder.Services.ConfigureOptions<TConfigureOptions>();
builder.Services.AddOpenApiDocumentToUi(apiName, apiTitle);
if (jsonOptionsName is not null)
{
builder.Services.ReplaceOpenApiSchemaService(apiName, jsonOptionsName);
}
return builder;
}
}
@@ -1,169 +0,0 @@
using Microsoft.AspNetCore.Http.Json;
using Microsoft.AspNetCore.OpenApi;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Umbraco.Cms.Api.Common.DependencyInjection;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Extensions;
namespace Umbraco.Cms.Api.Common.OpenApi;
/// <summary>
/// Fluent builder for configuring a custom OpenAPI document.
/// </summary>
public sealed class BackOfficeOpenApiDocumentBuilder
{
private readonly List<Action<OpenApiOptions>> _configurations = [];
private string? _title;
private string? _uiTitle;
private bool _includedInUi = true;
private Func<IServiceProvider, JsonOptions>? _httpJsonOptionsFactory;
/// <summary>
/// Initializes a new instance of the <see cref="BackOfficeOpenApiDocumentBuilder"/> class.
/// </summary>
/// <param name="documentName">The name of the OpenAPI document being configured.</param>
internal BackOfficeOpenApiDocumentBuilder(string documentName)
=> DocumentName = documentName;
/// <summary>
/// Gets the name of the OpenAPI document being configured.
/// </summary>
public string DocumentName { get; }
/// <summary>
/// Sets the document's <c>Info.Title</c>. Also used as the UI dropdown label unless overridden via
/// <see cref="WithUiTitle"/>.
/// </summary>
/// <param name="title">The title to display.</param>
/// <returns>The same builder for chaining.</returns>
public BackOfficeOpenApiDocumentBuilder WithTitle(string title)
{
_title = title;
return this;
}
/// <summary>
/// Overrides the UI dropdown label for this document.
/// </summary>
/// <param name="uiTitle">The label to display.</param>
/// <returns>The same builder for chaining.</returns>
public BackOfficeOpenApiDocumentBuilder WithUiTitle(string uiTitle)
{
_uiTitle = uiTitle;
return this;
}
/// <summary>
/// Excludes this document from the UI dropdown.
/// </summary>
/// <returns>The same builder for chaining.</returns>
public BackOfficeOpenApiDocumentBuilder ExcludeFromUi()
{
_includedInUi = false;
return this;
}
/// <summary>
/// Adds an <see cref="OpenApiOptions"/> configuration callback. Multiple calls compose.
/// </summary>
/// <param name="configure">Callback to configure the options.</param>
/// <returns>The same builder for chaining.</returns>
public BackOfficeOpenApiDocumentBuilder ConfigureOpenApiOptions(Action<OpenApiOptions> configure)
{
_configurations.Add(configure);
return this;
}
/// <summary>
/// Sets the named <see cref="JsonOptions">Microsoft.AspNetCore.Http.Json.JsonOptions</see> used when
/// generating this document's schema. Use this to match the serialization conventions of the API
/// endpoints the document describes.
/// </summary>
/// <param name="jsonOptionsName">The name of the registered HTTP <see cref="JsonOptions"/> to apply.</param>
/// <returns>The same builder for chaining.</returns>
public BackOfficeOpenApiDocumentBuilder WithJsonOptions(string jsonOptionsName)
=> WithJsonOptions(sp => sp.GetRequiredService<IOptionsMonitor<JsonOptions>>().Get(jsonOptionsName));
/// <summary>
/// Sets the <see cref="JsonOptions">Microsoft.AspNetCore.Http.Json.JsonOptions</see> used when
/// generating this document's schema. Use this to match the serialization conventions of the API
/// endpoints the document describes.
/// </summary>
/// <param name="jsonOptions">The HTTP JSON options to apply.</param>
/// <returns>The same builder for chaining.</returns>
public BackOfficeOpenApiDocumentBuilder WithJsonOptions(JsonOptions jsonOptions)
=> WithJsonOptions(_ => jsonOptions);
/// <summary>
/// Sets a factory that produces the <see cref="JsonOptions">Microsoft.AspNetCore.Http.Json.JsonOptions</see>
/// used when generating this document's schema. Use this to match the serialization conventions of the
/// API endpoints the document describes.
/// </summary>
/// <param name="jsonOptionsFactory">Factory invoked when the schema service is first resolved.</param>
/// <returns>The same builder for chaining.</returns>
public BackOfficeOpenApiDocumentBuilder WithJsonOptions(Func<IServiceProvider, JsonOptions> jsonOptionsFactory)
{
_httpJsonOptionsFactory = jsonOptionsFactory;
return this;
}
/// <summary>
/// Applies the accumulated configuration to the supplied <see cref="IUmbracoBuilder"/>'s service
/// collection. Called by <c>AddBackOfficeOpenApiDocument</c> once the user-supplied callback returns.
/// </summary>
/// <param name="builder">The Umbraco builder to register services against.</param>
internal void Build(IUmbracoBuilder builder)
{
builder.Services.AddOpenApi(
DocumentName,
options =>
{
options.ShouldInclude = apiDescription =>
apiDescription.ActionDescriptor.HasMapToApiAttribute(DocumentName);
options.CreateSchemaReferenceId = UmbracoSchemaIdGenerator.CreateSchemaReferenceId;
if (_title is not null)
{
options.AddDocumentTransformer((document, _, _) =>
{
document.Info.Title = _title;
return Task.CompletedTask;
});
}
// Generate operation IDs using Umbraco's naming conventions.
options.AddOperationTransformer<UmbracoOperationIdTransformer>();
// Trim redundant JSON-equivalent MIME types (e.g. text/json, application/*+json, text/plain)
// that ASP.NET Core adds alongside application/json.
options.AddOperationTransformer<MimeTypesTransformer>();
// Mark non-nullable properties as required so generated SDKs reflect the C# nullability.
options.AddSchemaTransformer<RequireNonNullablePropertiesSchemaTransformer>();
// Tag actions by group name and cleanup unused tags (caused by the tag changes).
options
.AddOperationTransformer<TagActionsByGroupNameTransformer>()
.AddDocumentTransformer<TagActionsByGroupNameTransformer>()
.AddDocumentTransformer<SortTagsAndPathsTransformer>();
foreach (Action<OpenApiOptions> configure in _configurations)
{
configure(options);
}
});
if (_includedInUi)
{
builder.Services.AddOpenApiDocumentToUi(DocumentName, _uiTitle ?? _title);
}
if (_httpJsonOptionsFactory is not null)
{
builder.Services.ReplaceOpenApiSchemaService(DocumentName, _httpJsonOptionsFactory);
}
}
}
@@ -0,0 +1,30 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Mvc.Abstractions;
using Microsoft.AspNetCore.Mvc.ApiExplorer;
using Microsoft.AspNetCore.Mvc.Controllers;
using Umbraco.Cms.Api.Common.Configuration;
using Umbraco.Extensions;
namespace Umbraco.Cms.Api.Common.OpenApi;
/// <summary>
/// Determines whether an API description should be included in a specific documentation set based on the document name
/// and API metadata.
/// </summary>
public class DocumentInclusionSelector : IDocumentInclusionSelector
{
/// <inheritdoc/>
public bool Include(string documentName, ApiDescription apiDescription)
{
if (apiDescription.ActionDescriptor is ControllerActionDescriptor controllerActionDescriptor
&& controllerActionDescriptor.HasMapToApiAttribute(documentName))
{
return true;
}
ApiVersionMetadata apiVersionMetadata = apiDescription.ActionDescriptor.GetApiVersionMetadata();
return apiVersionMetadata.Name == documentName
|| (string.IsNullOrEmpty(apiVersionMetadata.Name) && documentName == DefaultApiConfiguration.ApiName);
}
}
@@ -0,0 +1,35 @@
using System.Reflection;
using System.Runtime.Serialization;
using System.Text.Json.Nodes;
using Microsoft.OpenApi;
using Swashbuckle.AspNetCore.SwaggerGen;
namespace Umbraco.Cms.Api.Common.OpenApi;
/// <summary>
/// A schema filter that converts enum schemas to string type with enum member names.
/// </summary>
/// <remarks>
/// This filter ensures enums are represented as strings in the OpenAPI schema,
/// using <see cref="EnumMemberAttribute"/> values when available.
/// </remarks>
public class EnumSchemaFilter : ISchemaFilter
{
/// <inheritdoc/>
public void Apply(IOpenApiSchema model, SchemaFilterContext context)
{
if (model is not OpenApiSchema schema || context.Type.IsEnum is false)
{
return;
}
schema.Type = JsonSchemaType.String;
schema.Format = null;
schema.Enum = new List<JsonNode>();
foreach (var name in Enum.GetNames(context.Type))
{
var actualName = context.Type.GetField(name)?.GetCustomAttribute<EnumMemberAttribute>()?.Value ?? name;
schema.Enum.Add(actualName);
}
}
}
@@ -1,10 +0,0 @@
namespace Umbraco.Cms.Api.Common.OpenApi;
/// <summary>
/// Excludes the controller from the default OpenAPI document.
/// Use this when you have a custom OpenAPI document for your API.
/// </summary>
[AttributeUsage(AttributeTargets.Class)]
public sealed class ExcludeFromDefaultOpenApiDocumentAttribute : Attribute
{
}
@@ -1,47 +0,0 @@
using System.IO.Pipelines;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.OpenApi;
using Microsoft.OpenApi;
namespace Umbraco.Cms.Api.Common.OpenApi;
/// <summary>
/// Transformer to fix file return types in OpenAPI schema.
/// </summary>
/// <remarks>Can be removed once https://github.com/dotnet/aspnetcore/pull/63504 and
/// https://github.com/dotnet/aspnetcore/pull/64562 are released.</remarks>
internal class FixFileReturnTypesTransformer : IOpenApiSchemaTransformer
{
private static readonly Type[] _binaryStringTypes =
[
typeof(IFormFile),
typeof(FileResult),
typeof(Stream),
typeof(PipeReader),
];
/// <inheritdoc />
public Task TransformAsync(
OpenApiSchema schema,
OpenApiSchemaTransformerContext context,
CancellationToken cancellationToken)
{
if (_binaryStringTypes.Any(possibleBaseType => possibleBaseType.IsAssignableFrom(context.JsonTypeInfo.Type)) is false)
{
return Task.CompletedTask;
}
// Clear all properties
schema.Properties?.Clear();
schema.Required?.Clear();
// Make it an inline schema
schema.Metadata?.Remove("x-schema-id");
// Set type to string with binary format
schema.Type = JsonSchemaType.String;
schema.Format = "binary";
return Task.CompletedTask;
}
}
@@ -0,0 +1,19 @@
using Microsoft.AspNetCore.Mvc.ApiExplorer;
namespace Umbraco.Cms.Api.Common.OpenApi;
/// <summary>
/// Defines a method that determines whether a given API description should be included in a specific documentation
/// document.
/// </summary>
public interface IDocumentInclusionSelector
{
/// <summary>
/// Determines whether the specified API description should be included in the generated documentation for the given
/// document name.
/// </summary>
/// <param name="documentName">The name of the documentation document being generated.</param>
/// <param name="apiDescription">The API description to evaluate for inclusion.</param>
/// <returns>true if the API description should be included in the documentation; otherwise, false.</returns>
bool Include(string documentName, ApiDescription apiDescription);
}
@@ -0,0 +1,23 @@
using Microsoft.AspNetCore.Mvc.ApiExplorer;
namespace Umbraco.Cms.Api.Common.OpenApi;
/// <summary>
/// Defines a handler for generating OpenAPI operation IDs.
/// </summary>
public interface IOperationIdHandler
{
/// <summary>
/// Determines whether this handler can generate an operation ID for the specified API description.
/// </summary>
/// <param name="apiDescription">The API description to check.</param>
/// <returns><c>true</c> if this handler can handle the API description; otherwise, <c>false</c>.</returns>
bool CanHandle(ApiDescription apiDescription);
/// <summary>
/// Generates an operation ID for the specified API description.
/// </summary>
/// <param name="apiDescription">The API description to generate an operation ID for.</param>
/// <returns>The generated operation ID.</returns>
string Handle(ApiDescription apiDescription);
}
@@ -0,0 +1,17 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Mvc.ApiExplorer;
namespace Umbraco.Cms.Api.Common.OpenApi;
/// <summary>
/// Defines a selector for choosing operation IDs from registered handlers.
/// </summary>
public interface IOperationIdSelector
{
/// <summary>
/// Selects an operation ID for the specified API description.
/// </summary>
/// <param name="apiDescription">The API description to generate an operation ID for.</param>
/// <returns>The operation ID, or <c>null</c> if none could be determined.</returns>
string? OperationId(ApiDescription apiDescription);
}
@@ -0,0 +1,21 @@
namespace Umbraco.Cms.Api.Common.OpenApi;
/// <summary>
/// Defines a handler for generating OpenAPI schema IDs.
/// </summary>
public interface ISchemaIdHandler
{
/// <summary>
/// Determines whether this handler can generate a schema ID for the specified type.
/// </summary>
/// <param name="type">The type to check.</param>
/// <returns><c>true</c> if this handler can handle the type; otherwise, <c>false</c>.</returns>
bool CanHandle(Type type);
/// <summary>
/// Generates a schema ID for the specified type.
/// </summary>
/// <param name="type">The type to generate a schema ID for.</param>
/// <returns>The generated schema ID.</returns>
string Handle(Type type);
}
@@ -0,0 +1,14 @@
namespace Umbraco.Cms.Api.Common.OpenApi;
/// <summary>
/// Defines a selector for choosing schema IDs from registered handlers.
/// </summary>
public interface ISchemaIdSelector
{
/// <summary>
/// Selects a schema ID for the specified type.
/// </summary>
/// <param name="type">The type to generate a schema ID for.</param>
/// <returns>The schema ID.</returns>
string SchemaId(Type type);
}
@@ -0,0 +1,22 @@
namespace Umbraco.Cms.Api.Common.OpenApi;
/// <summary>
/// Defines a handler for discovering sub-types for polymorphic OpenAPI schemas.
/// </summary>
public interface ISubTypesHandler
{
/// <summary>
/// Determines whether this handler can discover sub-types for the specified type and document.
/// </summary>
/// <param name="type">The type to check.</param>
/// <param name="documentName">The OpenAPI document name.</param>
/// <returns><c>true</c> if this handler can handle the type; otherwise, <c>false</c>.</returns>
bool CanHandle(Type type, string documentName);
/// <summary>
/// Discovers sub-types for the specified type.
/// </summary>
/// <param name="type">The type to discover sub-types for.</param>
/// <returns>An enumerable of discovered sub-types.</returns>
IEnumerable<Type> Handle(Type type);
}
@@ -0,0 +1,14 @@
namespace Umbraco.Cms.Api.Common.OpenApi;
/// <summary>
/// Defines a selector for choosing sub-types from registered handlers.
/// </summary>
public interface ISubTypesSelector
{
/// <summary>
/// Selects sub-types for the specified type for polymorphic OpenAPI schema generation.
/// </summary>
/// <param name="type">The type to find sub-types for.</param>
/// <returns>An enumerable of sub-types.</returns>
IEnumerable<Type> SubTypes(Type type);
}
@@ -0,0 +1,60 @@
using Microsoft.OpenApi;
using Swashbuckle.AspNetCore.SwaggerGen;
using Umbraco.Extensions;
namespace Umbraco.Cms.Api.Common.OpenApi;
/// <summary>
/// This filter explicitly removes all other mime types than application/json from a named OpenAPI document when application/json is accepted.
/// </summary>
public class MimeTypeDocumentFilter : IDocumentFilter
{
private readonly string _documentName;
/// <summary>
/// Initializes a new instance of the <see cref="MimeTypeDocumentFilter"/> class.
/// </summary>
/// <param name="documentName">The name of the OpenAPI document to filter.</param>
public MimeTypeDocumentFilter(string documentName) => _documentName = documentName;
/// <inheritdoc/>
public void Apply(OpenApiDocument swaggerDoc, DocumentFilterContext context)
{
if (context.DocumentName != _documentName)
{
return;
}
OpenApiOperation[] operations = swaggerDoc.Paths
.SelectMany(path => path.Value.Operations?.Values ?? Enumerable.Empty<OpenApiOperation>())
.ToArray();
static void RemoveUnwantedMimeTypes(IDictionary<string, OpenApiMediaType>? content)
{
if (content is null || content.ContainsKey("application/json") is false)
{
return;
}
content.RemoveAll(r => r.Key != "application/json");
}
OpenApiRequestBody[] requestBodies = operations
.Select(operation => operation.RequestBody)
.OfType<OpenApiRequestBody>()
.ToArray();
foreach (OpenApiRequestBody requestBody in requestBodies)
{
RemoveUnwantedMimeTypes(requestBody.Content);
}
OpenApiResponse[] responses = operations
.SelectMany(operation => operation.Responses?.Values ?? Enumerable.Empty<IOpenApiResponse>())
.OfType<OpenApiResponse>()
.ToArray();
foreach (OpenApiResponse response in responses)
{
RemoveUnwantedMimeTypes(response.Content);
}
}
}
@@ -1,88 +0,0 @@
using System.Net.Mime;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.OpenApi;
using Microsoft.OpenApi;
using Umbraco.Extensions;
namespace Umbraco.Cms.Api.Common.OpenApi;
/// <summary>
/// Trims redundant JSON-equivalent media types from OpenAPI operations.
/// </summary>
/// <remarks>
/// <para>
/// ASP.NET Core's content negotiation populates operations with several media types that all serialize to JSON
/// (<c>text/json</c>, <c>application/*+json</c>, and <c>text/plain</c> alongside <c>application/json</c>).
/// When <c>application/json</c> is present on a response or request body, this transformer strips those
/// equivalents so OpenAPI consumers and generated SDKs aren't burdened with variants that produce identical
/// payloads. Non-JSON media types (e.g. <c>application/xml</c>, <c>application/octet-stream</c>) are preserved.
/// </para>
/// <para>
/// Request bodies additionally honour <c>[Consumes]</c>: when the attribute is present, the request content is
/// replaced entirely with the declared content types, taking precedence over the
/// JSON-equivalent stripping above.
/// </para>
/// </remarks>
internal class MimeTypesTransformer : IOpenApiOperationTransformer
{
private static readonly string[] _jsonEquivalentMimeTypes =
[
MediaTypeNames.Text.Plain,
"application/*+json",
"text/json"
];
/// <inheritdoc/>
public Task TransformAsync(
OpenApiOperation operation,
OpenApiOperationTransformerContext context,
CancellationToken cancellationToken)
{
// For request bodies, keep only the content types declared in [Consumes], or fall back to application/json.
if (operation.RequestBody?.Content is { } requestContent)
{
var explicitContentTypes = context.Description.ActionDescriptor.EndpointMetadata
.OfType<ConsumesAttribute>()
.SelectMany(p => p.ContentTypes)
.Distinct()
.ToArray();
if (explicitContentTypes.Length != 0)
{
// Replace content types entirely with what [Consumes] declares,
// preserving the schema from the existing entry.
OpenApiMediaType? existingMediaType = requestContent.Values.FirstOrDefault();
requestContent.Clear();
foreach (var contentType in explicitContentTypes)
{
requestContent[contentType] = existingMediaType ?? new OpenApiMediaType();
}
}
else
{
RemoveJsonEquivalentMimeTypes(requestContent);
}
}
// For responses, drop JSON-equivalent media types when application/json is present.
foreach (IOpenApiResponse response in (operation.Responses ?? []).Values)
{
if (response is OpenApiResponse openApiResponse)
{
RemoveJsonEquivalentMimeTypes(openApiResponse.Content);
}
}
return Task.CompletedTask;
}
private static void RemoveJsonEquivalentMimeTypes(IDictionary<string, OpenApiMediaType>? content)
{
if (content?.ContainsKey(MediaTypeNames.Application.Json) != true)
{
return;
}
content.RemoveAll(r => _jsonEquivalentMimeTypes.Contains(r.Key, StringComparer.OrdinalIgnoreCase));
}
}
@@ -1,57 +0,0 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Swashbuckle.AspNetCore.SwaggerUI;
using Umbraco.Cms.Core;
using Umbraco.Cms.Web.Common.ApplicationBuilder;
namespace Umbraco.Cms.Api.Common.OpenApi;
internal class OpenApiRouteTemplatePipelineFilter : UmbracoPipelineFilter
{
public OpenApiRouteTemplatePipelineFilter(string name)
: base(name)
{
PostPipeline = PostPipelineAction;
PreMapEndpoints = OnPreMapEndpointsAction;
}
private static void PostPipelineAction(IApplicationBuilder applicationBuilder)
{
UmbracoOpenApiOptions options = applicationBuilder.ApplicationServices
.GetRequiredService<IOptions<UmbracoOpenApiOptions>>().Value;
if (options.Enabled is false || options.DefaultUiEnabled is false)
{
return;
}
applicationBuilder.UseSwaggerUI(swaggerUiOptions => ConfigureSwaggerUi(swaggerUiOptions, options));
}
private static void OnPreMapEndpointsAction(IEndpointRouteBuilder endpoints)
{
UmbracoOpenApiOptions options = endpoints.ServiceProvider
.GetRequiredService<IOptions<UmbracoOpenApiOptions>>().Value;
if (options.Enabled is false)
{
return;
}
endpoints.MapOpenApi(options.RouteTemplate);
}
private static void ConfigureSwaggerUi(SwaggerUIOptions swaggerUiOptions, UmbracoOpenApiOptions options)
{
swaggerUiOptions.RoutePrefix = options.UiRoutePrefix;
// Add custom configuration from https://swagger.io/docs/open-source-tools/swagger-ui/usage/configuration/
swaggerUiOptions.ConfigObject.PersistAuthorization = true; // persists authorization data so it would not be lost on browser close/refresh
swaggerUiOptions.ConfigObject.Filter = string.Empty; // Enable the filter with an empty string as default filter.
swaggerUiOptions.OAuthClientId(Constants.OAuthClientIds.OpenApiUi);
swaggerUiOptions.OAuthUsePkce();
}
}
@@ -1,54 +1,63 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Mvc.ApiExplorer;
using Microsoft.AspNetCore.Mvc.Controllers;
using Microsoft.AspNetCore.OpenApi;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Microsoft.OpenApi;
using Umbraco.Extensions;
namespace Umbraco.Cms.Api.Common.OpenApi;
/// <summary>
/// Transforms OpenAPI operation IDs using Umbraco's naming conventions.
/// Default handler for generating OpenAPI operation IDs for Umbraco API controllers.
/// </summary>
/// <remarks>
/// This transformer can be registered manually for custom OpenAPI configurations.
/// Left unsealed on purpose, so it is extendable by consuming APIs.
/// </remarks>
public class UmbracoOperationIdTransformer : IOpenApiOperationTransformer
public class OperationIdHandler : IOperationIdHandler
{
private readonly ApiVersioningOptions _apiVersioningOptions;
/// <summary>
/// Transforms the specified OpenAPI operation, setting its operation ID using a custom selector.
/// Initializes a new instance of the <see cref="OperationIdHandler"/> class.
/// </summary>
/// <param name="operation">The <see cref="OpenApiOperation"/> to modify.</param>
/// <param name="context">The <see cref="OpenApiOperationTransformerContext"/> associated with the <paramref name="operation"/>.</param>
/// <param name="cancellationToken">The cancellation token to use.</param>
/// <returns>The task object representing the asynchronous operation.</returns>
public Task TransformAsync(
OpenApiOperation operation,
OpenApiOperationTransformerContext context,
CancellationToken cancellationToken)
{
var operationId = GenerateOperationId(context);
if (operationId is not null)
{
operation.OperationId = operationId;
}
/// <param name="apiVersioningOptions">The API versioning options.</param>
public OperationIdHandler(IOptions<ApiVersioningOptions> apiVersioningOptions)
=> _apiVersioningOptions = apiVersioningOptions.Value;
return Task.CompletedTask;
}
private static string? GenerateOperationId(OpenApiOperationTransformerContext context)
/// <inheritdoc/>
public bool CanHandle(ApiDescription apiDescription)
{
ApiDescription apiDescription = context.Description;
if (apiDescription.ActionDescriptor is not ControllerActionDescriptor controllerActionDescriptor)
{
// Minimal APIs and other non-MVC endpoints don't carry a ControllerActionDescriptor; leave their
// operation ID untouched so the framework's default applies.
return null;
return false;
}
ApiVersion defaultVersion = context.ApplicationServices.GetRequiredService<IOptions<ApiVersioningOptions>>().Value.DefaultApiVersion;
return CanHandle(apiDescription, controllerActionDescriptor);
}
/// <summary>
/// Determines whether this handler can process the API description based on the controller namespace.
/// </summary>
/// <param name="apiDescription">The API description.</param>
/// <param name="controllerActionDescriptor">The controller action descriptor.</param>
/// <returns><c>true</c> if the controller is in an Umbraco.Cms.Api namespace; otherwise, <c>false</c>.</returns>
protected virtual bool CanHandle(ApiDescription apiDescription, ControllerActionDescriptor controllerActionDescriptor)
=> controllerActionDescriptor.ControllerTypeInfo.Namespace?.StartsWith("Umbraco.Cms.Api") is true;
/// <inheritdoc/>
public virtual string Handle(ApiDescription apiDescription)
=> UmbracoOperationId(apiDescription);
/// <summary>
/// Generates a unique operation identifier for a given API following Umbraco's operation id naming conventions.
/// </summary>
protected string UmbracoOperationId(ApiDescription apiDescription)
{
if (apiDescription.ActionDescriptor is not ControllerActionDescriptor controllerActionDescriptor)
{
throw new ArgumentException($"This handler operates only on {nameof(ControllerActionDescriptor)}.");
}
ApiVersion defaultVersion = _apiVersioningOptions.DefaultApiVersion;
var httpMethod = apiDescription.HttpMethod?.ToLower().ToFirstUpper() ?? "Get";
// if the route info "Name" is supplied we'll use this explicitly as the operation ID
@@ -0,0 +1,35 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Mvc.ApiExplorer;
namespace Umbraco.Cms.Api.Common.OpenApi;
/// <summary>
/// Selects an operation ID for an API description using registered handlers.
/// </summary>
public class OperationIdSelector : IOperationIdSelector
{
private readonly IEnumerable<IOperationIdHandler> _operationIdHandlers;
/// <summary>
/// Initializes a new instance of the <see cref="OperationIdSelector"/> class.
/// </summary>
[Obsolete("Use non-obsolete constructor. Scheduled for removal in Umbraco 18.")]
public OperationIdSelector()
: this(Enumerable.Empty<IOperationIdHandler>())
{
}
/// <summary>
/// Initializes a new instance of the <see cref="OperationIdSelector"/> class.
/// </summary>
/// <param name="operationIdHandlers">The registered operation ID handlers.</param>
public OperationIdSelector(IEnumerable<IOperationIdHandler> operationIdHandlers)
=> _operationIdHandlers = operationIdHandlers;
/// <inheritdoc/>
public virtual string? OperationId(ApiDescription apiDescription)
{
IOperationIdHandler? handler = _operationIdHandlers.FirstOrDefault(h => h.CanHandle(apiDescription));
return handler?.Handle(apiDescription);
}
}
@@ -0,0 +1,30 @@
using Microsoft.OpenApi;
using Swashbuckle.AspNetCore.SwaggerGen;
namespace Umbraco.Cms.Api.Common.OpenApi;
/// <summary>
/// This filter explicitly removes all security schemes from a named OpenAPI document.
/// </summary>
public class RemoveSecuritySchemesDocumentFilter : IDocumentFilter
{
private readonly string _documentName;
/// <summary>
/// Initializes a new instance of the <see cref="RemoveSecuritySchemesDocumentFilter"/> class.
/// </summary>
/// <param name="documentName">The name of the OpenAPI document to filter.</param>
public RemoveSecuritySchemesDocumentFilter(string documentName)
=> _documentName = documentName;
/// <inheritdoc/>
public void Apply(OpenApiDocument swaggerDoc, DocumentFilterContext context)
{
if (context.DocumentName != _documentName)
{
return;
}
swaggerDoc.Components?.SecuritySchemes?.Clear();
}
}
@@ -1,48 +0,0 @@
using System.Reflection;
using System.Text.Json.Serialization.Metadata;
using Microsoft.AspNetCore.OpenApi;
using Microsoft.OpenApi;
namespace Umbraco.Cms.Api.Common.OpenApi;
/// <summary>
/// Ensures that all non-nullable properties are marked as required in the OpenAPI schema.
/// </summary>
/// <remarks>By default, only properties marked with the required keyword will actually show as required.
/// Non-nullable reference types were not taken into account.</remarks>
internal class RequireNonNullablePropertiesSchemaTransformer : IOpenApiSchemaTransformer
{
/// <inheritdoc />
public Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerContext context, CancellationToken cancellationToken)
{
IEnumerable<string> additionalRequiredProps = schema.Properties?
.Where(p => schema.Required?.Contains(p.Key) != true) // If it's already required, skip
.Where(x => IsRequiredProperty(schema, context.JsonTypeInfo, x.Key))
.Select(x => x.Key)
?? [];
schema.Required ??= new HashSet<string>();
foreach (var propKey in additionalRequiredProps)
{
schema.Required.Add(propKey);
}
return Task.CompletedTask;
}
private static bool IsRequiredProperty(OpenApiSchema schema, JsonTypeInfo jsonTypeInfo, string propertyName)
{
if (jsonTypeInfo.Properties.FirstOrDefault(p => p.Name == propertyName) is { } property)
{
return property.IsGetNullable is false;
}
// If we can't find the property in the type (e.g. discriminator '$type'), use the schema type information.
if (schema.Properties?.TryGetValue(propertyName, out IOpenApiSchema? schemaProperty) is true
&& schemaProperty?.Type is { } propertyType)
{
return propertyType.HasFlag(JsonSchemaType.Null) is false;
}
return false;
}
}
@@ -0,0 +1,59 @@
using System.Text.RegularExpressions;
using Umbraco.Extensions;
namespace Umbraco.Cms.Api.Common.OpenApi;
/// <summary>
/// Default handler for generating OpenAPI schema IDs for Umbraco types.
/// </summary>
/// <remarks>
/// Left unsealed on purpose, so it is extendable by consuming APIs.
/// Adds "Model" suffix to avoid TypeScript name clashes and removes invalid characters.
/// </remarks>
public class SchemaIdHandler : ISchemaIdHandler
{
/// <inheritdoc/>
public virtual bool CanHandle(Type type)
=> type.Namespace?.StartsWith("Umbraco.Cms") is true;
/// <inheritdoc/>
public virtual string Handle(Type type)
=> UmbracoSchemaId(type);
/// <summary>
/// Generates a sanitized and consistent schema identifier for a given type following Umbraco's schema id naming conventions.
/// </summary>
protected string UmbracoSchemaId(Type type)
{
var name = SanitizedTypeName(type);
name = HandleGenerics(name, type);
if (name.EndsWith("Model") == false)
{
// because some models names clash with common classes in TypeScript (i.e. Document),
// we need to add a "Model" postfix to all models
name = $"{name}Model";
}
// make absolutely sure we don't pass any invalid named by removing all non-word chars
return Regex.Replace(name, @"[^\w]", string.Empty);
}
private string SanitizedTypeName(Type t) => t.Name
// first grab the "non-generic" part of any generic type name (i.e. "PagedViewModel`1" becomes "PagedViewModel")
.Split('`').First()
// then remove the "ViewModel" postfix from type names
.TrimEnd("ViewModel");
private string HandleGenerics(string name, Type type)
{
if (!type.IsGenericType)
{
return name;
}
// use attribute custom name or append the generic type names, ultimately turning i.e. "PagedViewModel<RelationItemViewModel>" into "PagedRelationItem"
return $"{name}{string.Join(string.Empty, type.GenericTypeArguments.Select(SanitizedTypeName))}";
}
}
@@ -0,0 +1,23 @@
namespace Umbraco.Cms.Api.Common.OpenApi;
/// <summary>
/// Selects a schema ID for a type using registered handlers.
/// </summary>
public class SchemaIdSelector : ISchemaIdSelector
{
private readonly IEnumerable<ISchemaIdHandler> _schemaIdHandlers;
/// <summary>
/// Initializes a new instance of the <see cref="SchemaIdSelector"/> class.
/// </summary>
/// <param name="schemaIdHandlers">The registered schema ID handlers.</param>
public SchemaIdSelector(IEnumerable<ISchemaIdHandler> schemaIdHandlers)
=> _schemaIdHandlers = schemaIdHandlers;
/// <inheritdoc/>
public virtual string SchemaId(Type type)
{
ISchemaIdHandler? handler = _schemaIdHandlers.FirstOrDefault(h => h.CanHandle(type));
return handler?.Handle(type) ?? type.Name;
}
}
@@ -1,42 +0,0 @@
using Microsoft.AspNetCore.OpenApi;
using Microsoft.OpenApi;
namespace Umbraco.Cms.Api.Common.OpenApi;
/// <summary>
/// Transforms the OpenAPI document to sort tags and paths alphabetically.
/// </summary>
internal class SortTagsAndPathsTransformer : IOpenApiDocumentTransformer
{
/// <summary>
/// Transforms the specified OpenAPI document to sort its tags and paths alphabetically.
/// </summary>
/// <param name="document">The <see cref="OpenApiDocument"/> to modify.</param>
/// <param name="context">The <see cref="OpenApiDocumentTransformerContext"/> associated with the <paramref name="document"/>.</param>
/// <param name="cancellationToken">The cancellation token to use.</param>
/// <returns>The task object representing the asynchronous operation.</returns>
public Task TransformAsync(
OpenApiDocument document,
OpenApiDocumentTransformerContext context,
CancellationToken cancellationToken)
{
document.Tags = new SortedSet<OpenApiTag>(
document.Tags ?? Enumerable.Empty<OpenApiTag>(),
Comparer<OpenApiTag>.Create((a, b) => string.Compare(a.Name, b.Name, StringComparison.Ordinal)));
var sortedPaths = new OpenApiPaths();
foreach (KeyValuePair<string, IOpenApiPathItem> keyValuePair in document.Paths
.OrderBy(x => x.Value.Operations?.Values
.SelectMany(op => op.Tags ?? Enumerable.Empty<OpenApiTagReference>())
.OrderBy(t => t.Name)
.FirstOrDefault()?
.Name)
.ThenBy(x => x.Key))
{
sortedPaths.Add(keyValuePair.Key, keyValuePair.Value);
}
document.Paths = sortedPaths;
return Task.CompletedTask;
}
}
@@ -0,0 +1,34 @@
using Umbraco.Cms.Api.Common.Serialization;
namespace Umbraco.Cms.Api.Common.OpenApi;
/// <summary>
/// Default handler for discovering sub-types for polymorphic OpenAPI schemas.
/// </summary>
public class SubTypesHandler : ISubTypesHandler
{
private readonly IUmbracoJsonTypeInfoResolver _umbracoJsonTypeInfoResolver;
/// <summary>
/// Initializes a new instance of the <see cref="SubTypesHandler"/> class.
/// </summary>
/// <param name="umbracoJsonTypeInfoResolver">The JSON type info resolver for finding sub-types.</param>
public SubTypesHandler(IUmbracoJsonTypeInfoResolver umbracoJsonTypeInfoResolver)
=> _umbracoJsonTypeInfoResolver = umbracoJsonTypeInfoResolver;
/// <summary>
/// Determines whether this handler can process the specified type based on namespace.
/// </summary>
/// <param name="type">The type to check.</param>
/// <returns><c>true</c> if the type is in an Umbraco.Cms namespace; otherwise, <c>false</c>.</returns>
protected virtual bool CanHandle(Type type)
=> type.Namespace?.StartsWith("Umbraco.Cms") is true;
/// <inheritdoc/>
public virtual bool CanHandle(Type type, string documentName)
=> CanHandle(type);
/// <inheritdoc/>
public virtual IEnumerable<Type> Handle(Type type)
=> _umbracoJsonTypeInfoResolver.FindSubTypes(type);
}
@@ -0,0 +1,67 @@
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Options;
using Umbraco.Cms.Api.Common.Serialization;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.Hosting;
using Umbraco.Extensions;
namespace Umbraco.Cms.Api.Common.OpenApi;
/// <summary>
/// Selects sub-types for polymorphic OpenAPI schemas using registered handlers.
/// </summary>
public class SubTypesSelector : ISubTypesSelector
{
private readonly IHostingEnvironment _hostingEnvironment;
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly IEnumerable<ISubTypesHandler> _subTypeHandlers;
private readonly IUmbracoJsonTypeInfoResolver _umbracoJsonTypeInfoResolver;
/// <summary>
/// Initializes a new instance of the <see cref="SubTypesSelector"/> class.
/// </summary>
/// <param name="hostingEnvironment">The hosting environment.</param>
/// <param name="httpContextAccessor">The HTTP context accessor.</param>
/// <param name="subTypeHandlers">The registered sub-type handlers.</param>
/// <param name="umbracoJsonTypeInfoResolver">The JSON type info resolver for finding sub-types.</param>
public SubTypesSelector(
IHostingEnvironment hostingEnvironment,
IHttpContextAccessor httpContextAccessor,
IEnumerable<ISubTypesHandler> subTypeHandlers,
IUmbracoJsonTypeInfoResolver umbracoJsonTypeInfoResolver)
{
_hostingEnvironment = hostingEnvironment;
_httpContextAccessor = httpContextAccessor;
_subTypeHandlers = subTypeHandlers;
_umbracoJsonTypeInfoResolver = umbracoJsonTypeInfoResolver;
}
/// <inheritdoc/>
public IEnumerable<Type> SubTypes(Type type)
{
var backOfficePath = _hostingEnvironment.GetBackOfficePath();
var swaggerPath = $"{backOfficePath}/swagger";
if (_httpContextAccessor.HttpContext?.Request.Path.StartsWithSegments(swaggerPath) ?? false)
{
// Split the path into segments
var segments = _httpContextAccessor.HttpContext.Request.Path.Value![swaggerPath.Length..]
.TrimStart(Constants.CharArrays.ForwardSlash)
.Split(Constants.CharArrays.ForwardSlash);
// Extract the document name from the path
var documentName = segments[0];
// Find the first handler that can handle the type / document name combination
ISubTypesHandler? handler = _subTypeHandlers.FirstOrDefault(h => h.CanHandle(type, documentName));
if (handler != null)
{
return handler.Handle(type);
}
}
// Default implementation to maintain backwards compatibility
return _umbracoJsonTypeInfoResolver.FindSubTypes(type);
}
}
@@ -0,0 +1,98 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
using Microsoft.OpenApi;
using Swashbuckle.AspNetCore.SwaggerGen;
using Swashbuckle.AspNetCore.SwaggerUI;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Hosting;
using Umbraco.Cms.Web.Common.ApplicationBuilder;
using IHostingEnvironment = Umbraco.Cms.Core.Hosting.IHostingEnvironment;
namespace Umbraco.Cms.Api.Common.OpenApi;
/// <summary>
/// Pipeline filter that configures Swagger/OpenAPI endpoints for Umbraco APIs.
/// </summary>
public class SwaggerRouteTemplatePipelineFilter : UmbracoPipelineFilter
{
/// <summary>
/// Initializes a new instance of the <see cref="SwaggerRouteTemplatePipelineFilter"/> class.
/// </summary>
/// <param name="name">The name of the pipeline filter.</param>
public SwaggerRouteTemplatePipelineFilter(string name)
: base(name)
=> PostPipeline = PostPipelineAction;
private void PostPipelineAction(IApplicationBuilder applicationBuilder)
{
if (SwaggerIsEnabled(applicationBuilder) is false)
{
return;
}
IOptions<SwaggerGenOptions> swaggerGenOptions = applicationBuilder.ApplicationServices.GetRequiredService<IOptions<SwaggerGenOptions>>();
applicationBuilder.UseSwagger(swaggerOptions =>
{
swaggerOptions.RouteTemplate = SwaggerRouteTemplate(applicationBuilder);
});
applicationBuilder.UseSwaggerUI(swaggerUiOptions => SwaggerUiConfiguration(swaggerUiOptions, swaggerGenOptions.Value, applicationBuilder));
}
/// <summary>
/// Determines whether Swagger is enabled for the application.
/// </summary>
/// <param name="applicationBuilder">The application builder.</param>
/// <returns><c>true</c> if Swagger is enabled; otherwise, <c>false</c>.</returns>
protected virtual bool SwaggerIsEnabled(IApplicationBuilder applicationBuilder)
=> applicationBuilder.ApplicationServices.GetRequiredService<IWebHostEnvironment>().IsProduction() is false;
/// <summary>
/// Gets the route template for Swagger JSON endpoints.
/// </summary>
/// <param name="applicationBuilder">The application builder.</param>
/// <returns>The Swagger route template.</returns>
protected virtual string SwaggerRouteTemplate(IApplicationBuilder applicationBuilder)
=> $"{GetBackOfficePath(applicationBuilder).TrimStart(Constants.CharArrays.ForwardSlash)}/swagger/{{documentName}}/swagger.json";
/// <summary>
/// Gets the route prefix for the Swagger UI.
/// </summary>
/// <param name="applicationBuilder">The application builder.</param>
/// <returns>The Swagger UI route prefix.</returns>
protected virtual string SwaggerUiRoutePrefix(IApplicationBuilder applicationBuilder)
=> $"{GetBackOfficePath(applicationBuilder).TrimStart(Constants.CharArrays.ForwardSlash)}/swagger";
/// <summary>
/// Configures the Swagger UI options.
/// </summary>
/// <param name="swaggerUiOptions">The Swagger UI options to configure.</param>
/// <param name="swaggerGenOptions">The Swagger generation options.</param>
/// <param name="applicationBuilder">The application builder.</param>
protected virtual void SwaggerUiConfiguration(
SwaggerUIOptions swaggerUiOptions,
SwaggerGenOptions swaggerGenOptions,
IApplicationBuilder applicationBuilder)
{
swaggerUiOptions.RoutePrefix = SwaggerUiRoutePrefix(applicationBuilder);
foreach ((var name, OpenApiInfo? apiInfo) in swaggerGenOptions.SwaggerGeneratorOptions.SwaggerDocs.OrderBy(x => x.Value.Title))
{
swaggerUiOptions.SwaggerEndpoint($"{name}/swagger.json", $"{apiInfo.Title}");
}
// Add custom configuration from https://swagger.io/docs/open-source-tools/swagger-ui/usage/configuration/
swaggerUiOptions.ConfigObject.PersistAuthorization = true; // persists authorization data so it would not be lost on browser close/refresh
swaggerUiOptions.ConfigObject.Filter = string.Empty; // Enable the filter with an empty string as default filter.
swaggerUiOptions.OAuthClientId(Constants.OAuthClientIds.Swagger);
swaggerUiOptions.OAuthUsePkce();
}
private string GetBackOfficePath(IApplicationBuilder applicationBuilder)
=> applicationBuilder.ApplicationServices.GetRequiredService<IHostingEnvironment>().GetBackOfficePath();
}
@@ -1,67 +0,0 @@
using Microsoft.AspNetCore.OpenApi;
using Microsoft.OpenApi;
namespace Umbraco.Cms.Api.Common.OpenApi;
/// <summary>
/// Transformer that tags operations based on their group name.
/// </summary>
internal class TagActionsByGroupNameTransformer : IOpenApiOperationTransformer, IOpenApiDocumentTransformer
{
/// <summary>
/// Transforms the specified OpenAPI operation in order to tag it by its group name.
/// </summary>
/// <param name="operation">The <see cref="OpenApiOperation"/> to modify.</param>
/// <param name="context">The <see cref="OpenApiOperationTransformerContext"/> associated with the <paramref name="operation"/>.</param>
/// <param name="cancellationToken">The cancellation token to use.</param>
/// <returns>The task object representing the asynchronous operation.</returns>
public Task TransformAsync(
OpenApiOperation operation,
OpenApiOperationTransformerContext context,
CancellationToken cancellationToken)
{
if (context.Document is null || context.Description.GroupName is not { } groupName)
{
return Task.CompletedTask;
}
operation.Tags = new HashSet<OpenApiTagReference> { new(groupName) };
if (context.Document.Tags?.Any(t => t.Name == groupName) == true)
{
return Task.CompletedTask;
}
context.Document.Tags ??= new HashSet<OpenApiTag>();
context.Document.Tags.Add(new OpenApiTag { Name = groupName });
return Task.CompletedTask;
}
/// <summary>
/// Transforms the specified OpenAPI document in order to clean up unused tags.
/// </summary>
/// <param name="document">The <see cref="OpenApiDocument"/> to modify.</param>
/// <param name="context">The <see cref="OpenApiDocumentTransformerContext"/> associated with the <paramref name="document"/>.</param>
/// <param name="cancellationToken">The cancellation token to use.</param>
/// <returns>The task object representing the asynchronous operation.</returns>
public Task TransformAsync(
OpenApiDocument document,
OpenApiDocumentTransformerContext context,
CancellationToken cancellationToken)
{
var usedTags = new HashSet<string?>(document.Paths
.SelectMany(p => (p.Value.Operations ?? []).Values)
.SelectMany(o => o.Tags ?? new HashSet<OpenApiTagReference>())
.Select(t => t.Name));
var tagsToRemove = (document.Tags ?? Enumerable.Empty<OpenApiTag>())
.Where(tag => usedTags.Contains(tag.Name) is false)
.ToList();
foreach (OpenApiTag tag in tagsToRemove)
{
document.Tags?.Remove(tag);
}
return Task.CompletedTask;
}
}
@@ -1,81 +0,0 @@
using Umbraco.Cms.Core.DependencyInjection;
namespace Umbraco.Cms.Api.Common.OpenApi;
/// <summary>
/// Extension methods for <see cref="IUmbracoBuilder"/> to register custom OpenAPI documents.
/// </summary>
public static class UmbracoBuilderOpenApiExtensions
{
/// <summary>
/// Registers a custom OpenAPI document with Umbraco's defaults applied.
/// </summary>
/// <param name="builder">The Umbraco builder.</param>
/// <param name="documentName">The document name. Matches the <c>[MapToApi]</c> value on controllers to include.</param>
/// <param name="configure">Optional callback to customize the document.</param>
/// <returns>The same <see cref="IUmbracoBuilder"/> for chaining.</returns>
/// <remarks>
/// <para>
/// The following defaults are applied to the document and can be customized or overridden via the
/// <paramref name="configure"/> callback:
/// </para>
/// <list type="bullet">
/// <item>
/// <description>
/// Endpoints are filtered by <c>[MapToApi(documentName)]</c>; only matching endpoints appear in the document.
/// </description>
/// </item>
/// <item>
/// <description>
/// Schema reference IDs are generated by <see cref="UmbracoSchemaIdGenerator.CreateSchemaReferenceId"/>, applying
/// Umbraco naming conventions to types under the <c>Umbraco.Cms</c> namespace and falling back to the framework
/// default for everything else. Register your own <c>CreateSchemaReferenceId</c> delegate via
/// <see cref="BackOfficeOpenApiDocumentBuilder.ConfigureOpenApiOptions"/> to override.
/// </description>
/// </item>
/// <item>
/// <description>
/// Operation IDs are generated by <see cref="UmbracoOperationIdTransformer"/>. Register your own
/// <see cref="Microsoft.AspNetCore.OpenApi.IOpenApiOperationTransformer"/> via
/// <see cref="BackOfficeOpenApiDocumentBuilder.ConfigureOpenApiOptions"/> to override.
/// </description>
/// </item>
/// <item>
/// <description>
/// Operations are tagged by their controller's API group name, and the resulting tags and paths are sorted
/// for stable, diffable document output.
/// </description>
/// </item>
/// <item>
/// <description>
/// Redundant JSON-equivalent media types (such as <c>text/json</c>, <c>application/*+json</c>, and
/// <c>text/plain</c>) are stripped from request and response content when <c>application/json</c> is present,
/// so the document doesn't list spurious media types that ASP.NET Core adds by default.
/// </description>
/// </item>
/// <item>
/// <description>
/// Non-nullable properties are marked as <c>required</c> in the schema so generated client SDKs reflect
/// C# nullability. Override via <see cref="BackOfficeOpenApiDocumentBuilder.ConfigureOpenApiOptions"/>
/// if your types don't follow this convention.
/// </description>
/// </item>
/// <item>
/// <description>
/// The document is registered in the OpenAPI UI document selector dropdown. Call
/// <see cref="BackOfficeOpenApiDocumentBuilder.ExcludeFromUi"/> to opt out.
/// </description>
/// </item>
/// </list>
/// </remarks>
public static IUmbracoBuilder AddBackOfficeOpenApiDocument(
this IUmbracoBuilder builder,
string documentName,
Action<BackOfficeOpenApiDocumentBuilder>? configure = null)
{
var documentBuilder = new BackOfficeOpenApiDocumentBuilder(documentName);
configure?.Invoke(documentBuilder);
documentBuilder.Build(builder);
return builder;
}
}
@@ -1,48 +0,0 @@
namespace Umbraco.Cms.Api.Common.OpenApi;
/// <summary>
/// Options for configuring OpenAPI documents and UI.
/// </summary>
/// <remarks>
/// These options are populated by <c>AddUmbracoOpenApi</c> during DI configuration, which resolves the back-office path
/// from <see cref="Core.Hosting.IHostingEnvironment"/> and sets the default values for
/// <see cref="RouteTemplate"/> and <see cref="UiRoutePrefix"/>. Consumers that read this options type before
/// <c>AddUmbracoOpenApi</c> has run will observe the uninitialised defaults (empty strings for the route properties).
/// </remarks>
public class UmbracoOpenApiOptions
{
/// <summary>
/// Gets or sets whether OpenAPI documents are enabled.
/// Configured to <c>true</c> in non-production environments by default; <c>false</c> until configured.
/// This avoids exposing API structure on public-facing websites.
/// </summary>
public bool Enabled { get; set; }
/// <summary>
/// Gets or sets whether the default OpenAPI UI is enabled.
/// Only applies when <see cref="Enabled"/> is true.
/// Set to false to disable the default UI while keeping OpenAPI documents available,
/// allowing you to use an alternative UI.
/// Default: true.
/// </summary>
public bool DefaultUiEnabled { get; set; } = true;
/// <summary>
/// Gets or sets the route template for OpenAPI JSON documents.
/// Use <c>{documentName}</c> as a placeholder for the document name.
/// </summary>
/// <remarks>
/// Populated by <c>AddUmbracoOpenApi</c> to <c>"{backOfficePath}/openapi/{documentName}.json"</c>. The initial
/// <see cref="string.Empty"/> default is a sentinel for "not yet configured" — it is not a usable route template.
/// </remarks>
public string RouteTemplate { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the route prefix for OpenAPI UI.
/// </summary>
/// <remarks>
/// Populated by <c>AddUmbracoOpenApi</c> to <c>"{backOfficePath}/openapi"</c>. The initial <see cref="string.Empty"/>
/// default is a sentinel for "not yet configured" — it is not a usable route prefix.
/// </remarks>
public string UiRoutePrefix { get; set; } = string.Empty;
}
@@ -1,76 +0,0 @@
using System.Text.Json.Serialization.Metadata;
using System.Text.RegularExpressions;
using Microsoft.AspNetCore.OpenApi;
using Umbraco.Extensions;
namespace Umbraco.Cms.Api.Common.OpenApi;
/// <summary>
/// Static utility for generating OpenAPI schema IDs following Umbraco's naming conventions.
/// </summary>
public static class UmbracoSchemaIdGenerator
{
/// <summary>
/// Generates a schema ID for the specified type following Umbraco's naming conventions.
/// </summary>
/// <param name="type">The type to generate a schema ID for.</param>
/// <returns>The generated schema ID.</returns>
public static string Generate(Type type)
{
var name = SanitizedTypeName(type);
name = HandleGenerics(name, type);
if (name.EndsWith("Model") == false)
{
// because some models names clash with common classes in TypeScript (i.e. Document),
// we need to add a "Model" postfix to all models
name = $"{name}Model";
}
// make absolutely sure we don't pass any invalid named by removing all non-word chars
return Regex.Replace(name, @"[^\w]", string.Empty);
}
/// <summary>
/// Creates a schema reference ID for the given JSON type info, applying Umbraco's naming conventions to
/// types in the <c>Umbraco.Cms</c> namespace and falling back to the framework default for other types.
/// </summary>
/// <param name="jsonTypeInfo">The JSON type info to create a schema reference ID for.</param>
/// <returns>The schema reference ID, or <c>null</c> if the type should be inlined.</returns>
internal static string? CreateSchemaReferenceId(JsonTypeInfo jsonTypeInfo)
{
// Ensure that only types that would normally be included in the schema generation are given a schema reference ID.
// Otherwise, we should return null to inline them.
var defaultSchemaReferenceId = OpenApiOptions.CreateDefaultSchemaReferenceId(jsonTypeInfo);
if (defaultSchemaReferenceId is null)
{
return null;
}
Type targetType = Nullable.GetUnderlyingType(jsonTypeInfo.Type) ?? jsonTypeInfo.Type;
if (targetType.Namespace?.StartsWith("Umbraco.Cms") is not true)
{
return defaultSchemaReferenceId;
}
return Generate(targetType);
}
private static string SanitizedTypeName(Type t) => t.Name
// first grab the "non-generic" part of any generic type name (i.e. "PagedViewModel`1" becomes "PagedViewModel")
.Split('`').First()
// then remove the "ViewModel" postfix from type names
.TrimEnd("ViewModel");
private static string HandleGenerics(string name, Type type)
{
if (!type.IsGenericType)
{
return name;
}
// use attribute custom name or append the generic type names, ultimately turning i.e. "PagedViewModel<RelationItemViewModel>" into "PagedRelationItem"
return $"{name}{string.Join(string.Empty, type.GenericTypeArguments.Select(SanitizedTypeName))}";
}
}
@@ -3,7 +3,6 @@ using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Options;
using OpenIddict.Server;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.Security;
using Umbraco.Extensions;
@@ -33,12 +32,12 @@ public class ExposeBackOfficeAuthenticationOpenIddictServerEventsHandler : IOpen
// These are the type identifiers for the claims required by the principal
// for the custom authentication scheme.
// We make available the ID and user name claims, plus the claim necessary for parsing the user key.
// We make available the ID, user name and allowed applications (sections) claims.
_claimTypes =
[
backOfficeIdentityOptions.Value.ClaimsIdentity.UserIdClaimType,
backOfficeIdentityOptions.Value.ClaimsIdentity.UserNameClaimType,
Constants.Security.OpenIdDictSubClaimType
Core.Constants.Security.AllowedApplicationsClaimType,
];
}
@@ -8,12 +8,6 @@
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
<_Parameter1>Umbraco.Tests.UnitTests</_Parameter1>
</AssemblyAttribute>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
<_Parameter1>Umbraco.Cms.Api.Management</_Parameter1>
</AssemblyAttribute>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
<_Parameter1>Umbraco.Cms.Api.Delivery</_Parameter1>
</AssemblyAttribute>
</ItemGroup>
<ItemGroup>
@@ -21,12 +15,11 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Asp.Versioning.Mvc" />
<PackageReference Include="Asp.Versioning.Mvc "/>
<PackageReference Include="Asp.Versioning.Mvc.ApiExplorer" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
<PackageReference Include="Swashbuckle.AspNetCore" />
<PackageReference Include="OpenIddict.Abstractions" />
<PackageReference Include="OpenIddict.AspNetCore" />
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" />
</ItemGroup>
<ItemGroup>
+3 -2
View File
@@ -39,7 +39,7 @@ Umbraco.Cms.Api.Delivery/
├── Services/ # Business logic and query building
├── Caching/ # Output cache policies
├── Rendering/ # Output expansion strategies
├── Configuration/ # OpenAPI configuration
├── Configuration/ # Swagger configuration
└── Filters/ # Action filters (access, validation)
```
@@ -200,9 +200,10 @@ context.EnableOutputCaching = requestPreviewService.IsPreview() is false
### Technical Debt (TODOs in codebase)
1. **V1 Removal Pending** (2 locations):
1. **V1 Removal Pending** (4 locations):
- `DependencyInjection/UmbracoBuilderExtensions.cs:98` - FIXME: remove matcher policy
- `Routing/DeliveryApiItemsEndpointsMatcherPolicy.cs:11` - FIXME: remove class
- `Filters/SwaggerDocumentationFilterBase.cs:79,83` - FIXME: remove V1 swagger docs
2. **Obsolete Reference Warnings** (csproj:9-13):
- `ASP0019` - IHeaderDictionary.Append usage
@@ -1,67 +0,0 @@
using Microsoft.AspNetCore.OutputCaching;
using Microsoft.Extensions.Logging;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Cache;
using Umbraco.Cms.Core.Events;
using Umbraco.Cms.Core.Notifications;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Services.Changes;
using Umbraco.Cms.Core.Sync;
using Umbraco.Cms.Web.Common.Caching;
namespace Umbraco.Cms.Api.Delivery.Caching;
/// <summary>
/// Handles <see cref="ElementCacheRefresherNotification"/> to evict Delivery API output cache entries
/// for content that references the changed element via picker properties (umbElement relations).
/// </summary>
internal sealed class DeliveryApiElementOutputCacheEvictionHandler
: RelationOutputCacheEvictionHandlerBase, INotificationAsyncHandler<ElementCacheRefresherNotification>
{
private readonly ILogger<DeliveryApiElementOutputCacheEvictionHandler> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="DeliveryApiElementOutputCacheEvictionHandler"/> class.
/// </summary>
/// <param name="outputCacheStore">The output cache store for evicting cached responses.</param>
/// <param name="relationService">The relation service for querying entity references.</param>
/// <param name="idKeyMap">The ID/key mapping service for converting between integer IDs and GUIDs.</param>
/// <param name="logger">The logger.</param>
public DeliveryApiElementOutputCacheEvictionHandler(
IOutputCacheStore outputCacheStore,
IRelationService relationService,
IIdKeyMap idKeyMap,
ILogger<DeliveryApiElementOutputCacheEvictionHandler> logger)
: base(outputCacheStore, relationService, idKeyMap)
=> _logger = logger;
/// <inheritdoc />
public async Task HandleAsync(ElementCacheRefresherNotification notification, CancellationToken cancellationToken)
{
if (notification.MessageType != MessageType.RefreshByPayload
|| notification.MessageObject is not ElementCacheRefresher.JsonPayload[] payloads)
{
return;
}
foreach (ElementCacheRefresher.JsonPayload payload in payloads)
{
if (payload.ChangeTypes.HasFlag(TreeChangeTypes.RefreshAll))
{
// Evict all Delivery API responses — content responses may include referenced elements,
// so evicting only element-related entries would leave stale element references in content responses.
_logger.LogDebug("Element refresh all — evicting all Delivery API output cache entries.");
await OutputCacheStore.EvictByTagAsync(Constants.DeliveryApi.OutputCache.AllTag, cancellationToken);
return;
}
}
// Evict content that references the changed elements via picker properties.
await EvictRelatedContentAsync(
payloads.Select(p => p.Id),
Constants.Conventions.RelationTypes.RelatedElementAlias,
Constants.DeliveryApi.OutputCache.ContentTagPrefix,
_logger,
cancellationToken);
}
}
@@ -1,62 +0,0 @@
using Microsoft.AspNetCore.OpenApi;
using Microsoft.Extensions.Options;
using Umbraco.Cms.Api.Common.Configuration;
using Umbraco.Cms.Api.Common.OpenApi;
using Umbraco.Cms.Api.Delivery.OpenApi.Transformers;
using Umbraco.Cms.Core.Configuration.Models;
namespace Umbraco.Cms.Api.Delivery.Configuration;
/// <summary>
/// Configures the OpenAPI options for the Umbraco Delivery API.
/// </summary>
internal class ConfigureUmbracoDeliveryApiOpenApiOptions : ConfigureUmbracoOpenApiOptionsBase
{
private readonly DeliveryApiSettings _deliveryApiSettings;
/// <summary>
/// Initializes a new instance of the <see cref="ConfigureUmbracoDeliveryApiOpenApiOptions"/> class.
/// </summary>
/// <param name="deliveryApiSettings">The Delivery API settings.</param>
public ConfigureUmbracoDeliveryApiOpenApiOptions(IOptions<DeliveryApiSettings> deliveryApiSettings)
{
_deliveryApiSettings = deliveryApiSettings.Value;
}
/// <inheritdoc />
protected override string ApiName => DeliveryApiConfiguration.ApiName;
/// <inheritdoc />
protected override string ApiTitle => DeliveryApiConfiguration.ApiTitle;
/// <inheritdoc />
protected override string ApiVersion => "Latest";
/// <inheritdoc />
protected override string ApiDescription =>
$"You can find out more about the {DeliveryApiConfiguration.ApiTitle} in [the documentation]({DeliveryApiConfiguration.ApiDocumentationContentArticleLink}).";
/// <inheritdoc />
protected override void ConfigureOpenApi(OpenApiOptions options)
{
base.ConfigureOpenApi(options);
// Add API key security scheme and configure it for all operations
options
.AddDocumentTransformer<ApiKeyTransformer>()
.AddOperationTransformer<ApiKeyTransformer>();
options.AddSchemaTransformer<RequireNonNullablePropertiesSchemaTransformer>();
options.AddSchemaTransformer<FixFileReturnTypesTransformer>();
options.AddOperationTransformer<MimeTypesTransformer>();
options.AddOperationTransformer<ContentApiTransformer>();
options.AddOperationTransformer<MediaApiTransformer>();
if (_deliveryApiSettings.OpenApi.GenerateContentTypeSchemas)
{
options
.AddSchemaTransformer<ContentTypeSchemaTransformer>()
.AddDocumentTransformer<ContentTypeSchemaTransformer>();
}
}
}
@@ -0,0 +1,31 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Microsoft.OpenApi;
using Swashbuckle.AspNetCore.SwaggerGen;
using Umbraco.Cms.Api.Common.OpenApi;
using Umbraco.Cms.Api.Delivery.Filters;
namespace Umbraco.Cms.Api.Delivery.Configuration;
public class ConfigureUmbracoDeliveryApiSwaggerGenOptions: IConfigureOptions<SwaggerGenOptions>
{
public void Configure(SwaggerGenOptions swaggerGenOptions)
{
swaggerGenOptions.SwaggerDoc(
DeliveryApiConfiguration.ApiName,
new OpenApiInfo
{
Title = DeliveryApiConfiguration.ApiTitle,
Version = "Latest",
Description = $"You can find out more about the {DeliveryApiConfiguration.ApiTitle} in [the documentation]({DeliveryApiConfiguration.ApiDocumentationContentArticleLink})."
});
swaggerGenOptions.DocumentFilter<MimeTypeDocumentFilter>(DeliveryApiConfiguration.ApiName);
swaggerGenOptions.DocumentFilter<RemoveSecuritySchemesDocumentFilter>(DeliveryApiConfiguration.ApiName);
swaggerGenOptions.OperationFilter<SwaggerContentDocumentationFilter>();
swaggerGenOptions.OperationFilter<SwaggerMediaDocumentationFilter>();
swaggerGenOptions.ParameterFilter<SwaggerContentDocumentationFilter>();
swaggerGenOptions.ParameterFilter<SwaggerMediaDocumentationFilter>();
}
}
@@ -1,47 +0,0 @@
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Http.Json;
using Microsoft.Extensions.Options;
using Umbraco.Cms.Core;
namespace Umbraco.Cms.Api.Delivery.Configuration;
/// <summary>
/// Configures the Http JSON options for the Umbraco Delivery API.
/// </summary>
internal class ConfigureUmbracoDeliveryHttpJsonOptions : IConfigureNamedOptions<JsonOptions>
{
private readonly IOptionsMonitor<Microsoft.AspNetCore.Mvc.JsonOptions> _mvcJsonOptions;
/// <summary>
/// Initializes a new instance of the <see cref="ConfigureUmbracoDeliveryHttpJsonOptions"/> class.
/// </summary>
/// <param name="mvcJsonOptions">The configured MVC json options.</param>
public ConfigureUmbracoDeliveryHttpJsonOptions(IOptionsMonitor<Microsoft.AspNetCore.Mvc.JsonOptions> mvcJsonOptions)
=> _mvcJsonOptions = mvcJsonOptions;
/// <inheritdoc />
public void Configure(JsonOptions options) => Configure(Options.DefaultName, options);
/// <inheritdoc />
public void Configure(string? name, JsonOptions options)
{
if (name != Constants.JsonOptionsNames.DeliveryApi)
{
return;
}
// Copy all converters from the Delivery API MVC JSON options
Microsoft.AspNetCore.Mvc.JsonOptions backofficeMvcJsonOptions = _mvcJsonOptions.Get(Constants.JsonOptionsNames.DeliveryApi);
foreach (JsonConverter jsonConverter in backofficeMvcJsonOptions.JsonSerializerOptions.Converters)
{
options.SerializerOptions.Converters.Add(jsonConverter);
}
options.SerializerOptions.PropertyNamingPolicy = backofficeMvcJsonOptions.JsonSerializerOptions.PropertyNamingPolicy;
options.SerializerOptions.TypeInfoResolver = backofficeMvcJsonOptions.JsonSerializerOptions.TypeInfoResolver;
options.SerializerOptions.MaxDepth = backofficeMvcJsonOptions.JsonSerializerOptions.MaxDepth;
// Open API specific settings
options.SerializerOptions.NumberHandling = JsonNumberHandling.Strict;
}
}
@@ -0,0 +1,69 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Microsoft.OpenApi;
using Swashbuckle.AspNetCore.SwaggerGen;
using Umbraco.Cms.Api.Common.Security;
using Umbraco.Cms.Api.Delivery.Controllers.Content;
using Umbraco.Cms.Api.Delivery.Filters;
namespace Umbraco.Cms.Api.Delivery.Configuration;
/// <summary>
/// This configures member authentication for the Delivery API in Swagger. Consult the docs for
/// member authentication within the Delivery API for instructions on how to use this.
/// </summary>
/// <remarks>
/// This class is not used by the core CMS due to the required installation dependencies (local login page among other things).
/// </remarks>
public class ConfigureUmbracoMemberAuthenticationDeliveryApiSwaggerGenOptions : IConfigureOptions<SwaggerGenOptions>
{
private const string AuthSchemeName = "UmbracoMember";
public void Configure(SwaggerGenOptions options)
{
// add security requirements for content API operations
options.DocumentFilter<DeliveryApiSecurityFilter>();
options.OperationFilter<DeliveryApiSecurityFilter>();
}
private sealed class DeliveryApiSecurityFilter : SwaggerFilterBase<ContentApiControllerBase>, IOperationFilter, IDocumentFilter
{
public void Apply(OpenApiOperation operation, OperationFilterContext context)
{
if (CanApply(context) is false)
{
return;
}
var schemaRef = new OpenApiSecuritySchemeReference(AuthSchemeName, context.Document);
operation.Security ??= new List<OpenApiSecurityRequirement>();
operation.Security.Add(new OpenApiSecurityRequirement { [schemaRef] = [] });
}
public void Apply(OpenApiDocument swaggerDoc, DocumentFilterContext context)
{
if (context.DocumentName != DeliveryApiConfiguration.ApiName)
{
return;
}
swaggerDoc.AddComponent(
AuthSchemeName,
new OpenApiSecurityScheme
{
In = ParameterLocation.Header,
Name = AuthSchemeName,
Type = SecuritySchemeType.OAuth2,
Description = "Umbraco Member Authentication",
Flows = new OpenApiOAuthFlows
{
AuthorizationCode = new OpenApiOAuthFlow
{
AuthorizationUrl = new Uri(Paths.MemberApi.AuthorizationEndpoint, UriKind.Relative),
TokenUrl = new Uri(Paths.MemberApi.TokenEndpoint, UriKind.Relative),
},
},
});
}
}
}
@@ -20,7 +20,7 @@ public abstract class DeliveryApiControllerBase : Controller, IUmbracoFeature
{
protected string DecodePath(string path)
{
// OpenAPI does not allow reserved chars as "in:path" parameters, so clients based on the OpenAPI specification will URL
// OpenAPI does not allow reserved chars as "in:path" parameters, so clients based on the Swagger JSON will URL
// encode the path. Normally, ASP.NET Core handles that encoding with an automatic decoding - apparently just not
// for forward slashes, for whatever reason... so we need to deal with those. Hopefully this will be addressed in
// an upcoming version of ASP.NET Core.
@@ -6,7 +6,6 @@ using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Primitives;
using Umbraco.Cms.Api.Common.DependencyInjection;
using Umbraco.Cms.Api.Delivery.Accessors;
@@ -54,7 +53,7 @@ public static class UmbracoBuilderExtensions
provider =>
{
HttpContext? httpContext = provider.GetRequiredService<IHttpContextAccessor>().HttpContext;
ApiVersion? apiVersion = httpContext?.RequestedApiVersion;
ApiVersion? apiVersion = httpContext?.GetRequestedApiVersion();
if (apiVersion is null)
{
return provider.GetRequiredService<RequestContextOutputExpansionStrategyV2>();
@@ -68,6 +67,7 @@ public static class UmbracoBuilderExtensions
ServiceLifetime.Scoped);
builder.Services.AddSingleton<IRequestCultureService, RequestCultureService>();
builder.Services.AddSingleton<IRequestSegmmentService, RequestSegmentService>();
builder.Services.AddSingleton<IRequestSegmentService, RequestSegmentService>();
builder.Services.AddSingleton<IRequestRoutingService, RequestRoutingService>();
builder.Services.AddSingleton<IRequestRedirectService, RequestRedirectService>();
@@ -86,27 +86,19 @@ public static class UmbracoBuilderExtensions
builder.Services.AddTransient<IRequestMemberAccessService, RequestMemberAccessService>();
builder.Services.AddTransient<ICurrentMemberClaimsProvider, CurrentMemberClaimsProvider>();
builder.AddUmbracoOpenApi();
builder.AddUmbracoOpenApiDocument<ConfigureUmbracoDeliveryApiOpenApiOptions>(
DeliveryApiConfiguration.ApiName,
DeliveryApiConfiguration.ApiTitle,
Constants.JsonOptionsNames.DeliveryApi);
builder.Services.ConfigureOptions<ConfigureUmbracoDeliveryApiSwaggerGenOptions>();
builder.AddUmbracoApiOpenApiUI();
builder
.Services
.AddControllers()
.AddJsonOptions(
Constants.JsonOptionsNames.DeliveryApi,
options =>
{
// all Delivery API specific JSON options go here
options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
options.JsonSerializerOptions.TypeInfoResolver = new DeliveryApiJsonTypeResolver();
options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
});
// Configures the JSON options for the Open API schema generation (based on the Delivery API MVC JSON options)
builder.Services.ConfigureOptions<ConfigureUmbracoDeliveryHttpJsonOptions>();
.AddJsonOptions(Constants.JsonOptionsNames.DeliveryApi, options =>
{
// all Delivery API specific JSON options go here
options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
options.JsonSerializerOptions.TypeInfoResolver = new DeliveryApiJsonTypeResolver();
options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
});
builder.Services.AddAuthentication();
builder.AddUmbracoOpenIddict();
@@ -165,7 +157,6 @@ public static class UmbracoBuilderExtensions
builder.AddNotificationAsyncHandler<ContentCacheRefresherNotification, DeliveryApiDocumentOutputCacheEvictionHandler>();
builder.AddNotificationAsyncHandler<MediaCacheRefresherNotification, DeliveryApiMediaOutputCacheEvictionHandler>();
builder.AddNotificationAsyncHandler<MemberCacheRefresherNotification, DeliveryApiMemberOutputCacheEvictionHandler>();
builder.AddNotificationAsyncHandler<ElementCacheRefresherNotification, DeliveryApiElementOutputCacheEvictionHandler>();
// Register extension point default implementations.
builder.Services.AddSingleton<IDeliveryApiOutputCacheTagProvider, DeliveryApiContentTypeOutputCacheTagProvider>();
@@ -180,5 +171,4 @@ public static class UmbracoBuilderExtensions
return builder;
}
}
@@ -0,0 +1,127 @@
using System.Text.Json.Nodes;
using Microsoft.OpenApi;
using Swashbuckle.AspNetCore.SwaggerGen;
using Umbraco.Cms.Api.Delivery.Configuration;
using Umbraco.Cms.Api.Delivery.Controllers.Content;
using Umbraco.Cms.Core;
namespace Umbraco.Cms.Api.Delivery.Filters;
internal sealed class SwaggerContentDocumentationFilter : SwaggerDocumentationFilterBase<ContentApiControllerBase>
{
protected override string DocumentationLink => DeliveryApiConfiguration.ApiDocumentationContentArticleLink;
protected override void ApplyOperation(OpenApiOperation operation, OperationFilterContext context)
{
operation.Parameters ??= new List<IOpenApiParameter>();
AddExpand(operation, context);
AddFields(operation, context);
operation.Parameters.Add(new OpenApiParameter
{
Name = Constants.DeliveryApi.HeaderNames.AcceptLanguage,
In = ParameterLocation.Header,
Required = false,
Description = "Defines the language to return. Use this when querying language variant content items.",
Schema = new OpenApiSchema { Type = JsonSchemaType.String },
Examples = new Dictionary<string, IOpenApiExample>
{
{ "Default", new OpenApiExample { Value = string.Empty } },
{ "English culture", new OpenApiExample { Value = "en-us" } },
},
});
operation.Parameters.Add(new OpenApiParameter
{
Name = Constants.DeliveryApi.HeaderNames.AcceptSegment,
In = ParameterLocation.Header,
Required = false,
Description = "Defines the segment to return. Use this when querying segment variant content items.",
Schema = new OpenApiSchema { Type = JsonSchemaType.String },
Examples = new Dictionary<string, IOpenApiExample>
{
{ "Default", new OpenApiExample { Value = string.Empty } },
{ "Segment One", new OpenApiExample { Value = "segment-one" } },
},
});
AddApiKey(operation);
operation.Parameters.Add(new OpenApiParameter
{
Name = Constants.DeliveryApi.HeaderNames.Preview,
In = ParameterLocation.Header,
Required = false,
Description = "Whether to request draft content.",
Schema = new OpenApiSchema { Type = JsonSchemaType.Boolean },
});
operation.Parameters.Add(new OpenApiParameter
{
Name = Constants.DeliveryApi.HeaderNames.StartItem,
In = ParameterLocation.Header,
Required = false,
Description = "URL segment or GUID of a root content item.",
Schema = new OpenApiSchema { Type = JsonSchemaType.String },
});
}
protected override void ApplyParameter(OpenApiParameter parameter, ParameterFilterContext context)
{
switch (parameter.Name)
{
case "fetch":
AddQueryParameterDocumentation(parameter, FetchQueryParameterExamples(), "Specifies the content items to fetch");
break;
case "filter":
AddQueryParameterDocumentation(parameter, FilterQueryParameterExamples(), "Defines how to filter the fetched content items");
break;
case "sort":
AddQueryParameterDocumentation(parameter, SortQueryParameterExamples(), "Defines how to sort the found content items");
break;
case "skip":
parameter.Description = PaginationDescription(true, "content");
break;
case "take":
parameter.Description = PaginationDescription(false, "content");
break;
default:
return;
}
}
private Dictionary<string, IOpenApiExample> FetchQueryParameterExamples() =>
new()
{
{ "Select all", new OpenApiExample { Value = string.Empty } },
{ "Select all ancestors of a node by id", new OpenApiExample { Value = "ancestors:id" } },
{ "Select all ancestors of a node by path", new OpenApiExample { Value = "ancestors:path" } },
{ "Select all children of a node by id", new OpenApiExample { Value = "children:id" } },
{ "Select all children of a node by path", new OpenApiExample { Value = "children:path" } },
{ "Select all descendants of a node by id", new OpenApiExample { Value = "descendants:id" } },
{ "Select all descendants of a node by path", new OpenApiExample { Value = "descendants:path" } },
};
private Dictionary<string, IOpenApiExample> FilterQueryParameterExamples() =>
new()
{
{ "Default filter", new OpenApiExample { Value = string.Empty } },
{ "Filter by content type (equals)", new OpenApiExample { Value = new JsonArray { "contentType:alias1" } } },
{ "Filter by name (contains)", new OpenApiExample { Value = new JsonArray { "name:nodeName" } } },
{ "Filter by creation date (less than)", new OpenApiExample { Value = new JsonArray { "createDate<2024-01-01" } } },
{ "Filter by update date (greater than or equal)", new OpenApiExample { Value = new JsonArray { "updateDate>:2023-01-01" } } },
};
private Dictionary<string, IOpenApiExample> SortQueryParameterExamples() =>
new()
{
{ "Default sort", new OpenApiExample { Value = string.Empty } },
{ "Sort by create date", new OpenApiExample { Value = new JsonArray { "createDate:asc", "createDate:desc" } } },
{ "Sort by level", new OpenApiExample { Value = new JsonArray { "level:asc", "level:desc" } } },
{ "Sort by name", new OpenApiExample { Value = new JsonArray { "name:asc", "name:desc" } } },
{ "Sort by sort order", new OpenApiExample { Value = new JsonArray { "sortOrder:asc", "sortOrder:desc" } } },
{ "Sort by update date", new OpenApiExample { Value = new JsonArray { "updateDate:asc", "updateDate:desc" } } },
};
}
@@ -0,0 +1,155 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.OpenApi;
using Swashbuckle.AspNetCore.SwaggerGen;
using Umbraco.Cms.Core;
namespace Umbraco.Cms.Api.Delivery.Filters;
internal abstract class SwaggerDocumentationFilterBase<TBaseController>
: SwaggerFilterBase<TBaseController>, IOperationFilter, IParameterFilter
where TBaseController : Controller
{
protected abstract string DocumentationLink { get; }
public void Apply(OpenApiOperation operation, OperationFilterContext context)
{
if (CanApply(context))
{
ApplyOperation(operation, context);
}
}
public void Apply(IOpenApiParameter parameter, ParameterFilterContext context)
{
if (CanApply(context) && parameter is OpenApiParameter openApiParameter)
{
ApplyParameter(openApiParameter, context);
}
}
protected abstract void ApplyOperation(OpenApiOperation operation, OperationFilterContext context);
protected abstract void ApplyParameter(OpenApiParameter parameter, ParameterFilterContext context);
protected void AddQueryParameterDocumentation(OpenApiParameter parameter, Dictionary<string, IOpenApiExample> examples, string description)
{
parameter.Description = QueryParameterDescription(description);
parameter.Examples = examples;
}
protected void AddExpand(OpenApiOperation operation, OperationFilterContext context)
{
if (IsApiV1(context))
{
AddExpandV1(operation);
}
else
{
AddExpand(operation);
}
}
protected void AddFields(OpenApiOperation operation, OperationFilterContext context)
{
if (IsApiV1(context))
{
// "fields" is not a thing in Delivery API V1
return;
}
AddFields(operation);
}
protected void AddApiKey(OpenApiOperation operation)
{
operation.Parameters ??= new List<IOpenApiParameter>();
operation.Parameters.Add(
new OpenApiParameter
{
Name = Constants.DeliveryApi.HeaderNames.ApiKey,
In = ParameterLocation.Header,
Required = false,
Description = "API key specified through configuration to authorize access to the API.",
Schema = new OpenApiSchema { Type = JsonSchemaType.String },
});
}
protected string PaginationDescription(bool skip, string itemType)
=> $"Specifies the number of found {itemType} items to {(skip ? "skip" : "take")}. Use this to control pagination of the response.";
private string QueryParameterDescription(string description)
=> $"{description}. Refer to [the documentation]({DocumentationLink}#query-parameters) for more details on this.";
// FIXME: remove this when Delivery API V1 has been removed (expectedly in V15)
private static bool IsApiV1(OperationFilterContext context)
=> context.ApiDescription.RelativePath?.Contains("api/v1") is true;
// FIXME: remove this when Delivery API V1 has been removed (expectedly in V15)
private void AddExpandV1(OpenApiOperation operation)
{
operation.Parameters ??= new List<IOpenApiParameter>();
operation.Parameters.Add(
new OpenApiParameter
{
Name = "expand",
In = ParameterLocation.Query,
Required = false,
Description =
QueryParameterDescription("Defines the properties that should be expanded in the response"),
Schema = new OpenApiSchema { Type = JsonSchemaType.String },
Examples = new Dictionary<string, IOpenApiExample>
{
{ "Expand none", new OpenApiExample { Value = string.Empty } },
{ "Expand all", new OpenApiExample { Value = "all" } },
{ "Expand specific property", new OpenApiExample { Value = "property:alias1" } },
{ "Expand specific properties", new OpenApiExample { Value = "property:alias1,alias2" } },
},
});
}
private void AddExpand(OpenApiOperation operation)
{
operation.Parameters ??= new List<IOpenApiParameter>();
operation.Parameters.Add(
new OpenApiParameter
{
Name = "expand",
In = ParameterLocation.Query,
Required = false,
Description =
QueryParameterDescription("Defines the properties that should be expanded in the response"),
Schema = new OpenApiSchema { Type = JsonSchemaType.String },
Examples = new Dictionary<string, IOpenApiExample>
{
{ "Expand none", new OpenApiExample { Value = string.Empty } },
{ "Expand all properties", new OpenApiExample { Value = "properties[$all]" } },
{ "Expand specific property", new OpenApiExample { Value = "properties[alias1]" } },
{ "Expand specific properties", new OpenApiExample { Value = "properties[alias1,alias2]" } },
{ "Expand nested properties", new OpenApiExample { Value = "properties[alias1[properties[nestedAlias1,nestedAlias2]]]" } },
},
});
}
private void AddFields(OpenApiOperation operation)
{
operation.Parameters ??= new List<IOpenApiParameter>();
operation.Parameters.Add(
new OpenApiParameter
{
Name = "fields",
In = ParameterLocation.Query,
Required = false,
Description =
QueryParameterDescription(
"Explicitly defines which properties should be included in the response (by default all properties are included)"),
Schema = new OpenApiSchema { Type = JsonSchemaType.String },
Examples = new Dictionary<string, IOpenApiExample>
{
{ "Include all properties", new OpenApiExample { Value = "properties[$all]" } },
{ "Include only specific property", new OpenApiExample { Value = "properties[alias1]" } },
{ "Include only specific properties", new OpenApiExample { Value = "properties[alias1,alias2]" } },
{ "Include only specific nested properties", new OpenApiExample { Value = "properties[alias1[properties[nestedAlias1,nestedAlias2]]]" } },
},
});
}
}
@@ -0,0 +1,19 @@
using System.Reflection;
using Microsoft.AspNetCore.Mvc;
using Swashbuckle.AspNetCore.SwaggerGen;
using Umbraco.Extensions;
namespace Umbraco.Cms.Api.Delivery.Filters;
internal abstract class SwaggerFilterBase<TBaseController>
where TBaseController : Controller
{
protected bool CanApply(OperationFilterContext context)
=> CanApply(context.MethodInfo);
protected bool CanApply(ParameterFilterContext context)
=> CanApply(context.ParameterInfo.Member);
private bool CanApply(MemberInfo member)
=> member.DeclaringType?.Implements<TBaseController>() is true;
}
@@ -1,41 +1,27 @@
using System.Text.Json.Nodes;
using Microsoft.AspNetCore.Mvc.Controllers;
using Microsoft.AspNetCore.OpenApi;
using System.Text.Json.Nodes;
using Microsoft.OpenApi;
using Swashbuckle.AspNetCore.SwaggerGen;
using Umbraco.Cms.Api.Delivery.Configuration;
using Umbraco.Cms.Api.Delivery.Controllers.Media;
using Umbraco.Extensions;
namespace Umbraco.Cms.Api.Delivery.OpenApi.Transformers;
namespace Umbraco.Cms.Api.Delivery.Filters;
/// <summary>
/// Transforms OpenAPI operations for the Media API, adding relevant parameters and documentation.
/// </summary>
internal sealed class MediaApiTransformer : DeliveryApiTransformerBase
internal sealed class SwaggerMediaDocumentationFilter : SwaggerDocumentationFilterBase<MediaApiControllerBase>
{
protected override string DocumentationLink => DeliveryApiConfiguration.ApiDocumentationMediaArticleLink;
/// <inheritdoc/>
protected override bool ShouldApply(OpenApiOperationTransformerContext context) =>
context.Description.ActionDescriptor is ControllerActionDescriptor description
&& description.ControllerTypeInfo.Implements<MediaApiControllerBase>();
/// <inheritdoc/>
protected override Task ApplyAsync(
OpenApiOperation operation,
OpenApiOperationTransformerContext context,
CancellationToken cancellationToken)
protected override void ApplyOperation(OpenApiOperation operation, OperationFilterContext context)
{
operation.Parameters ??= new List<IOpenApiParameter>();
foreach (OpenApiParameter parameter in operation.Parameters?.OfType<OpenApiParameter>() ?? [])
{
ApplyParameter(parameter);
}
return Task.CompletedTask;
AddExpand(operation, context);
AddFields(operation, context);
AddApiKey(operation);
}
private void ApplyParameter(OpenApiParameter parameter)
protected override void ApplyParameter(OpenApiParameter parameter, ParameterFilterContext context)
{
switch (parameter.Name)
{
@@ -70,18 +56,18 @@ internal sealed class MediaApiTransformer : DeliveryApiTransformerBase
private Dictionary<string, IOpenApiExample> FilterQueryParameterExamples() =>
new()
{
{ "Default filter", new OpenApiExample { Value = new JsonArray(string.Empty) } },
{ "Filter by media type", new OpenApiExample { Value = new JsonArray("mediaType:alias1") } },
{ "Filter by name", new OpenApiExample { Value = new JsonArray("name:nodeName") } },
{ "Default filter", new OpenApiExample { Value = string.Empty } },
{ "Filter by media type", new OpenApiExample { Value = new JsonArray { "mediaType:alias1" } } },
{ "Filter by name", new OpenApiExample { Value = new JsonArray { "name:nodeName" } } },
};
private Dictionary<string, IOpenApiExample> SortQueryParameterExamples() =>
new()
{
{ "Default sort", new OpenApiExample { Value = new JsonArray(string.Empty) } },
{ "Sort by create date", new OpenApiExample { Value = new JsonArray("createDate:asc", "createDate:desc") } },
{ "Sort by name", new OpenApiExample { Value = new JsonArray("name:asc", "name:desc") } },
{ "Sort by sort order", new OpenApiExample { Value = new JsonArray("sortOrder:asc", "sortOrder:desc") } },
{ "Sort by update date", new OpenApiExample { Value = new JsonArray("updateDate:asc", "updateDate:desc") } },
{ "Default sort", new OpenApiExample { Value = string.Empty } },
{ "Sort by create date", new OpenApiExample { Value = new JsonArray { "createDate:asc", "createDate:desc" } } },
{ "Sort by name", new OpenApiExample { Value = new JsonArray { "name:asc", "name:desc" } } },
{ "Sort by sort order", new OpenApiExample { Value = new JsonArray { "sortOrder:asc", "sortOrder:desc" } } },
{ "Sort by update date", new OpenApiExample { Value = new JsonArray { "updateDate:asc", "updateDate:desc" } } },
};
}
@@ -51,7 +51,7 @@ public abstract class DeliveryApiVersionAwareJsonConverterBase<T> : JsonConverte
private int? GetApiVersion()
{
HttpContext? httpContext = _httpContextAccessor.HttpContext;
ApiVersion? apiVersion = httpContext?.RequestedApiVersion;
ApiVersion? apiVersion = httpContext?.GetRequestedApiVersion();
return apiVersion?.MajorVersion;
}
@@ -1,19 +0,0 @@
using Microsoft.AspNetCore.OpenApi;
using Microsoft.OpenApi;
namespace Umbraco.Cms.Api.Delivery.OpenApi.Extensions;
/// <summary>
/// Provides extension methods for <see cref="OpenApiSchemaTransformerContext"/>.
/// </summary>
internal static class OpenApiSchemaTransformerContextExtensions
{
/// <summary>
/// Gets the OpenAPI document from the context, throwing if it is null.
/// </summary>
/// <param name="context">The schema transformer context.</param>
/// <returns>The OpenAPI document.</returns>
/// <exception cref="InvalidOperationException">Thrown when the document is null.</exception>
public static OpenApiDocument GetRequiredDocument(this OpenApiSchemaTransformerContext context)
=> context.Document ?? throw new InvalidOperationException("OpenAPI document context is required for schema registration.");
}
@@ -1,34 +0,0 @@
using Microsoft.AspNetCore.OpenApi;
using Microsoft.Extensions.DependencyInjection;
using Umbraco.Cms.Api.Delivery.Configuration;
using Umbraco.Cms.Api.Delivery.OpenApi.Transformers;
namespace Umbraco.Cms.Api.Delivery.OpenApi;
/// <summary>
/// Extension methods for configuring OpenAPI support for the Delivery API.
/// </summary>
public static class OpenApiServiceCollectionExtensions
{
/// <summary>
/// Adds member authentication support to the Delivery API OpenAPI document.
/// </summary>
/// <param name="services">The <see cref="IServiceCollection"/> to configure.</param>
/// <returns>The configured <see cref="IServiceCollection"/> instance.</returns>
/// <remarks>
/// This enables the OAuth2 authorization code flow for member authentication in Swagger UI.
/// Consult the Delivery API member authentication documentation for setup instructions.
/// </remarks>
public static IServiceCollection AddDeliveryApiOpenApiMemberAuthentication(this IServiceCollection services)
{
services.PostConfigure<OpenApiOptions>(
DeliveryApiConfiguration.ApiName,
options =>
{
options.AddDocumentTransformer<MemberAuthenticationSecurityRequirementsTransformer>();
options.AddOperationTransformer<MemberAuthenticationSecurityRequirementsTransformer>();
});
return services;
}
}
@@ -1,51 +0,0 @@
using Microsoft.AspNetCore.OpenApi;
using Microsoft.OpenApi;
using Umbraco.Cms.Core;
namespace Umbraco.Cms.Api.Delivery.OpenApi.Transformers;
/// <summary>
/// Transforms the OpenAPI document to include API key security scheme.
/// </summary>
internal class ApiKeyTransformer : IOpenApiDocumentTransformer, IOpenApiOperationTransformer
{
private const string AuthSchemeName = "ApiKeyAuth";
/// <inheritdoc/>
public Task TransformAsync(
OpenApiDocument document,
OpenApiDocumentTransformerContext context,
CancellationToken cancellationToken)
{
var apiKeyScheme = new OpenApiSecurityScheme
{
Type = SecuritySchemeType.ApiKey,
Name = Constants.DeliveryApi.HeaderNames.ApiKey,
In = ParameterLocation.Header,
Description = "API key specified through configuration to authorize access to the API.",
};
document.Components ??= new OpenApiComponents();
document.Components.SecuritySchemes ??= new Dictionary<string, IOpenApiSecurityScheme>();
document.Components.SecuritySchemes[AuthSchemeName] = apiKeyScheme;
var schemaRef = new OpenApiSecuritySchemeReference(AuthSchemeName, document);
document.Security ??= new List<OpenApiSecurityRequirement>();
document.Security.Add(new OpenApiSecurityRequirement { [schemaRef] = [] });
return Task.CompletedTask;
}
/// <inheritdoc/>
public Task TransformAsync(
OpenApiOperation operation,
OpenApiOperationTransformerContext context,
CancellationToken cancellationToken)
{
var schemaRef = new OpenApiSecuritySchemeReference(AuthSchemeName, context.Document);
operation.Security ??= new List<OpenApiSecurityRequirement>();
operation.Security.Add(new OpenApiSecurityRequirement { [schemaRef] = [] });
return Task.CompletedTask;
}
}
@@ -1,146 +0,0 @@
using System.Text.Json.Nodes;
using Microsoft.AspNetCore.Mvc.Controllers;
using Microsoft.AspNetCore.OpenApi;
using Microsoft.OpenApi;
using Umbraco.Cms.Api.Delivery.Configuration;
using Umbraco.Cms.Api.Delivery.Controllers.Content;
using Umbraco.Cms.Core;
using Umbraco.Extensions;
namespace Umbraco.Cms.Api.Delivery.OpenApi.Transformers;
/// <summary>
/// Transforms OpenAPI operations for the Content API, adding relevant parameters and documentation.
/// </summary>
internal sealed class ContentApiTransformer : DeliveryApiTransformerBase
{
/// <inheritdoc/>
protected override string DocumentationLink => DeliveryApiConfiguration.ApiDocumentationContentArticleLink;
/// <inheritdoc/>
protected override bool ShouldApply(OpenApiOperationTransformerContext context) =>
context.Description.ActionDescriptor is ControllerActionDescriptor description
&& description.ControllerTypeInfo.Implements<ContentApiControllerBase>();
/// <inheritdoc/>
protected override Task ApplyAsync(
OpenApiOperation operation,
OpenApiOperationTransformerContext context,
CancellationToken cancellationToken)
{
operation.Parameters ??= new List<IOpenApiParameter>();
operation.Parameters.Add(
new OpenApiParameter
{
Name = Constants.DeliveryApi.HeaderNames.AcceptLanguage,
In = ParameterLocation.Header,
Required = false,
Description = "Defines the language to return. Use this when querying language variant content items.",
Schema = new OpenApiSchema { Type = JsonSchemaType.String },
Examples = new Dictionary<string, IOpenApiExample>
{
{ "Default", new OpenApiExample { Value = string.Empty } },
{ "English culture", new OpenApiExample { Value = "en-us" } },
},
});
operation.Parameters.Add(
new OpenApiParameter
{
Name = Constants.DeliveryApi.HeaderNames.AcceptSegment,
In = ParameterLocation.Header,
Required = false,
Description = "Defines the segment to return. Use this when querying segment variant content items.",
Schema = new OpenApiSchema { Type = JsonSchemaType.String },
Examples = new Dictionary<string, IOpenApiExample>
{
{ "Default", new OpenApiExample { Value = string.Empty } },
{ "Segment One", new OpenApiExample { Value = "segment-one" } },
},
});
operation.Parameters.Add(
new OpenApiParameter
{
Name = Constants.DeliveryApi.HeaderNames.Preview,
In = ParameterLocation.Header,
Required = false,
Description = "Whether to request draft content.",
Schema = new OpenApiSchema { Type = JsonSchemaType.Boolean },
});
operation.Parameters.Add(
new OpenApiParameter
{
Name = Constants.DeliveryApi.HeaderNames.StartItem,
In = ParameterLocation.Header,
Required = false,
Description = "URL segment or GUID of a root content item.",
Schema = new OpenApiSchema { Type = JsonSchemaType.String },
});
foreach (OpenApiParameter parameter in operation.Parameters?.OfType<OpenApiParameter>() ?? [])
{
ApplyParameter(parameter);
}
return Task.CompletedTask;
}
private void ApplyParameter(OpenApiParameter parameter)
{
switch (parameter.Name)
{
case "fetch":
AddQueryParameterDocumentation(parameter, FetchQueryParameterExamples(), "Specifies the content items to fetch");
break;
case "filter":
AddQueryParameterDocumentation(parameter, FilterQueryParameterExamples(), "Defines how to filter the fetched content items");
break;
case "sort":
AddQueryParameterDocumentation(parameter, SortQueryParameterExamples(), "Defines how to sort the found content items");
break;
case "skip":
parameter.Description = PaginationDescription(true, "content");
break;
case "take":
parameter.Description = PaginationDescription(false, "content");
break;
default:
return;
}
}
private Dictionary<string, IOpenApiExample> FetchQueryParameterExamples() =>
new()
{
{ "Select all", new OpenApiExample { Value = "" } },
{ "Select all ancestors of a node by id", new OpenApiExample { Value = "ancestors:id" } },
{ "Select all ancestors of a node by path", new OpenApiExample { Value = "ancestors:path" } },
{ "Select all children of a node by id", new OpenApiExample { Value = "children:id" } },
{ "Select all children of a node by path", new OpenApiExample { Value = "children:path" } },
{ "Select all descendants of a node by id", new OpenApiExample { Value = "descendants:id" } },
{ "Select all descendants of a node by path", new OpenApiExample { Value = "descendants:path" } },
};
private Dictionary<string, IOpenApiExample> FilterQueryParameterExamples() =>
new()
{
{ "Default filter", new OpenApiExample { Value = new JsonArray("") } },
{ "Filter by content type (equals)", new OpenApiExample { Value = new JsonArray("contentType:alias1") } },
{ "Filter by name (contains)", new OpenApiExample { Value = new JsonArray("name:nodeName") } },
{ "Filter by creation date (less than)", new OpenApiExample { Value = new JsonArray("createDate<2024-01-01") } },
{ "Filter by update date (greater than or equal)", new OpenApiExample { Value = new JsonArray("updateDate>:2023-01-01") } },
};
private Dictionary<string, IOpenApiExample> SortQueryParameterExamples() =>
new()
{
{ "Default sort", new OpenApiExample { Value = new JsonArray("") } },
{ "Sort by create date", new OpenApiExample { Value = new JsonArray("createDate:asc", "createDate:desc") } },
{ "Sort by level", new OpenApiExample { Value = new JsonArray("level:asc", "level:desc") } },
{ "Sort by name", new OpenApiExample { Value = new JsonArray("name:asc", "name:desc") } },
{ "Sort by sort order", new OpenApiExample { Value = new JsonArray("sortOrder:asc", "sortOrder:desc") } },
{ "Sort by update date", new OpenApiExample { Value = new JsonArray("updateDate:asc", "updateDate:desc") } },
};
}
@@ -1,737 +0,0 @@
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Schema;
using System.Text.Json.Serialization.Metadata;
using Microsoft.AspNetCore.Http.Json;
using Microsoft.AspNetCore.OpenApi;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.OpenApi;
using Umbraco.Cms.Api.Common.OpenApi;
using Umbraco.Cms.Api.Delivery.OpenApi.Extensions;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.DeliveryApi;
using Umbraco.Cms.Core.Models.PublishedContent;
using Umbraco.Cms.Core.Services;
using Umbraco.Extensions;
namespace Umbraco.Cms.Api.Delivery.OpenApi.Transformers;
/// <summary>
/// Transforms the OpenAPI document to add schemas for the instance's document types.
/// </summary>
/// <remarks>
/// <para>
/// This transformer implements both <see cref="IOpenApiSchemaTransformer"/> and <see cref="IOpenApiDocumentTransformer"/>
/// to handle schema generation in two phases:
/// </para>
/// <para>
/// <b>Phase 1 - Schema Transformation:</b> When the schema transformer encounters types like
/// <see cref="IApiContentResponse"/> or <see cref="IApiMediaWithCrops"/>, it generates content-type-specific
/// schemas (e.g., "ArticleContentResponseModel") and registers them as components in the OpenAPI document.
/// </para>
/// <para>
/// <b>Circular Reference Handling:</b> Content type schemas can reference each other (e.g., a "Page"
/// might have a property of type "Article", which might reference "Page" again). To prevent infinite recursion
/// during schema generation, we use a placeholder pattern:
/// <list type="bullet">
/// <item>When generating a schema, we track its ID in <c>_handledSchemas</c></item>
/// <item>If we encounter the same schema ID again (circular reference), we return a temporary placeholder
/// schema with metadata marking it for later replacement</item>
/// <item>The placeholder contains a <c>x-recursive-ref</c> metadata key with the target schema ID</item>
/// </list>
/// </para>
/// <para>
/// <b>Phase 2 - Document Transformation:</b> After all schemas are generated, the document transformer
/// resolves inline schemas into proper <c>$ref</c> references. This handles two cases:
/// <list type="bullet">
/// <item>Circular reference placeholders (marked with <c>x-recursive-ref</c>) created during Phase 1</item>
/// <item>Componentized schemas (marked with <c>x-schema-id</c>) that the framework did not automatically
/// resolve to <c>$ref</c> — this can happen for schemas reached through properties or composition
/// rather than as direct API response types</item>
/// </list>
/// This is done by <see cref="ResolveSchemaReferences(OpenApiDocument, IOpenApiSchema)"/> which recursively walks
/// through all schemas and substitutes matching entries with <see cref="OpenApiSchemaReference"/> instances.
/// </para>
/// </remarks>
public sealed class ContentTypeSchemaTransformer : IOpenApiSchemaTransformer, IOpenApiDocumentTransformer
{
// Metadata keys
private const string RecursiveRefMetadataKey = "x-recursive-ref";
private const string SchemaIdMetadataKey = "x-schema-id";
// Schema ID suffixes
private const string ResponseModelSuffix = "ResponseModel";
private const string ModelSuffix = "Model";
private const string ContentSuffix = "Content";
private const string ElementSuffix = "Element";
private const string MediaSuffix = "Media";
private const string MediaWithCropsSuffix = "MediaWithCrops";
private const string PropertiesModelSuffix = "PropertiesModel";
private readonly IContentTypeSchemaService _contentTypeSchemaService;
private readonly IOptionsMonitor<DeliveryApiSettings> _deliveryApiSettings;
private readonly ILogger<ContentTypeSchemaTransformer> _logger;
private readonly IJsonTypeInfoResolver _jsonTypeInfoResolver;
/// <summary>
/// Tracks schema IDs that have been or are being generated to detect circular references.
/// When a schema ID is encountered a second time, a placeholder is returned instead of recursing infinitely.
/// </summary>
private readonly HashSet<string> _handledSchemas = [];
private readonly JsonSerializerOptions _serializerOptions;
/// <summary>
/// Initializes a new instance of the <see cref="ContentTypeSchemaTransformer"/> class.
/// </summary>
/// <param name="contentTypeSchemaService">The content type info service.</param>
/// <param name="jsonOptionsMonitor">The JSON options monitor.</param>
/// <param name="deliveryApiSettings">The Delivery API settings, used to honour the allow/deny content type list.</param>
/// <param name="logger">The logger.</param>
public ContentTypeSchemaTransformer(
IContentTypeSchemaService contentTypeSchemaService,
IOptionsMonitor<JsonOptions> jsonOptionsMonitor,
IOptionsMonitor<DeliveryApiSettings> deliveryApiSettings,
ILogger<ContentTypeSchemaTransformer> logger)
{
_contentTypeSchemaService = contentTypeSchemaService;
_deliveryApiSettings = deliveryApiSettings;
_logger = logger;
_serializerOptions = jsonOptionsMonitor
.Get(Constants.JsonOptionsNames.DeliveryApi)
.SerializerOptions;
_jsonTypeInfoResolver = _serializerOptions.TypeInfoResolver
?? throw new InvalidOperationException("The JSON serializer options must have a TypeInfoResolver configured.");
}
private IReadOnlyCollection<ContentTypeSchemaInfo> DocumentTypes
=> field ??= FilterAllowedDocumentTypes(_contentTypeSchemaService.GetDocumentTypes());
private IReadOnlyCollection<ContentTypeSchemaInfo> MediaTypes
=> field ??= _contentTypeSchemaService.GetMediaTypes();
/// <inheritdoc />
public Task TransformAsync(
OpenApiDocument document,
OpenApiDocumentTransformerContext context,
CancellationToken cancellationToken)
{
if (document.Components?.Schemas is not { Count: > 0 })
{
return Task.CompletedTask;
}
foreach ((var schemaId, IOpenApiSchema componentsSchema) in document.Components.Schemas)
{
ResolveSchemaReferences(document, componentsSchema);
FixAutoBuiltDiscriminatorMapping(document, schemaId, componentsSchema);
}
return Task.CompletedTask;
}
/// <summary>
/// Repairs broken discriminator mapping refs auto-built by the framework for polymorphic types.
/// </summary>
/// <remarks>
/// The framework prefixes each ref with the base schema id, but the derived schemas are
/// registered without that prefix. Stripping the prefix recovers the correct ref.
/// </remarks>
private static void FixAutoBuiltDiscriminatorMapping(OpenApiDocument document, string parentSchemaId, IOpenApiSchema schema)
{
if (schema is not OpenApiSchema concrete
|| concrete.Discriminator?.Mapping is not { } mapping
|| document.Components?.Schemas is not { } schemas)
{
return;
}
foreach ((var key, OpenApiSchemaReference currentRef) in mapping.ToList())
{
var targetId = currentRef.Reference.Id;
if (string.IsNullOrEmpty(targetId) || schemas.ContainsKey(targetId))
{
continue;
}
if (targetId.StartsWith(parentSchemaId, StringComparison.Ordinal) is false)
{
continue;
}
var stripped = targetId[parentSchemaId.Length..];
if (schemas.ContainsKey(stripped))
{
mapping[key] = new OpenApiSchemaReference(stripped, document);
}
}
}
/// <inheritdoc />
public async Task TransformAsync(
OpenApiSchema schema,
OpenApiSchemaTransformerContext context,
CancellationToken cancellationToken)
{
switch (context.JsonTypeInfo.Type)
{
case var type when type == typeof(IApiContentResponse):
await ApplyPolymorphicContentType(
schema,
context,
PublishedItemType.Content,
DocumentTypes.Where(c => !c.IsElement),
async (contentType, derivedTypeSchemas) =>
{
var schemaIdPrefix = $"{contentType.SchemaId}{ContentSuffix}";
return await CreateContentTypeResponseSchema(
schemaIdPrefix,
derivedTypeSchemas,
context);
},
cancellationToken);
await CreateSchema(GetJsonTypeInfo(typeof(IApiContent)), context, cancellationToken);
return;
case var type when type == typeof(IApiContent):
await ApplyPolymorphicContentType(
schema,
context,
PublishedItemType.Content,
DocumentTypes.Where(c => !c.IsElement),
async (contentType, derivedTypeSchemas) =>
{
var schemaId = $"{contentType.SchemaId}{ContentSuffix}{ModelSuffix}";
return await CreateContentTypeSchema(
schemaId,
PublishedItemType.Content,
contentType,
derivedTypeSchemas,
context,
cancellationToken);
},
cancellationToken);
await CreateSchema(GetJsonTypeInfo(typeof(IApiElement)), context, cancellationToken);
return;
case var type when type == typeof(IApiElement):
await ApplyPolymorphicContentType(
schema,
context,
PublishedItemType.Content,
DocumentTypes.Where(c => c.IsElement),
async (contentType, derivedTypeSchemas) =>
{
var schemaId = $"{contentType.SchemaId}{ElementSuffix}{ModelSuffix}";
return await CreateContentTypeSchema(
schemaId,
PublishedItemType.Content,
contentType,
derivedTypeSchemas,
context,
cancellationToken);
},
cancellationToken);
return;
case var type when type == typeof(IApiMediaWithCropsResponse):
await ApplyPolymorphicContentType(
schema,
context,
PublishedItemType.Media,
MediaTypes,
async (contentType, derivedTypeSchemas) =>
{
var schemaId = $"{contentType.SchemaId}{MediaWithCropsSuffix}";
return await CreateContentTypeResponseSchema(
schemaId,
derivedTypeSchemas,
context);
},
cancellationToken);
await CreateSchema(GetJsonTypeInfo(typeof(IApiMediaWithCrops)), context, cancellationToken);
return;
case var type when type == typeof(IApiMediaWithCrops):
await ApplyPolymorphicContentType(
schema,
context,
PublishedItemType.Media,
MediaTypes,
async (contentType, derivedTypeSchemas) =>
{
var schemaId = $"{contentType.SchemaId}{MediaWithCropsSuffix}{ModelSuffix}";
return await CreateContentTypeSchema(
schemaId,
PublishedItemType.Media,
contentType,
derivedTypeSchemas,
context,
cancellationToken);
},
cancellationToken);
return;
default:
// HACK: Some types with circular references (e.g. ApiBlockGridItem) get left
// inlined by the framework, breaking $ref resolution. Register them explicitly.
if (GetSchemaId(context.JsonTypeInfo) is not { } schemaId || !_handledSchemas.Add(schemaId))
{
return;
}
OpenApiDocument document = context.GetRequiredDocument();
document.AddComponent(schemaId, schema);
return;
}
}
private async Task ApplyPolymorphicContentType(
OpenApiSchema schema,
OpenApiSchemaTransformerContext context,
PublishedItemType itemType,
IEnumerable<ContentTypeSchemaInfo> contentTypes,
Func<ContentTypeSchemaInfo, List<IOpenApiSchema>, Task<OpenApiSchema>> contentTypeSchemaFactory,
CancellationToken cancellationToken)
{
List<IOpenApiSchema> derivedTypeSchemas = await ResolveDerivedTypeSchemas(
schema,
context,
cancellationToken);
OpenApiDocument document = context.GetRequiredDocument();
var typePropertyName = GetTypePropertyName(itemType);
schema.Discriminator = new OpenApiDiscriminator
{
PropertyName = typePropertyName,
Mapping = new Dictionary<string, OpenApiSchemaReference>(),
};
schema.OneOf ??= new List<IOpenApiSchema>();
foreach (ContentTypeSchemaInfo contentType in contentTypes)
{
OpenApiSchema contentTypeSchema = await contentTypeSchemaFactory(contentType, derivedTypeSchemas);
var schemaId = (string)contentTypeSchema.Metadata![SchemaIdMetadataKey];
schema.Discriminator.Mapping[contentType.Alias] = new OpenApiSchemaReference(schemaId, document);
schema.OneOf.Add(contentTypeSchema);
}
// Remove all schema properties that are now handled by the derived types
schema.AnyOf = null;
schema.Properties = null;
schema.Required = new HashSet<string> { typePropertyName };
}
/// <summary>
/// Creates and adds a schema to the OpenAPI document if it does not already exist.
/// </summary>
/// <remarks>A placeholder schema is added first to avoid recursion issues when generating schemas that reference themselves.</remarks>
private async Task<IOpenApiSchema> CreateSchema(
JsonTypeInfo jsonTypeInfo,
OpenApiSchemaTransformerContext context,
CancellationToken cancellationToken)
{
if (jsonTypeInfo.Type.IsArray || jsonTypeInfo.Kind == JsonTypeInfoKind.Enumerable)
{
Type elementType = jsonTypeInfo.ElementType ?? jsonTypeInfo.Type.GetElementType() ?? typeof(object);
JsonTypeInfo elementJsonTypeInfo = GetJsonTypeInfo(elementType);
IOpenApiSchema itemSchema = await CreateSchema(elementJsonTypeInfo, context, cancellationToken);
return new OpenApiSchema
{
Type = JsonSchemaType.Array,
Items = itemSchema,
};
}
var schemaId = GetSchemaId(jsonTypeInfo);
// Types that produce 'true' in JSON Schema (unconstrained: JsonNode, object, custom-converter types) should be inline {} rather than named components.
if (jsonTypeInfo.Kind == JsonTypeInfoKind.None && jsonTypeInfo.GetJsonSchemaAsNode().GetValueKind() == JsonValueKind.True)
{
return new OpenApiSchema();
}
// If this is one of the types we handle, and we already started generating it, return a placeholder
// to avoid circular reference issues.
// In the document transformer, these placeholders will be replaced with the actual schemas.
if (schemaId is not null && !_handledSchemas.Add(schemaId))
{
return GetPlaceholderSchema(schemaId);
}
OpenApiSchema schema;
try
{
schema = await context.GetOrCreateSchemaAsync(
jsonTypeInfo.Type,
cancellationToken: cancellationToken);
}
catch (Exception ex)
{
// Log the error but continue with a fallback schema to avoid failing the entire document generation.
// The fallback schema includes a description indicating the failure, making it visible to API consumers.
_logger.LogError(ex, "Failed to create OpenAPI schema for type {TypeName}", jsonTypeInfo.Type.FullName);
schema = new OpenApiSchema
{
Description = $"[Schema generation failed for type '{jsonTypeInfo.Type.FullName}'. See server logs for details.]",
};
}
if (schemaId is null)
{
return schema;
}
OpenApiDocument document = context.GetRequiredDocument();
document.AddComponent(schemaId, schema);
return new OpenApiSchemaReference(schemaId, document);
}
/// <summary>
/// Allows null at a property reference site without mutating any shared component schema.
/// Inline schemas have <c>null</c> OR-ed into their <c>type</c> flags; schema references and
/// recursive-ref placeholders are wrapped in a <c>oneOf</c> with an explicit null branch so the
/// shared component is left unchanged.
/// </summary>
private static IOpenApiSchema AsNullable(IOpenApiSchema schema)
{
if (schema is OpenApiSchema inline
&& inline.Metadata?.ContainsKey(RecursiveRefMetadataKey) is not true)
{
inline.Type |= JsonSchemaType.Null;
return inline;
}
return new OpenApiSchema
{
OneOf =
[
schema,
new OpenApiSchema { Type = JsonSchemaType.Null },
],
};
}
private static Task<OpenApiSchema> CreateContentTypeResponseSchema(
string schemaIdPrefix,
List<IOpenApiSchema> derivedTypeSchemas,
OpenApiSchemaTransformerContext context)
{
var schemaId = $"{schemaIdPrefix}{ResponseModelSuffix}";
OpenApiDocument document = context.GetRequiredDocument();
var schema = new OpenApiSchema
{
Type = JsonSchemaType.Object,
AllOf = [..derivedTypeSchemas, new OpenApiSchemaReference($"{schemaIdPrefix}{ModelSuffix}", document)],
Metadata = new Dictionary<string, object> { [SchemaIdMetadataKey] = schemaId },
};
document.AddComponent(schemaId, schema);
return Task.FromResult(schema);
}
private async Task<OpenApiSchema> CreateContentTypeSchema(
string schemaId,
PublishedItemType itemType,
ContentTypeSchemaInfo contentType,
List<IOpenApiSchema> derivedTypeSchemas,
OpenApiSchemaTransformerContext context,
CancellationToken cancellationToken)
{
var typePropertyName = GetTypePropertyName(itemType);
var schema = new OpenApiSchema
{
Type = JsonSchemaType.Object,
Properties = new Dictionary<string, IOpenApiSchema>
{
[typePropertyName] = new OpenApiSchema { Const = contentType.Alias },
["properties"] = await CreatePropertiesSchema(contentType, itemType, context, cancellationToken),
},
Required = new HashSet<string> { typePropertyName },
AllOf = derivedTypeSchemas.Count > 0 ? derivedTypeSchemas : null,
Metadata = new Dictionary<string, object> { [SchemaIdMetadataKey] = schemaId, },
};
OpenApiDocument document = context.GetRequiredDocument();
document.AddComponent(schemaId, schema);
return schema;
}
private async Task<OpenApiSchemaReference> CreatePropertiesSchema(
ContentTypeSchemaInfo contentType,
PublishedItemType itemType,
OpenApiSchemaTransformerContext context,
CancellationToken cancellationToken)
{
var schemaId = GetPropertiesModelSchemaId(contentType, itemType);
var propertiesSchema = new OpenApiSchema
{
Type = JsonSchemaType.Object,
AllOf =
[
..contentType.CompositionSchemaIds.Select(compositionSchemaId
=> GetPlaceholderSchema(GetCompositionPropertiesModelSchemaId(compositionSchemaId, itemType)))
],
Properties = await CreateContentTypeProperties(contentType, context, cancellationToken),
Metadata = new Dictionary<string, object> { [SchemaIdMetadataKey] = schemaId },
};
OpenApiDocument document = context.GetRequiredDocument();
document.AddComponent(schemaId, propertiesSchema);
return new OpenApiSchemaReference(schemaId, document);
}
private static string GetPropertiesModelSchemaId(ContentTypeSchemaInfo contentType, PublishedItemType itemType) =>
$"{contentType.SchemaId}{GetItemTypeSuffix(itemType, contentType.IsElement)}{PropertiesModelSuffix}";
private string GetCompositionPropertiesModelSchemaId(string compositionSchemaId, PublishedItemType itemType)
{
// Look up the composition's own IsElement so its reference points at the right
// generated schema (element-type compositions live under the Element suffix).
IReadOnlyCollection<ContentTypeSchemaInfo> candidates = itemType == PublishedItemType.Media ? MediaTypes : DocumentTypes;
ContentTypeSchemaInfo? composition = candidates.FirstOrDefault(c => c.SchemaId == compositionSchemaId);
var suffix = GetItemTypeSuffix(itemType, composition?.IsElement ?? false);
return $"{compositionSchemaId}{suffix}{PropertiesModelSuffix}";
}
private static string GetItemTypeSuffix(PublishedItemType itemType, bool isElement) =>
itemType switch
{
PublishedItemType.Media => MediaSuffix,
PublishedItemType.Content => isElement ? ElementSuffix : ContentSuffix,
_ => throw new NotSupportedException($"Unsupported PublishedItemType: {itemType}"),
};
private async Task<Dictionary<string, IOpenApiSchema>> CreateContentTypeProperties(
ContentTypeSchemaInfo contentType,
OpenApiSchemaTransformerContext context,
CancellationToken cancellationToken)
{
var properties = new Dictionary<string, IOpenApiSchema>();
foreach (ContentTypePropertySchemaInfo propertyInfo in contentType.Properties.Where(p => !p.Inherited))
{
IOpenApiSchema schema = await CreateSchema(
GetJsonTypeInfo(propertyInfo.DeliveryApiClrType),
context,
cancellationToken);
// Properties may be null (e.g. property added after content was last published).
// Nullability is applied at the reference site, never on a shared component schema.
properties[propertyInfo.Alias] = AsNullable(schema);
}
return properties;
}
private JsonTypeInfo GetJsonTypeInfo(Type type)
{
JsonTypeInfo? jsonTypeInfo = _jsonTypeInfoResolver.GetTypeInfo(type, _serializerOptions);
return jsonTypeInfo ?? throw new InvalidOperationException("Could not get JsonTypeInfo for type " + type.FullName);
}
private string GetTypePropertyName(PublishedItemType itemType)
{
var propertyName = itemType switch
{
PublishedItemType.Content => nameof(IApiElement.ContentType),
PublishedItemType.Media => nameof(IApiMedia.MediaType),
_ => throw new NotSupportedException($"Unsupported PublishedItemType: {itemType}"),
};
return _serializerOptions.PropertyNamingPolicy?.ConvertName(propertyName) ?? propertyName;
}
private static string? GetSchemaId(JsonTypeInfo type)
=> UmbracoSchemaIdGenerator.CreateSchemaReferenceId(type);
/// <summary>
/// Creates a temporary placeholder schema to break circular reference chains during schema generation.
/// </summary>
/// <remarks>
/// The placeholder contains metadata with the target schema ID. During the document transformation phase,
/// <see cref="ResolveSchemaReferences(OpenApiDocument, IOpenApiSchema)"/> will replace these placeholders with actual schema references.
/// </remarks>
/// <param name="schemaId">The ID of the schema this placeholder represents.</param>
/// <returns>A placeholder schema with metadata indicating the target schema reference.</returns>
private static OpenApiSchema GetPlaceholderSchema(string schemaId)
=> new()
{
Metadata = new Dictionary<string, object>
{
[RecursiveRefMetadataKey] = schemaId,
},
};
/// <summary>
/// Recursively resolves inline schemas into proper <c>$ref</c> references.
/// </summary>
/// <remarks>
/// This method is called during the document transformation phase (after all schemas have been generated).
/// It walks through all schema properties, allOf, oneOf, and anyOf collections, resolving two types of
/// inline schemas:
/// <list type="bullet">
/// <item>Circular reference placeholders created by <see cref="GetPlaceholderSchema"/> (marked with <c>x-recursive-ref</c>)</item>
/// <item>Componentized schemas that should be references (marked with <c>x-schema-id</c>)</item>
/// </list>
/// Each match is replaced with an <see cref="OpenApiSchemaReference"/> pointing to the actual schema in the document's components.
/// </remarks>
/// <param name="document">The OpenAPI document containing the registered schema components.</param>
/// <param name="schema">The schema to process (will be modified in place).</param>
private static void ResolveSchemaReferences(OpenApiDocument document, IOpenApiSchema schema)
{
// Replace in allOf, oneOf, anyOf
ResolveSchemaReferences(document, schema.AllOf);
ResolveSchemaReferences(document, schema.OneOf);
ResolveSchemaReferences(document, schema.AnyOf);
// Process array items
if (schema is OpenApiSchema { Items: OpenApiSchema itemsSchema } parentSchema)
{
parentSchema.Items = GetActualSchemaOrReference(document, itemsSchema, out var itemsReplaced);
if (!itemsReplaced)
{
ResolveSchemaReferences(document, itemsSchema);
}
}
if (schema.Properties is not { Count: > 0 })
{
return;
}
// Process properties
foreach (var propertyKey in schema.Properties.Keys)
{
IOpenApiSchema propertySchema = schema.Properties[propertyKey];
if (propertySchema is not OpenApiSchema innerSchema)
{
continue;
}
schema.Properties[propertyKey] = GetActualSchemaOrReference(document, innerSchema, out var replaced);
if (replaced)
{
continue;
}
// Recursive call to handle the property schema
ResolveSchemaReferences(document, innerSchema);
}
}
private static void ResolveSchemaReferences(OpenApiDocument document, IList<IOpenApiSchema>? schemas)
{
if (schemas is null || schemas.Count == 0)
{
return;
}
for (var i = 0; i < schemas.Count; i++)
{
IOpenApiSchema allOfSchema = schemas[i];
schemas[i] = GetActualSchemaOrReference(document, allOfSchema, out var replaced);
if (!replaced)
{
ResolveSchemaReferences(document, schemas[i]);
}
}
}
[return: NotNullIfNotNull(nameof(schema))]
private static IOpenApiSchema? GetActualSchemaOrReference(
OpenApiDocument document,
IOpenApiSchema? schema,
out bool replaced)
{
if (schema is not OpenApiSchema openApiSchema)
{
replaced = false;
return schema;
}
// Check if this is a placeholder schema (circular reference)
if (openApiSchema.Metadata?.TryGetValue(RecursiveRefMetadataKey, out var recursiveRefIdObj) == true
&& recursiveRefIdObj is string recursiveRefId)
{
replaced = true;
return new OpenApiSchemaReference(recursiveRefId, document);
}
// Check if this is a componentized schema that should be a $ref
// Only resolve if the component actually exists — the framework also sets x-schema-id on
// schemas that may not end up as components.
if (openApiSchema.Metadata?.TryGetValue(SchemaIdMetadataKey, out var schemaIdObj) == true
&& schemaIdObj is string schemaId
&& !string.IsNullOrEmpty(schemaId)
&& document.Components?.Schemas?.ContainsKey(schemaId) == true)
{
replaced = true;
return new OpenApiSchemaReference(schemaId, document);
}
replaced = false;
return schema;
}
private IReadOnlyCollection<ContentTypeSchemaInfo> FilterAllowedDocumentTypes(IReadOnlyCollection<ContentTypeSchemaInfo> documentTypes)
{
DeliveryApiSettings settings = _deliveryApiSettings.CurrentValue;
return documentTypes
.Where(c => settings.IsAllowedContentType(c.Alias))
.ToList();
}
/// <summary>
/// Returns the schemas to use as the <c>allOf</c> bases for each typed content type
/// schema in a polymorphic union. Prefers concrete derived types declared on the
/// interface via <c>[JsonDerivedType]</c>; when none are advertised, falls back to a
/// schema built from the interface's own properties.
/// </summary>
/// <remarks>
/// The fallback exists for media interfaces, whose concrete classes are internal in
/// Umbraco.Infrastructure and therefore cannot be referenced via <c>[JsonDerivedType]</c>
/// from Umbraco.Core.
/// </remarks>
private async Task<List<IOpenApiSchema>> ResolveDerivedTypeSchemas(
OpenApiSchema interfaceSchema,
OpenApiSchemaTransformerContext context,
CancellationToken cancellationToken)
{
List<IOpenApiSchema> derivedTypeSchemas = [];
foreach (JsonDerivedType derivedType in context.JsonTypeInfo.PolymorphismOptions?.DerivedTypes ?? [])
{
IOpenApiSchema derivedTypeSchema = await CreateSchema(
GetJsonTypeInfo(derivedType.DerivedType),
context,
cancellationToken);
derivedTypeSchemas.Add(derivedTypeSchema);
}
if (derivedTypeSchemas.Count == 0)
{
derivedTypeSchemas.Add(CreateBaseSchemaFromInterface(interfaceSchema, context));
}
return derivedTypeSchemas;
}
private static IOpenApiSchema CreateBaseSchemaFromInterface(
OpenApiSchema interfaceSchema,
OpenApiSchemaTransformerContext context)
{
// Append a "Base" marker so this schema stays distinct from the polymorphic union
// schema for the same interface (e.g. IApiMediaWithCropsResponseBaseModel vs.
// IApiMediaWithCropsResponseModel).
var baseSchemaId = $"{context.JsonTypeInfo.Type.Name}Base{ModelSuffix}";
OpenApiDocument document = context.GetRequiredDocument();
var baseSchema = new OpenApiSchema
{
Type = interfaceSchema.Type,
Properties = interfaceSchema.Properties,
Required = interfaceSchema.Required,
Metadata = new Dictionary<string, object> { [SchemaIdMetadataKey] = baseSchemaId },
};
document.AddComponent(baseSchemaId, baseSchema);
return new OpenApiSchemaReference(baseSchemaId, document);
}
}
@@ -1,104 +0,0 @@
using Microsoft.AspNetCore.OpenApi;
using Microsoft.OpenApi;
namespace Umbraco.Cms.Api.Delivery.OpenApi.Transformers;
internal abstract class DeliveryApiTransformerBase : IOpenApiOperationTransformer
{
/// <summary>
/// Gets the link to the relevant documentation section.
/// </summary>
protected abstract string DocumentationLink { get; }
/// <inheritdoc/>
public async Task TransformAsync(
OpenApiOperation operation,
OpenApiOperationTransformerContext context,
CancellationToken cancellationToken)
{
if (!ShouldApply(context))
{
return;
}
AddExpand(operation);
AddFields(operation);
await ApplyAsync(operation, context, cancellationToken);
}
/// <summary>
/// Determines whether the transformer should be applied for the given context.
/// </summary>
/// <param name="context">The operation transformer context.</param>
/// <returns>>True if the transformer should be applied; otherwise, false.</returns>
protected abstract bool ShouldApply(OpenApiOperationTransformerContext context);
/// <summary>
/// Applies the specific transformations to the OpenAPI operation.
/// </summary>
/// <param name="operation">The <see cref="OpenApiOperation"/> to modify.</param>
/// <param name="context">The <see cref="OpenApiOperationTransformerContext"/> associated with the <see paramref="operation"/>.</param>
/// <param name="cancellationToken">The cancellation token to use.</param>
/// <returns>The task object representing the asynchronous operation.</returns>
protected abstract Task ApplyAsync(
OpenApiOperation operation,
OpenApiOperationTransformerContext context,
CancellationToken cancellationToken);
private void AddExpand(OpenApiOperation operation)
{
operation.Parameters ??= new List<IOpenApiParameter>();
operation.Parameters.Add(
new OpenApiParameter
{
Name = "expand",
In = ParameterLocation.Query,
Required = false,
Description = QueryParameterDescription("Defines the properties that should be expanded in the response"),
Schema = new OpenApiSchema { Type = JsonSchemaType.String },
Examples = new Dictionary<string, IOpenApiExample>
{
{ "Expand none", new OpenApiExample { Value = "" } },
{ "Expand all properties", new OpenApiExample { Value = "properties[$all]" } },
{ "Expand specific property", new OpenApiExample { Value = "properties[alias1]" } },
{ "Expand specific properties", new OpenApiExample { Value = "properties[alias1,alias2]" } },
{ "Expand nested properties", new OpenApiExample { Value = "properties[alias1[properties[nestedAlias1,nestedAlias2]]]" } },
},
});
}
private void AddFields(OpenApiOperation operation)
{
operation.Parameters ??= new List<IOpenApiParameter>();
operation.Parameters.Add(
new OpenApiParameter
{
Name = "fields",
In = ParameterLocation.Query,
Required = false,
Description =
QueryParameterDescription("Explicitly defines which properties should be included in the response (by default all properties are included)"),
Schema = new OpenApiSchema { Type = JsonSchemaType.String },
Examples = new Dictionary<string, IOpenApiExample>
{
{ "Include all properties", new OpenApiExample { Value = "properties[$all]" } },
{ "Include only specific property", new OpenApiExample { Value = "properties[alias1]" } },
{ "Include only specific properties", new OpenApiExample { Value = "properties[alias1,alias2]" } },
{ "Include only specific nested properties", new OpenApiExample { Value = "properties[alias1[properties[nestedAlias1,nestedAlias2]]]" } },
},
});
}
protected void AddQueryParameterDocumentation(OpenApiParameter parameter, Dictionary<string, IOpenApiExample> examples, string description)
{
parameter.Description = QueryParameterDescription(description);
parameter.Examples = examples;
}
protected string PaginationDescription(bool skip, string itemType)
=> $"Specifies the number of found {itemType} items to {(skip ? "skip" : "take")}. Use this to control pagination of the response.";
private string QueryParameterDescription(string description)
=> $"{description}. Refer to [the documentation]({DocumentationLink}#query-parameters) for more details on this.";
}
@@ -1,53 +0,0 @@
using Microsoft.AspNetCore.OpenApi;
using Microsoft.OpenApi;
using Umbraco.Cms.Api.Common.Security;
namespace Umbraco.Cms.Api.Delivery.OpenApi.Transformers;
/// <summary>
/// Transformer that adds member authentication security requirements to OpenAPI documents.
/// </summary>
internal class MemberAuthenticationSecurityRequirementsTransformer : IOpenApiOperationTransformer, IOpenApiDocumentTransformer
{
private const string AuthSchemeName = "UmbracoMember";
/// <inheritdoc />
public Task TransformAsync(
OpenApiDocument document,
OpenApiDocumentTransformerContext context,
CancellationToken cancellationToken)
{
var securityScheme = new OpenApiSecurityScheme
{
In = ParameterLocation.Header,
Name = AuthSchemeName,
Type = SecuritySchemeType.OAuth2,
Description = "Umbraco Member Authentication",
Flows = new OpenApiOAuthFlows
{
AuthorizationCode = new OpenApiOAuthFlow
{
AuthorizationUrl = new Uri(Paths.MemberApi.AuthorizationEndpoint, UriKind.Relative),
TokenUrl = new Uri(Paths.MemberApi.TokenEndpoint, UriKind.Relative),
},
},
};
document.AddComponent(AuthSchemeName, securityScheme);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task TransformAsync(
OpenApiOperation operation,
OpenApiOperationTransformerContext context,
CancellationToken cancellationToken)
{
var schemaRef = new OpenApiSecuritySchemeReference(AuthSchemeName, context.Document);
operation.Security ??= new List<OpenApiSecurityRequirement>();
operation.Security.Add(new OpenApiSecurityRequirement { [schemaRef] = [] });
return Task.CompletedTask;
}
}
@@ -30,7 +30,7 @@ internal sealed class DeliveryApiItemsEndpointsMatcherPolicy : MatcherPolicy, IE
public Task ApplyAsync(HttpContext httpContext, CandidateSet candidates)
{
var hasIdQueryParameter = httpContext.Request.Query.ContainsKey("id");
ApiVersion? requestedApiVersion = httpContext.RequestedApiVersion;
ApiVersion? requestedApiVersion = httpContext.GetRequestedApiVersion();
for (var i = 0; i < candidates.Count; i++)
{
CandidateState candidate = candidates[i];
@@ -18,7 +18,6 @@ internal sealed class RequestRedirectService : RoutingServiceBase, IRequestRedir
private readonly IRedirectUrlService _redirectUrlService;
private readonly IApiPublishedContentCache _apiPublishedContentCache;
private readonly IApiContentRouteBuilder _apiContentRouteBuilder;
private readonly IDocumentUrlService _documentUrlService;
private readonly GlobalSettings _globalSettings;
public RequestRedirectService(
@@ -29,15 +28,13 @@ internal sealed class RequestRedirectService : RoutingServiceBase, IRequestRedir
IRedirectUrlService redirectUrlService,
IApiPublishedContentCache apiPublishedContentCache,
IApiContentRouteBuilder apiContentRouteBuilder,
IOptions<GlobalSettings> globalSettings,
IDocumentUrlService documentUrlService)
IOptions<GlobalSettings> globalSettings)
: base(domainCache, httpContextAccessor, requestStartItemProviderAccessor)
{
_requestCultureService = requestCultureService;
_redirectUrlService = redirectUrlService;
_apiPublishedContentCache = apiPublishedContentCache;
_apiContentRouteBuilder = apiContentRouteBuilder;
_documentUrlService = documentUrlService;
_globalSettings = globalSettings.Value;
}
@@ -46,19 +43,16 @@ internal sealed class RequestRedirectService : RoutingServiceBase, IRequestRedir
requestedPath = requestedPath.EnsureStartsWith("/");
IPublishedContent? startItem = GetStartItem();
var culture = _requestCultureService.GetRequestedCulture();
// must append the root content url segment if it is not hidden by config, because
// the URL tracking is based on the actual URL, including the root content url segment
if (_globalSettings.HideTopLevelNodeFromPath == false && startItem is not null)
if (_globalSettings.HideTopLevelNodeFromPath == false && startItem?.UrlSegment != null)
{
var startItemUrlSegment = _documentUrlService.GetUrlSegment(startItem.Key, culture ?? string.Empty, isDraft: false);
if (startItemUrlSegment is not null)
{
requestedPath = $"{startItemUrlSegment.EnsureStartsWith("/")}{requestedPath}";
}
requestedPath = $"{startItem.UrlSegment.EnsureStartsWith("/")}{requestedPath}";
}
var culture = _requestCultureService.GetRequestedCulture();
// important: redirect URLs are always tracked without trailing slashes
requestedPath = requestedPath.TrimEnd("/");
IRedirectUrl? redirectUrl = _redirectUrlService.GetMostRecentRedirectUrl(requestedPath, culture);
@@ -3,7 +3,7 @@ using Umbraco.Cms.Core.DeliveryApi;
namespace Umbraco.Cms.Api.Delivery.Services;
internal sealed class RequestSegmentService : RequestHeaderHandler, IRequestSegmentService
internal sealed class RequestSegmentService : RequestHeaderHandler, IRequestSegmentService, IRequestSegmmentService
{
public RequestSegmentService(IHttpContextAccessor httpContextAccessor)
: base(httpContextAccessor)
@@ -3,7 +3,6 @@ using Umbraco.Cms.Core;
using Umbraco.Cms.Core.DeliveryApi;
using Umbraco.Cms.Core.Models.PublishedContent;
using Umbraco.Cms.Core.PublishedCache;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Services.Navigation;
using Umbraco.Extensions;
@@ -15,7 +14,6 @@ internal sealed class RequestStartItemProvider : RequestHeaderHandler, IRequestS
private readonly IRequestPreviewService _requestPreviewService;
private readonly IDocumentNavigationQueryService _documentNavigationQueryService;
private readonly IPublishedContentCache _publishedContentCache;
private readonly IDocumentUrlService _documentUrlService;
// this provider lifetime is Scope, so we can cache this as a field
private IPublishedContent? _requestedStartContent;
@@ -25,15 +23,14 @@ internal sealed class RequestStartItemProvider : RequestHeaderHandler, IRequestS
IVariationContextAccessor variationContextAccessor,
IRequestPreviewService requestPreviewService,
IDocumentNavigationQueryService documentNavigationQueryService,
IPublishedContentCache publishedContentCache,
IDocumentUrlService documentUrlService)
IPublishedContentCache publishedContentCache)
: base(httpContextAccessor)
{
_variationContextAccessor = variationContextAccessor;
_requestPreviewService = requestPreviewService;
_documentNavigationQueryService = documentNavigationQueryService;
_publishedContentCache = publishedContentCache;
_documentUrlService = documentUrlService;
}
/// <inheritdoc/>
@@ -50,16 +47,14 @@ internal sealed class RequestStartItemProvider : RequestHeaderHandler, IRequestS
return null;
}
var isPreview = _requestPreviewService.IsPreview();
_documentNavigationQueryService.TryGetRootKeys(out IEnumerable<Guid> rootKeys);
IEnumerable<IPublishedContent> rootContent = rootKeys
.Select(rootKey => _publishedContentCache.GetById(isPreview, rootKey))
.Select(rootKey => _publishedContentCache.GetById(_requestPreviewService.IsPreview(), rootKey))
.WhereNotNull();
var culture = _variationContextAccessor.VariationContext?.Culture ?? string.Empty;
_requestedStartContent = Guid.TryParse(headerValue, out Guid key)
? rootContent.FirstOrDefault(c => c.Key == key)
: rootContent.FirstOrDefault(c => _documentUrlService.GetUrlSegment(c.Key, culture, isPreview).InvariantEquals(headerValue));
: rootContent.FirstOrDefault(c => c.UrlSegment(_variationContextAccessor).InvariantEquals(headerValue));
return _requestedStartContent;
}
+8 -9
View File
@@ -20,7 +20,7 @@ RESTful API for Umbraco backoffice operations. Manages content, media, users, an
### Key Technologies
- **Web Framework**: ASP.NET Core MVC with `Asp.Versioning.Mvc` (v1.0 currently)
- **OpenAPI**: Microsoft.AspNetCore.OpenApi with custom transformers, Swagger UI via Swashbuckle
- **OpenAPI**: Swashbuckle.AspNetCore with custom schema/operation filters
- **Authentication**: OpenIddict via `Umbraco.Cms.Api.Common` (reference tokens, not JWT)
- **Authorization**: Policy-based with `IAuthorizationService`
- **Validation**: FluentValidation via base controllers
@@ -57,7 +57,7 @@ src/Umbraco.Cms.Api.Management/
├── Services/ # Business logic (thin layer over Core.Services)
├── Mapping/ # ViewModel → domain model mappers
├── Security/ # Auth providers, sign-in manager, external logins
├── OpenApi/ # OpenAPI transformers (schema, operation, security)
├── OpenApi/ # Swashbuckle filters (schema, operation, security)
├── Routing/ # Route configuration, SignalR hubs
├── DependencyInjection/ # Service registration (55+ files)
├── Middleware/ # Preview, server events
@@ -71,8 +71,7 @@ src/Umbraco.Cms.Api.Management/
- **Umbraco.Cms.Api.Common** - Shared API infrastructure (base controllers, OpenAPI config)
- **Umbraco.Infrastructure** - Service implementations, data access
- **Umbraco.PublishedCache.HybridCache** - Published content queries
- **Microsoft.AspNetCore.OpenApi** - OpenAPI document generation
- **Swashbuckle.AspNetCore.SwaggerUI** - Swagger UI
- **Swashbuckle.AspNetCore** - OpenAPI generation
### Design Patterns
1. **Controller-per-Operation** - Each endpoint is a separate controller class
@@ -123,8 +122,8 @@ dotnet build src/Umbraco.Cms.Api.Management /p:TreatWarningsAsErrors=true
### OpenAPI Documentation
The project embeds a pre-generated `OpenApi.json` (1.3MB). To regenerate:
```bash
# Run Umbraco.Web.UI, access /umbraco/openapi/
# Download JSON from /umbraco/openapi/management.json
# Run Umbraco.Web.UI, access /umbraco/swagger
# Export JSON from Swagger UI
```
### Package Management
@@ -202,7 +201,7 @@ dotnet test --filter "FullyQualifiedName~Management.Controllers.Document"
1. **Controller logic** - Request validation, authorization checks, status code mapping
2. **Factories** - ViewModel ↔ Domain model conversion accuracy
3. **Authorization** - Policy enforcement for each operation
4. **OpenAPI schema** - Ensure OpenAPI document generation doesn't break
4. **OpenAPI schema** - Ensure Swagger generation doesn't break
### InternalsVisibleTo
Tests have access to internal types (see .csproj:44-52):
@@ -466,7 +465,7 @@ TODO: [NL] This must return path segments for a query to work
1. All tests pass
2. Code formatted (`dotnet format`)
3. No new warnings (check suppressed warnings list in .csproj:23)
4. OpenAPI schema valid (check at `/umbraco/openapi/`)
4. OpenAPI schema valid (run Swagger UI)
5. Authorization tested (unit + integration tests)
### Common Pitfalls
@@ -613,7 +612,7 @@ Examples:
- `PUT /umbraco/management/api/v1/document/{id}` - Update document
- `DELETE /umbraco/management/api/v1/document/{id}` - Delete document
Full spec: See OpenApi.json or Swagger UI at `/umbraco/openapi/`
Full spec: See OpenApi.json or Swagger UI at `/umbraco/swagger`
### Getting Help
- **Root Documentation**: `/CLAUDE.md` (repository overview)
@@ -0,0 +1,18 @@
<?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>
@@ -0,0 +1,79 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Microsoft.OpenApi;
using Swashbuckle.AspNetCore.SwaggerGen;
using Umbraco.Cms.Api.Common.Security;
using Umbraco.Cms.Api.Common.Serialization;
using Umbraco.Cms.Api.Management.DependencyInjection;
using Umbraco.Cms.Api.Management.OpenApi;
namespace Umbraco.Cms.Api.Management.Configuration;
/// <summary>
/// Provides configuration for Swagger generation options specific to the Umbraco Management API.
/// This class is used to customize the Swagger documentation for the API endpoints.
/// </summary>
public class ConfigureUmbracoManagementApiSwaggerGenOptions : IConfigureOptions<SwaggerGenOptions>
{
private readonly IUmbracoJsonTypeInfoResolver _umbracoJsonTypeInfoResolver;
/// <summary>
/// Initializes a new instance of the <see cref="ConfigureUmbracoManagementApiSwaggerGenOptions"/> class.
/// </summary>
/// <param name="umbracoJsonTypeInfoResolver">An instance of <see cref="IUmbracoJsonTypeInfoResolver"/> used to resolve JSON type information for Umbraco.</param>
public ConfigureUmbracoManagementApiSwaggerGenOptions(IUmbracoJsonTypeInfoResolver umbracoJsonTypeInfoResolver)
{
_umbracoJsonTypeInfoResolver = umbracoJsonTypeInfoResolver;
}
/// <summary>
/// Configures the <see cref="SwaggerGenOptions"/> for the Umbraco Management API.
/// Sets up the Swagger documentation, including API metadata, security definitions for OAuth2 authentication,
/// operation filters for response headers and security requirements, and schema filters for non-nullable properties.
/// Also configures polymorphism handling and discriminator properties for OpenAPI schemas.
/// </summary>
/// <param name="swaggerGenOptions">The <see cref="SwaggerGenOptions"/> instance to configure for the Management API.</param>
public void Configure(SwaggerGenOptions swaggerGenOptions)
{
swaggerGenOptions.SwaggerDoc(
ManagementApiConfiguration.ApiName,
new OpenApiInfo
{
Title = ManagementApiConfiguration.ApiTitle,
Version = "Latest",
Description = "This shows all APIs available in this version of Umbraco - including all the legacy apis that are available for backward compatibility",
});
swaggerGenOptions.OperationFilter<ResponseHeaderOperationFilter>();
swaggerGenOptions.UseOneOfForPolymorphism();
// Ensure all types that implements the IOpenApiDiscriminator have a $type property in the OpenApi schema with the default value (The class name) that is expected by the server
swaggerGenOptions.SelectDiscriminatorNameUsing(type => _umbracoJsonTypeInfoResolver.GetTypeDiscriminatorValue(type) is not null ? "$type" : null);
swaggerGenOptions.SelectDiscriminatorValueUsing(_umbracoJsonTypeInfoResolver.GetTypeDiscriminatorValue);
swaggerGenOptions.AddSecurityDefinition(
ManagementApiConfiguration.ApiSecurityName,
new OpenApiSecurityScheme
{
In = ParameterLocation.Header,
Name = "Umbraco",
Type = SecuritySchemeType.OAuth2,
Description = "Umbraco Authentication",
Flows = new OpenApiOAuthFlows
{
AuthorizationCode = new OpenApiOAuthFlow
{
AuthorizationUrl =
new Uri(Paths.BackOfficeApi.AuthorizationEndpoint, UriKind.Relative),
TokenUrl = new Uri(Paths.BackOfficeApi.TokenEndpoint, UriKind.Relative),
},
},
});
// Sets Security requirement on backoffice apis
swaggerGenOptions.OperationFilter<BackOfficeSecurityRequirementsOperationFilter>();
swaggerGenOptions.OperationFilter<NotificationHeaderFilter>();
swaggerGenOptions.SchemaFilter<RequireNonNullablePropertiesSchemaFilter>();
}
}
@@ -1,9 +1,7 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Api.Management.ViewModels.Document;
using Umbraco.Cms.Core.Models.ContentEditing;
using Umbraco.Cms.Core.Models.ContentEditing.Validation;
using Umbraco.Cms.Core.Models.ContentPublishing;
using Umbraco.Cms.Core.PropertyEditors.Validation;
using Umbraco.Cms.Core.Services.OperationStatus;
using Umbraco.Extensions;
@@ -15,8 +13,6 @@ namespace Umbraco.Cms.Api.Management.Controllers.Content;
/// </summary>
public abstract class ContentControllerBase : ManagementApiControllerBase
{
protected abstract string EntityName { get; }
protected IActionResult ContentEditingOperationStatusResult(ContentEditingOperationStatus status)
=> OperationStatusResult(status, problemDetailsBuilder => status switch
{
@@ -94,12 +90,12 @@ public abstract class ContentControllerBase : ManagementApiControllerBase
.WithDetail("The supplied name is already in use for the same content type.")
.Build()),
ContentEditingOperationStatus.CannotDeleteWhenReferenced => BadRequest(problemDetailsBuilder
.WithTitle($"Cannot delete a referenced {EntityName}")
.WithDetail($"Cannot delete a referenced {EntityName}, while the setting ContentSettings.DisableDeleteWhenReferenced is enabled.")
.WithTitle("Cannot delete a referenced content item")
.WithDetail("Cannot delete a referenced content item, while the setting ContentSettings.DisableDeleteWhenReferenced is enabled.")
.Build()),
ContentEditingOperationStatus.CannotMoveToRecycleBinWhenReferenced => BadRequest(problemDetailsBuilder
.WithTitle($"Cannot move a referenced {EntityName} to the recycle bin")
.WithDetail($"Cannot move a referenced {EntityName} to the recycle bin, while the setting ContentSettings.DisableUnpublishWhenReferenced is enabled.")
.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.")
.Build()),
ContentEditingOperationStatus.Unknown => StatusCode(
StatusCodes.Status500InternalServerError,
@@ -122,122 +118,6 @@ public abstract class ContentControllerBase : ManagementApiControllerBase
.Build()),
});
protected IActionResult ContentPublishingOperationStatusResult(
ContentPublishingOperationStatus status,
IEnumerable<string>? invalidPropertyAliases = null,
IEnumerable<ContentPublishingBranchItemResult>? failedBranchItems = null)
=> OperationStatusResult(
status,
problemDetailsBuilder => status switch
{
ContentPublishingOperationStatus.ContentNotFound => NotFound(problemDetailsBuilder
.WithTitle($"The requested {EntityName} could not be found")
.Build()),
ContentPublishingOperationStatus.CancelledByEvent => BadRequest(problemDetailsBuilder
.WithTitle("Publish cancelled by event")
.WithDetail("The publish operation was cancelled by an event.")
.Build()),
ContentPublishingOperationStatus.ContentInvalid => BadRequest(problemDetailsBuilder
.WithTitle($"Invalid {EntityName}")
.WithDetail($"The specified {EntityName} had an invalid configuration.")
.WithExtension("invalidProperties", invalidPropertyAliases ?? Enumerable.Empty<string>())
.Build()),
ContentPublishingOperationStatus.NothingToPublish => BadRequest(problemDetailsBuilder
.WithTitle("Nothing to publish")
.WithDetail("None of the specified cultures needed publishing.")
.Build()),
ContentPublishingOperationStatus.MandatoryCultureMissing => BadRequest(problemDetailsBuilder
.WithTitle("Mandatory culture missing")
.WithDetail("Must include all mandatory cultures when publishing.")
.Build()),
ContentPublishingOperationStatus.HasExpired => BadRequest(problemDetailsBuilder
.WithTitle($"{EntityName.ToFirstUpperInvariant()} expired")
.WithDetail($"Could not publish the {EntityName} because it was expired.")
.Build()),
ContentPublishingOperationStatus.CultureHasExpired => BadRequest(problemDetailsBuilder
.WithTitle($"{EntityName.ToFirstUpperInvariant()} culture expired")
.WithDetail($"Could not publish the {EntityName} because some of the specified cultures were expired.")
.Build()),
ContentPublishingOperationStatus.AwaitingRelease => BadRequest(problemDetailsBuilder
.WithTitle($"{EntityName.ToFirstUpperInvariant()} awaiting release")
.WithDetail($"Could not publish the {EntityName} because it was awaiting release.")
.Build()),
ContentPublishingOperationStatus.CultureAwaitingRelease => BadRequest(problemDetailsBuilder
.WithTitle($"{EntityName.ToFirstUpperInvariant()} culture awaiting release")
.WithDetail(
$"Could not publish the {EntityName} because some of the specified cultures were awaiting release.")
.Build()),
ContentPublishingOperationStatus.InTrash => BadRequest(problemDetailsBuilder
.WithTitle($"{EntityName.ToFirstUpperInvariant()} in the recycle bin")
.WithDetail($"Could not publish the {EntityName} because it was in the recycle bin.")
.Build()),
ContentPublishingOperationStatus.PathNotPublished => BadRequest(problemDetailsBuilder
.WithTitle("Parent not published")
.WithDetail($"Could not publish the {EntityName} because its parent was not published.")
.Build()),
ContentPublishingOperationStatus.InvalidCulture => BadRequest(problemDetailsBuilder
.WithTitle("Invalid cultures specified")
.WithDetail("A specified culture is not valid for the operation.")
.Build()),
ContentPublishingOperationStatus.CultureMissing => BadRequest(problemDetailsBuilder
.WithTitle("Culture missing")
.WithDetail("A culture needs to be specified to execute the operation.")
.Build()),
ContentPublishingOperationStatus.CannotPublishInvariantWhenVariant => BadRequest(problemDetailsBuilder
.WithTitle("Cannot publish invariant when variant")
.WithDetail($"Cannot publish invariant culture when the {EntityName} varies by culture.")
.Build()),
ContentPublishingOperationStatus.CannotPublishVariantWhenNotVariant => BadRequest(problemDetailsBuilder
.WithTitle("Cannot publish variant when not variant.")
.WithDetail($"Cannot publish a given culture when the {EntityName} is invariant.")
.Build()),
ContentPublishingOperationStatus.ConcurrencyViolation => BadRequest(problemDetailsBuilder
.WithTitle("Concurrency violation detected")
.WithDetail("An attempt was made to publish a version older than the latest version.")
.Build()),
ContentPublishingOperationStatus.UnsavedChanges => BadRequest(problemDetailsBuilder
.WithTitle("Unsaved changes")
.WithDetail(
$"Could not publish the {EntityName} because it had unsaved changes. Make sure to save all changes before attempting a publish.")
.Build()),
ContentPublishingOperationStatus.UnpublishTimeNeedsToBeAfterPublishTime => BadRequest(problemDetailsBuilder
.WithTitle("Unpublish time needs to be after the publish time")
.WithDetail(
"Cannot handle an unpublish time that is not after the specified publish time.")
.Build()),
ContentPublishingOperationStatus.PublishTimeNeedsToBeInFuture => BadRequest(problemDetailsBuilder
.WithTitle("Publish time needs to be higher than the current time")
.WithDetail(
"Cannot handle a publish time that is not after the current server time.")
.Build()),
ContentPublishingOperationStatus.UpublishTimeNeedsToBeInFuture => BadRequest(problemDetailsBuilder
.WithTitle("Unpublish time needs to be higher than the current time")
.WithDetail(
"Cannot handle an unpublish time that is not after the current server time.")
.Build()),
ContentPublishingOperationStatus.CannotUnpublishWhenReferenced => BadRequest(problemDetailsBuilder
.WithTitle($"Cannot unpublish {EntityName} when it's referenced somewhere else.")
.WithDetail(
$"Cannot unpublish a referenced {EntityName}, while the setting ContentSettings.DisableUnpublishWhenReferenced is enabled.")
.Build()),
ContentPublishingOperationStatus.FailedBranch => BadRequest(problemDetailsBuilder
.WithTitle("Failed branch operation")
.WithDetail("One or more items in the branch could not complete the operation.")
.WithExtension("failedBranchItems", failedBranchItems?.Select(item => new DocumentPublishBranchItemResult { Id = item.Key, OperationStatus = item.OperationStatus }) ?? [])
.Build()),
ContentPublishingOperationStatus.Failed => BadRequest(
problemDetailsBuilder
.WithTitle("Publish or unpublish failed")
.WithDetail(
"An unspecified error occurred while (un)publishing. Please check the logs for additional information.")
.Build()),
ContentPublishingOperationStatus.TaskResultNotFound => NotFound(problemDetailsBuilder
.WithTitle("The result of the submitted task could not be found")
.Build()),
_ => StatusCode(StatusCodes.Status500InternalServerError, "Unknown content operation status."),
});
protected IActionResult ContentEditingOperationStatusResult<TContentModelBase, TValueModel, TVariantModel>(
ContentEditingOperationStatus status,
TContentModelBase requestModel,
@@ -15,7 +15,7 @@ namespace Umbraco.Cms.Api.Management.Controllers.DataType;
/// </summary>
[VersionedApiBackOfficeRoute(Constants.UdiEntityType.DataType)]
[ApiExplorerSettings(GroupName = "Data Type")]
[Authorize(Policy = AuthorizationPolicies.TreeAccessDocumentsOrElementsOrMediaOrMembersOrContentTypes)]
[Authorize(Policy = AuthorizationPolicies.TreeAccessDocumentsOrMediaOrMembersOrContentTypes)]
public abstract class DataTypeControllerBase : ManagementApiControllerBase
{
protected IActionResult DataTypeOperationStatusResult(DataTypeOperationStatus status) =>
@@ -53,11 +53,14 @@ public class SearchDataTypeItemController : DatatypeItemControllerBase
return Ok(new PagedModel<DataTypeItemResponseModel> { Total = searchResult.Total });
}
IEnumerable<IDataType> dataTypes = await _dataTypeService.GetAllAsync(searchResult.Items.Select(item => item.Key).ToArray());
Guid[] keys = searchResult.Items.Select(x => x.Key).ToArray();
IEnumerable<IDataType> dataTypes = await _dataTypeService.GetAllAsync(keys);
IEnumerable<IDataType> orderedDataTypes = OrderByRequestedIds(dataTypes, keys);
var result = new PagedModel<DataTypeItemResponseModel>
{
Items = _mapper.MapEnumerable<IDataType, DataTypeItemResponseModel>(dataTypes),
Total = searchResult.Total
Items = _mapper.MapEnumerable<IDataType, DataTypeItemResponseModel>(orderedDataTypes),
Total = searchResult.Total,
};
return Ok(result);
@@ -14,6 +14,24 @@ namespace Umbraco.Cms.Api.Management.Controllers.DataType.Tree;
[ApiVersion("1.0")]
public class AncestorsDataTypeTreeController : DataTypeTreeControllerBase
{
/// <summary>
/// Initializes a new instance of the <see cref="AncestorsDataTypeTreeController"/> class, which provides API endpoints for retrieving ancestor data types in the tree structure.
/// </summary>
/// <param name="entityService">Service used for entity operations within the API.</param>
/// <param name="dataTypeService">Service used for data type management and retrieval.</param>
[Obsolete("Please use the constructor taking all parameters. Scheduled for removal in Umbraco 18.")]
public AncestorsDataTypeTreeController(IEntityService entityService, IDataTypeService dataTypeService)
: base(entityService, dataTypeService)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="AncestorsDataTypeTreeController"/> class, which manages operations related to ancestor data type trees in the Umbraco CMS.
/// </summary>
/// <param name="entityService">Service used for entity-related operations.</param>
/// <param name="flagProviders">A collection of providers that supply flags for tree nodes.</param>
/// <param name="dataTypeService">Service used for data type management operations.</param>
[ActivatorUtilitiesConstructor]
public AncestorsDataTypeTreeController(IEntityService entityService, FlagProviderCollection flagProviders, IDataTypeService dataTypeService)
: base(entityService, flagProviders, dataTypeService)
{
@@ -15,6 +15,24 @@ namespace Umbraco.Cms.Api.Management.Controllers.DataType.Tree;
[ApiVersion("1.0")]
public class ChildrenDataTypeTreeController : DataTypeTreeControllerBase
{
/// <summary>
/// Initializes a new instance of the <see cref="ChildrenDataTypeTreeController"/> class.
/// </summary>
/// <param name="entityService">Service used for managing and retrieving entities within the system.</param>
/// <param name="dataTypeService">Service used for managing and retrieving data types.</param>
[Obsolete("Please use the constructor taking all parameters. Scheduled for removal in Umbraco 18.")]
public ChildrenDataTypeTreeController(IEntityService entityService, IDataTypeService dataTypeService)
: base(entityService, dataTypeService)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="ChildrenDataTypeTreeController"/> class.
/// </summary>
/// <param name="entityService">Service used for managing and retrieving entities within the system.</param>
/// <param name="flagProviders">A collection of providers that supply additional flags or metadata for entities.</param>
/// <param name="dataTypeService">Service responsible for operations related to data types.</param>
[ActivatorUtilitiesConstructor]
public ChildrenDataTypeTreeController(IEntityService entityService, FlagProviderCollection flagProviders, IDataTypeService dataTypeService)
: base(entityService, flagProviders, dataTypeService)
{
@@ -25,6 +25,26 @@ public class DataTypeTreeControllerBase : FolderTreeControllerBase<DataTypeTreeI
{
private readonly IDataTypeService _dataTypeService;
/// <summary>
/// Initializes a new instance of the <see cref="DataTypeTreeControllerBase"/> class.
/// </summary>
/// <param name="entityService">Service for managing Umbraco entities.</param>
/// <param name="dataTypeService">Service for managing data types within Umbraco.</param>
[Obsolete("Please use the constructor taking all parameters. Scheduled for removal in Umbraco 18.")]
public DataTypeTreeControllerBase(IEntityService entityService, IDataTypeService dataTypeService)
: this(
entityService,
StaticServiceProvider.Instance.GetRequiredService<FlagProviderCollection>(),
dataTypeService)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="DataTypeTreeControllerBase"/> class with the specified services.
/// </summary>
/// <param name="entityService">Service used for entity operations within the data type tree.</param>
/// <param name="flagProviders">A collection of providers that supply flags for entities.</param>
/// <param name="dataTypeService">Service used for managing data types.</param>
[Obsolete("Please use the constructor taking all parameters. Scheduled for removal in Umbraco 19.")]
public DataTypeTreeControllerBase(IEntityService entityService, FlagProviderCollection flagProviders, IDataTypeService dataTypeService)
: this(
@@ -60,17 +80,17 @@ public class DataTypeTreeControllerBase : FolderTreeControllerBase<DataTypeTreeI
}
}
protected override async Task<DataTypeTreeItemResponseModel[]> MapTreeItemViewModelsAsync(Guid? parentId, IEntitySlim[] entities)
protected override DataTypeTreeItemResponseModel[] MapTreeItemViewModels(Guid? parentId, IEntitySlim[] entities)
{
Dictionary<int, IDataType> dataTypes = entities.Any()
? (await _dataTypeService
.GetAllAsync(entities.Select(entity => entity.Key).ToArray()))
? _dataTypeService
.GetAllAsync(entities.Select(entity => entity.Key).ToArray()).GetAwaiter().GetResult()
.ToDictionary(contentType => contentType.Id)
: new Dictionary<int, IDataType>();
IEnumerable<Task<DataTypeTreeItemResponseModel>> tasks = entities.Select(async entity =>
return entities.Select(entity =>
{
DataTypeTreeItemResponseModel responseModel = await MapTreeItemViewModelAsync(parentId, entity);
DataTypeTreeItemResponseModel responseModel = MapTreeItemViewModel(parentId, entity);
if (dataTypes.TryGetValue(entity.Id, out IDataType? dataType))
{
responseModel.EditorUiAlias = dataType.EditorUiAlias;
@@ -78,8 +98,6 @@ public class DataTypeTreeControllerBase : FolderTreeControllerBase<DataTypeTreeI
}
return responseModel;
});
return await Task.WhenAll(tasks);
}).ToArray();
}
}
@@ -15,6 +15,24 @@ namespace Umbraco.Cms.Api.Management.Controllers.DataType.Tree;
[ApiVersion("1.0")]
public class RootDataTypeTreeController : DataTypeTreeControllerBase
{
/// <summary>
/// Initializes a new instance of the <see cref="RootDataTypeTreeController"/> class.
/// </summary>
/// <param name="entityService">Service used for managing and retrieving entities within Umbraco.</param>
/// <param name="dataTypeService">Service used for managing data types in Umbraco.</param>
[Obsolete("Please use the constructor taking all parameters. Scheduled for removal in Umbraco 18.")]
public RootDataTypeTreeController(IEntityService entityService, IDataTypeService dataTypeService)
: base(entityService, dataTypeService)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="RootDataTypeTreeController"/> class, which manages the root of the data type tree in the Umbraco management API.
/// </summary>
/// <param name="entityService">Service used for entity operations within the tree.</param>
/// <param name="flagProviders">A collection of providers that supply flags for tree nodes.</param>
/// <param name="dataTypeService">Service used for data type management and retrieval.</param>
[ActivatorUtilitiesConstructor]
public RootDataTypeTreeController(IEntityService entityService, FlagProviderCollection flagProviders, IDataTypeService dataTypeService)
: base(entityService, flagProviders, dataTypeService)
{
@@ -13,6 +13,24 @@ namespace Umbraco.Cms.Api.Management.Controllers.DataType.Tree;
/// </summary>
public class SiblingsDataTypeTreeController : DataTypeTreeControllerBase
{
/// <summary>
/// Initializes a new instance of the <see cref="SiblingsDataTypeTreeController"/> class.
/// </summary>
/// <param name="entityService">Service used for managing and retrieving entities within Umbraco.</param>
/// <param name="dataTypeService">Service used for managing data types in Umbraco.</param>
[Obsolete("Please use the constructor taking all parameters. Scheduled for removal in Umbraco 18.")]
public SiblingsDataTypeTreeController(IEntityService entityService, IDataTypeService dataTypeService)
: base(entityService, dataTypeService)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="SiblingsDataTypeTreeController"/> class, which manages operations related to sibling data type trees in the Umbraco CMS.
/// </summary>
/// <param name="entityService">Service used for entity operations within the CMS.</param>
/// <param name="flagProviders">A collection of providers that supply flags for tree nodes.</param>
/// <param name="dataTypeService">Service used for managing data types.</param>
[ActivatorUtilitiesConstructor]
public SiblingsDataTypeTreeController(IEntityService entityService, FlagProviderCollection flagProviders, IDataTypeService dataTypeService)
: base(entityService, flagProviders, dataTypeService)
{
@@ -42,7 +42,7 @@ public class ExportDictionaryController : DictionaryControllerBase
/// </returns>
[HttpGet("{id:guid}/export")]
[MapToApiVersion("1.0")]
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK, MediaTypeNames.Application.Octet)]
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[EndpointSummary("Exports a dictionary.")]
[EndpointDescription("Exports the dictionary identified by the provided Id to a downloadable format.")]
@@ -14,6 +14,24 @@ namespace Umbraco.Cms.Api.Management.Controllers.Dictionary.Tree;
[ApiVersion("1.0")]
public class AncestorsDictionaryTreeController : DictionaryTreeControllerBase
{
/// <summary>
/// Initializes a new instance of the <see cref="AncestorsDictionaryTreeController"/> class.
/// </summary>
/// <param name="entityService">Service used for managing and retrieving entities within Umbraco.</param>
/// <param name="dictionaryItemService">Service used for managing dictionary items in the Umbraco dictionary tree.</param>
[Obsolete("Please use the constructor taking all parameters. Scheduled for removal in Umbraco 18.")]
public AncestorsDictionaryTreeController(IEntityService entityService, IDictionaryItemService dictionaryItemService)
: base(entityService, dictionaryItemService)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="AncestorsDictionaryTreeController"/> class, which handles operations related to retrieving ancestor dictionary tree items.
/// </summary>
/// <param name="entityService">The service used for entity operations.</param>
/// <param name="flagProviders">A collection of providers for entity flags.</param>
/// <param name="dictionaryItemService">The service used for dictionary item operations.</param>
[ActivatorUtilitiesConstructor]
public AncestorsDictionaryTreeController(IEntityService entityService, FlagProviderCollection flagProviders, IDictionaryItemService dictionaryItemService)
: base(entityService, flagProviders, dictionaryItemService)
{
@@ -16,6 +16,24 @@ namespace Umbraco.Cms.Api.Management.Controllers.Dictionary.Tree;
[ApiVersion("1.0")]
public class ChildrenDictionaryTreeController : DictionaryTreeControllerBase
{
/// <summary>
/// Initializes a new instance of the <see cref="ChildrenDictionaryTreeController"/> class.
/// </summary>
/// <param name="entityService">Service used for managing and retrieving entities within the system.</param>
/// <param name="dictionaryItemService">Service used for managing dictionary items.</param>
[Obsolete("Please use the constructor taking all parameters. Scheduled for removal in Umbraco 18.")]
public ChildrenDictionaryTreeController(IEntityService entityService, IDictionaryItemService dictionaryItemService)
: base(entityService, dictionaryItemService)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="ChildrenDictionaryTreeController"/> class.
/// </summary>
/// <param name="entityService">Service for managing and retrieving entities within Umbraco.</param>
/// <param name="flagProviders">A collection of providers that supply additional flags or metadata for entities.</param>
/// <param name="dictionaryItemService">Service for managing dictionary items used for localization.</param>
[ActivatorUtilitiesConstructor]
public ChildrenDictionaryTreeController(IEntityService entityService, FlagProviderCollection flagProviders, IDictionaryItemService dictionaryItemService)
: base(entityService, flagProviders, dictionaryItemService)
{
@@ -1,11 +1,13 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
using Umbraco.Cms.Api.Management.Controllers.Tree;
using Umbraco.Cms.Api.Management.Routing;
using Umbraco.Cms.Api.Management.Services.Flags;
using Umbraco.Cms.Api.Management.ViewModels;
using Umbraco.Cms.Api.Management.ViewModels.Tree;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Web.Common.Authorization;
@@ -23,6 +25,26 @@ namespace Umbraco.Cms.Api.Management.Controllers.Dictionary.Tree;
// tree controller base. We'll keep it though, in the hope that we can mend EntityService.
public class DictionaryTreeControllerBase : NamedEntityTreeControllerBase<NamedEntityTreeItemResponseModel>
{
/// <summary>
/// Initializes a new instance of the <see cref="DictionaryTreeControllerBase"/> class.
/// </summary>
/// <param name="entityService">Service used for managing and retrieving entities within the Umbraco system.</param>
/// <param name="dictionaryItemService">Service used for managing and retrieving dictionary items for localization.</param>
[Obsolete("Please use the constructor taking all parameters. Scheduled for removal in Umbraco 18.")]
public DictionaryTreeControllerBase(IEntityService entityService, IDictionaryItemService dictionaryItemService)
: this(
entityService,
StaticServiceProvider.Instance.GetRequiredService<FlagProviderCollection>(),
dictionaryItemService)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="DictionaryTreeControllerBase"/> class.
/// </summary>
/// <param name="entityService">Service for managing entities within the system.</param>
/// <param name="flagProviders">A collection of providers for entity flags.</param>
/// <param name="dictionaryItemService">Service for managing dictionary items.</param>
public DictionaryTreeControllerBase(
IEntityService entityService,
FlagProviderCollection flagProviders,
@@ -5,7 +5,6 @@ using Microsoft.Extensions.DependencyInjection;
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.DependencyInjection;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Services;
@@ -17,12 +16,13 @@ namespace Umbraco.Cms.Api.Management.Controllers.Dictionary.Tree;
[ApiVersion("1.0")]
public class RootDictionaryTreeController : DictionaryTreeControllerBase
{
[Obsolete("Please use the constructor taking all parameters. Scheduled for removal in Umbraco 20.")]
/// <summary>
/// Initializes a new instance of the <see cref="RootDictionaryTreeController"/> class.
/// </summary>
/// <param name="entityService">Service used for entity operations within the dictionary tree.</param>
/// <param name="dictionaryItemService">Service used for managing dictionary items.</param>
public RootDictionaryTreeController(IEntityService entityService, IDictionaryItemService dictionaryItemService)
: this(
entityService,
StaticServiceProvider.Instance.GetRequiredService<FlagProviderCollection>(),
dictionaryItemService)
: base(entityService, dictionaryItemService)
{
}
@@ -1,11 +1,13 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
using Umbraco.Cms.Api.Common.ViewModels.Pagination;
using Umbraco.Cms.Api.Management.Factories;
using Umbraco.Cms.Api.Management.Services.Flags;
using Umbraco.Cms.Api.Management.ViewModels.Document.Collection;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.Mapping;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Security;
@@ -24,6 +26,16 @@ public class ByKeyDocumentCollectionController : DocumentCollectionControllerBas
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;
private readonly IDocumentCollectionPresentationFactory _documentCollectionPresentationFactory;
/// <summary>
/// Initializes a new instance of the <see cref="Umbraco.Cms.Api.Management.Controllers.Document.Collection.ByKeyDocumentCollectionController"/> class,
/// which handles document collection operations by document key.
/// </summary>
/// <param name="contentListViewService">Service for retrieving and managing content list views.</param>
/// <param name="backOfficeSecurityAccessor">Accessor for back office security context.</param>
/// <param name="mapper">The Umbraco object mapper used for mapping between models.</param>
/// <param name="documentCollectionPresentationFactory">Factory for creating document collection presentation models.</param>
/// <param name="flagProviders">A collection of providers for document collection flags.</param>
[ActivatorUtilitiesConstructor]
public ByKeyDocumentCollectionController(
IContentListViewService contentListViewService,
IBackOfficeSecurityAccessor backOfficeSecurityAccessor,
@@ -37,6 +49,28 @@ public class ByKeyDocumentCollectionController : DocumentCollectionControllerBas
_documentCollectionPresentationFactory = documentCollectionPresentationFactory;
}
/// <summary>
/// Initializes a new instance of the <see cref="Umbraco.Cms.Api.Management.Controllers.Document.Collection.ByKeyDocumentCollectionController"/> class.
/// </summary>
/// <param name="contentListViewService">Service for managing content list views.</param>
/// <param name="backOfficeSecurityAccessor">Accessor for back office security operations.</param>
/// <param name="mapper">Maps Umbraco objects to API models.</param>
/// <param name="documentCollectionPresentationFactory">Factory for creating document collection presentation models.</param>
[Obsolete("Please use the constructor with all parameters. Scheduled for removal in Umbraco 18.")]
public ByKeyDocumentCollectionController(
IContentListViewService contentListViewService,
IBackOfficeSecurityAccessor backOfficeSecurityAccessor,
IUmbracoMapper mapper,
IDocumentCollectionPresentationFactory documentCollectionPresentationFactory)
: this(
contentListViewService,
backOfficeSecurityAccessor,
mapper,
documentCollectionPresentationFactory,
StaticServiceProvider.Instance.GetRequiredService<FlagProviderCollection>())
{
}
/// <summary>
/// Retrieves a paged collection of documents identified by the provided unique identifier.
/// </summary>
@@ -21,8 +21,6 @@ namespace Umbraco.Cms.Api.Management.Controllers.Document;
[Authorize(Policy = AuthorizationPolicies.TreeAccessDocuments)]
public abstract class DocumentControllerBase : ContentControllerBase
{
protected override string EntityName => "document";
protected IActionResult DocumentNotFound()
=> OperationStatusResult(ContentEditingOperationStatus.NotFound, problemDetailsBuilder
=> NotFound(problemDetailsBuilder
@@ -40,7 +38,118 @@ public abstract class DocumentControllerBase : ContentControllerBase
ContentPublishingOperationStatus status,
IEnumerable<string>? invalidPropertyAliases = null,
IEnumerable<ContentPublishingBranchItemResult>? failedBranchItems = null)
=> ContentPublishingOperationStatusResult(status, invalidPropertyAliases, failedBranchItems);
=> OperationStatusResult(status, problemDetailsBuilder => status switch
{
ContentPublishingOperationStatus.ContentNotFound => NotFound(problemDetailsBuilder
.WithTitle("The requested document could not be found")
.Build()),
ContentPublishingOperationStatus.CancelledByEvent => BadRequest(problemDetailsBuilder
.WithTitle("Publish cancelled by event")
.WithDetail("The publish operation was cancelled by an event.")
.Build()),
ContentPublishingOperationStatus.ContentInvalid => BadRequest(problemDetailsBuilder
.WithTitle("Invalid document")
.WithDetail("The specified document had an invalid configuration.")
.WithExtension("invalidProperties", invalidPropertyAliases ?? Enumerable.Empty<string>())
.Build()),
ContentPublishingOperationStatus.NothingToPublish => BadRequest(problemDetailsBuilder
.WithTitle("Nothing to publish")
.WithDetail("None of the specified cultures needed publishing.")
.Build()),
ContentPublishingOperationStatus.MandatoryCultureMissing => BadRequest(problemDetailsBuilder
.WithTitle("Mandatory culture missing")
.WithDetail("Must include all mandatory cultures when publishing.")
.Build()),
ContentPublishingOperationStatus.HasExpired => BadRequest(problemDetailsBuilder
.WithTitle("Document expired")
.WithDetail("Could not publish the document because it was expired.")
.Build()),
ContentPublishingOperationStatus.CultureHasExpired => BadRequest(problemDetailsBuilder
.WithTitle("Document culture expired")
.WithDetail("Could not publish the document because some of the specified cultures were expired.")
.Build()),
ContentPublishingOperationStatus.AwaitingRelease => BadRequest(problemDetailsBuilder
.WithTitle("Document awaiting release")
.WithDetail("Could not publish the document because it was awaiting release.")
.Build()),
ContentPublishingOperationStatus.CultureAwaitingRelease => BadRequest(problemDetailsBuilder
.WithTitle("Document culture awaiting release")
.WithDetail(
"Could not publish the document because some of the specified cultures were awaiting release.")
.Build()),
ContentPublishingOperationStatus.InTrash => BadRequest(problemDetailsBuilder
.WithTitle("Document in the recycle bin")
.WithDetail("Could not publish the document because it was in the recycle bin.")
.Build()),
ContentPublishingOperationStatus.PathNotPublished => BadRequest(problemDetailsBuilder
.WithTitle("Parent not published")
.WithDetail("Could not publish the document because its parent was not published.")
.Build()),
ContentPublishingOperationStatus.InvalidCulture => BadRequest(problemDetailsBuilder
.WithTitle("Invalid cultures specified")
.WithDetail("A specified culture is not valid for the operation.")
.Build()),
ContentPublishingOperationStatus.CultureMissing => BadRequest(problemDetailsBuilder
.WithTitle("Culture missing")
.WithDetail("A culture needs to be specified to execute the operation.")
.Build()),
ContentPublishingOperationStatus.CannotPublishInvariantWhenVariant => BadRequest(problemDetailsBuilder
.WithTitle("Cannot publish invariant when variant")
.WithDetail("Cannot publish invariant culture when the document varies by culture.")
.Build()),
ContentPublishingOperationStatus.CannotPublishVariantWhenNotVariant => BadRequest(problemDetailsBuilder
.WithTitle("Cannot publish variant when not variant.")
.WithDetail("Cannot publish a given culture when the document is invariant.")
.Build()),
ContentPublishingOperationStatus.ConcurrencyViolation => BadRequest(problemDetailsBuilder
.WithTitle("Concurrency violation detected")
.WithDetail("An attempt was made to publish a version older than the latest version.")
.Build()),
ContentPublishingOperationStatus.UnsavedChanges => BadRequest(problemDetailsBuilder
.WithTitle("Unsaved changes")
.WithDetail(
"Could not publish the document because it had unsaved changes. Make sure to save all changes before attempting a publish.")
.Build()),
ContentPublishingOperationStatus.UnpublishTimeNeedsToBeAfterPublishTime => BadRequest(problemDetailsBuilder
.WithTitle("Unpublish time needs to be after the publish time")
.WithDetail(
"Cannot handle an unpublish time that is not after the specified publish time.")
.Build()),
ContentPublishingOperationStatus.PublishTimeNeedsToBeInFuture => BadRequest(problemDetailsBuilder
.WithTitle("Publish time needs to be higher than the current time")
.WithDetail(
"Cannot handle a publish time that is not after the current server time.")
.Build()),
ContentPublishingOperationStatus.UpublishTimeNeedsToBeInFuture => BadRequest(problemDetailsBuilder
.WithTitle("Unpublish time needs to be higher than the current time")
.WithDetail(
"Cannot handle an unpublish time that is not after the current server time.")
.Build()),
ContentPublishingOperationStatus.CannotUnpublishWhenReferenced => BadRequest(problemDetailsBuilder
.WithTitle("Cannot unpublish document when it's referenced somewhere else.")
.WithDetail(
"Cannot unpublish a referenced document, while the setting ContentSettings.DisableUnpublishWhenReferenced is enabled.")
.Build()),
ContentPublishingOperationStatus.FailedBranch => BadRequest(problemDetailsBuilder
.WithTitle("Failed branch operation")
.WithDetail("One or more items in the branch could not complete the operation.")
.WithExtension("failedBranchItems", failedBranchItems?.Select(item => new DocumentPublishBranchItemResult
{
Id = item.Key,
OperationStatus = item.OperationStatus
}) ?? Enumerable.Empty<DocumentPublishBranchItemResult>())
.Build()),
ContentPublishingOperationStatus.Failed => BadRequest(problemDetailsBuilder
.WithTitle("Publish or unpublish failed")
.WithDetail(
"An unspecified error occurred while (un)publishing. Please check the logs for additional information.")
.Build()),
ContentPublishingOperationStatus.TaskResultNotFound => NotFound(problemDetailsBuilder
.WithTitle("The result of the submitted task could not be found")
.Build()),
_ => StatusCode(StatusCodes.Status500InternalServerError, "Unknown content operation status."),
});
protected IActionResult PublicAccessOperationStatusResult(PublicAccessOperationStatus status)
=> OperationStatusResult(status, problemDetailsBuilder => status switch
@@ -38,6 +38,15 @@ public class DomainsController : DocumentControllerBase
_umbracoMapper = umbracoMapper;
}
[Obsolete("Please use the constructor taking all parameters. Scheduled for removal in Umbraco 18.")]
public DomainsController(IDomainService domainService, IUmbracoMapper umbracoMapper)
: this(
StaticServiceProvider.Instance.GetRequiredService<IAuthorizationService>(),
domainService,
umbracoMapper)
{
}
/// <summary>
/// Retrieves the list of domains and their associated culture settings assigned to the specified document.
/// </summary>
@@ -1,6 +1,7 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
using Umbraco.Cms.Api.Management.Factories;
using Umbraco.Cms.Api.Management.ViewModels.Document.Item;
using Umbraco.Cms.Core.Models;
@@ -25,6 +26,7 @@ public class ItemDocumentItemController : DocumentItemControllerBase
/// </summary>
/// <param name="entityService">The service used to manage and retrieve entities within the CMS.</param>
/// <param name="documentPresentationFactory">The factory responsible for creating document presentation models.</param>
[ActivatorUtilitiesConstructor]
public ItemDocumentItemController(
IEntityService entityService,
IDocumentPresentationFactory documentPresentationFactory)
@@ -57,8 +59,7 @@ public class ItemDocumentItemController : DocumentItemControllerBase
.GetAll(UmbracoObjectTypes.Document, ids.ToArray())
.OfType<IDocumentEntitySlim>();
IEnumerable<Task<DocumentItemResponseModel>> tasks = documents.Select(_documentPresentationFactory.CreateItemResponseModelAsync);
DocumentItemResponseModel[] responseModels = await Task.WhenAll(tasks);
IEnumerable<DocumentItemResponseModel> responseModels = documents.Select(_documentPresentationFactory.CreateItemResponseModel);
return Ok(responseModels);
}
}
@@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
using Umbraco.Cms.Api.Management.Factories;
using Umbraco.Cms.Api.Management.ViewModels.Document.Item;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.Entities;
using Umbraco.Cms.Core.Services;
@@ -27,6 +28,7 @@ public class SearchDocumentItemController : DocumentItemControllerBase
/// <param name="indexedEntitySearchService">Service for searching indexed entities.</param>
/// <param name="documentPresentationFactory">Factory for creating document presentation models.</param>
/// <param name="dataTypeService">Service for managing data types.</param>
[ActivatorUtilitiesConstructor]
public SearchDocumentItemController(
IIndexedEntitySearchService indexedEntitySearchService,
IDocumentPresentationFactory documentPresentationFactory,
@@ -37,6 +39,56 @@ public class SearchDocumentItemController : DocumentItemControllerBase
_dataTypeService = dataTypeService;
}
/// <summary>
/// Initializes a new instance of the <see cref="SearchDocumentItemController"/> class.
/// </summary>
/// <param name="indexedEntitySearchService">The service used to perform searches on indexed entities. This dependency is injected.</param>
/// <param name="documentPresentationFactory">The factory responsible for creating document presentation models. This dependency is injected.</param>
[Obsolete("Use the non-obsolete constructor instead. Scheduled for removal in Umbraco 18.")]
public SearchDocumentItemController(
IIndexedEntitySearchService indexedEntitySearchService,
IDocumentPresentationFactory documentPresentationFactory)
: this(
indexedEntitySearchService,
documentPresentationFactory,
StaticServiceProvider.Instance.GetRequiredService<IDataTypeService>())
{
}
/// <summary>
/// Searches for document items, including those in the recycle bin, using the specified query and filters.
/// </summary>
/// <param name="cancellationToken">A token to monitor for cancellation requests.</param>
/// <param name="query">The search query string.</param>
/// <param name="trashed">If set, filters results to include only trashed items, only non-trashed items, or both (when null).</param>
/// <param name="culture">An optional culture code to filter search results.</param>
/// <param name="skip">The number of items to skip (for pagination).</param>
/// <param name="take">The maximum number of items to return (for pagination).</param>
/// <param name="parentId">An optional parent ID to filter results by parent.</param>
/// <param name="allowedDocumentTypes">An optional list of allowed document type IDs to filter results.</param>
/// <returns>A task representing the asynchronous operation, with an action result containing the search results.</returns>
[Obsolete("Please use the overload taking all parameters. Scheduled for removal in Umbraco 18.")]
[ApiExplorerSettings(IgnoreApi = true)]
public async Task<IActionResult> SearchWithTrashed(
CancellationToken cancellationToken,
string query,
bool? trashed = null,
string? culture = null,
int skip = 0,
int take = 100,
Guid? parentId = null,
[FromQuery] IEnumerable<Guid>? allowedDocumentTypes = null)
=> await SearchWithTrashed(
cancellationToken,
query,
trashed,
culture,
skip,
take,
parentId,
allowedDocumentTypes,
null);
/// <summary>
/// Searches for document items, including those in the recycle bin, based on the specified query and filters.
/// </summary>
@@ -78,12 +130,9 @@ public class SearchDocumentItemController : DocumentItemControllerBase
take,
ignoreUserStartNodes);
IEnumerable<Task<DocumentItemResponseModel>> tasks = searchResult.Items.OfType<IDocumentEntitySlim>().Select(_documentPresentationFactory.CreateItemResponseModelAsync);
DocumentItemResponseModel[] items = await Task.WhenAll(tasks);
var result = new PagedModel<DocumentItemResponseModel>
{
Items = items,
Items = searchResult.Items.OfType<IDocumentEntitySlim>().Select(_documentPresentationFactory.CreateItemResponseModel),
Total = searchResult.Total,
};
@@ -1,4 +1,3 @@
using System.Net.Mime;
using Asp.Versioning;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
@@ -45,7 +44,7 @@ public class PatchDocumentController : PatchDocumentControllerBase
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
[EndpointSummary("Make partial updates to a document. For more information, see the documentation at https://docs.umbraco.com/umbraco-cms/reference/management-api/patching/document-endpoint-guide or https://docs.umbraco.com/umbraco-cms/reference/management-api/patching/document-endpoint-spec")]
[Consumes(MediaTypeNames.Application.JsonPatch)]
[Consumes("application/json-patch+json")]
public async Task<IActionResult> Patch(
CancellationToken cancellationToken,
Guid id,
@@ -22,8 +22,6 @@ namespace Umbraco.Cms.Api.Management.Controllers.Document.RecycleBin;
[Authorize(Policy = AuthorizationPolicies.TreeAccessDocuments)]
public class DocumentRecycleBinControllerBase : RecycleBinControllerBase<DocumentRecycleBinItemResponseModel>
{
protected override string EntityName => "document";
private readonly IDocumentPresentationFactory _documentPresentationFactory;
/// <summary>
@@ -39,13 +37,13 @@ public class DocumentRecycleBinControllerBase : RecycleBinControllerBase<Documen
protected override Guid RecycleBinRootKey => Constants.System.RecycleBinContentKey;
protected override async Task<DocumentRecycleBinItemResponseModel> MapRecycleBinViewModelAsync(Guid? parentId, IEntitySlim entity)
protected override DocumentRecycleBinItemResponseModel MapRecycleBinViewModel(Guid? parentId, IEntitySlim entity)
{
DocumentRecycleBinItemResponseModel responseModel = await base.MapRecycleBinViewModelAsync(parentId, entity);
DocumentRecycleBinItemResponseModel responseModel = base.MapRecycleBinViewModel(parentId, entity);
if (entity is IDocumentEntitySlim documentEntitySlim)
{
responseModel.Variants = await _documentPresentationFactory.CreateVariantsItemResponseModelsAsync(documentEntitySlim);
responseModel.Variants = _documentPresentationFactory.CreateVariantsItemResponseModels(documentEntitySlim);
responseModel.DocumentType = _documentPresentationFactory.CreateDocumentTypeReferenceResponseModel(documentEntitySlim);
}
@@ -0,0 +1,100 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Api.Management.Security.Authorization;
using Umbraco.Cms.Api.Management.ViewModels.Sorting;
using Umbraco.Cms.Core.Actions;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Security;
using Umbraco.Cms.Core.Security.Authorization;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Services.OperationStatus;
using Umbraco.Cms.Web.Common.Authorization;
using Umbraco.Extensions;
namespace Umbraco.Cms.Api.Management.Controllers.Document;
/// <summary>
/// Provides an API endpoint for sorting the root-level documents by a system field.
/// </summary>
[ApiVersion("1.0")]
public class SortChildrenAtRootDocumentController : DocumentControllerBase
{
private readonly IAuthorizationService _authorizationService;
private readonly IContentEditingService _contentEditingService;
private readonly IEntityService _entityService;
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;
/// <summary>
/// Initializes a new instance of the <see cref="SortChildrenAtRootDocumentController"/> class.
/// </summary>
/// <param name="authorizationService">Service used to authorize user actions.</param>
/// <param name="contentEditingService">Service for editing and managing content.</param>
/// <param name="entityService">Service used to resolve the children to authorize.</param>
/// <param name="backOfficeSecurityAccessor">Accessor for back office security context.</param>
public SortChildrenAtRootDocumentController(
IAuthorizationService authorizationService,
IContentEditingService contentEditingService,
IEntityService entityService,
IBackOfficeSecurityAccessor backOfficeSecurityAccessor)
{
_authorizationService = authorizationService;
_contentEditingService = contentEditingService;
_entityService = entityService;
_backOfficeSecurityAccessor = backOfficeSecurityAccessor;
}
/// <summary>
/// Sorts the root-level documents by a system field.
/// </summary>
/// <param name="cancellationToken">A <see cref="CancellationToken"/> to observe while waiting for the task to complete.</param>
/// <param name="requestModel">The field to sort by and the sort direction.</param>
/// <returns>
/// An <see cref="IActionResult"/> indicating the outcome of the operation:
/// returns <c>200 OK</c> if sorting succeeds or <c>400 Bad Request</c> if the field is not recognised.
/// </returns>
[HttpPut("root/sort-children")]
[MapToApiVersion("1.0")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
[EndpointSummary("Sorts the root-level documents by a field.")]
[EndpointDescription("Sorts the root-level documents by a system field in the given direction. When sorting by name, an optional culture selects the variant name; the culture is not validated, so documents that do not vary by it (or an unrecognised culture) fall back to the invariant name.")]
public async Task<IActionResult> SortChildren(CancellationToken cancellationToken, SortDocumentChildrenByFieldRequestModel requestModel)
{
AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync(
User,
ContentPermissionResource.WithKeys(ActionSort.ActionLetter, (Guid?)null),
AuthorizationPolicies.ContentPermissionByResource);
if (!authorizationResult.Succeeded)
{
return Forbidden();
}
var childrenAuthorized = await AllChildrenAuthorizer.IsAuthorizedForChildrenAsync(
_authorizationService,
_entityService,
User,
parentKey: null,
UmbracoObjectTypes.Document,
childKeys => ContentPermissionResource.WithKeys(ActionSort.ActionLetter, childKeys),
AuthorizationPolicies.ContentPermissionByResource);
if (!childrenAuthorized)
{
return Forbidden();
}
ContentEditingOperationStatus result = await _contentEditingService.SortByFieldAsync(
null,
requestModel.Field,
requestModel.Direction,
requestModel.Culture,
CurrentUserKey(_backOfficeSecurityAccessor));
return result == ContentEditingOperationStatus.Success
? Ok()
: ContentEditingOperationStatusResult(result);
}
}
@@ -0,0 +1,102 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Api.Management.Security.Authorization;
using Umbraco.Cms.Api.Management.ViewModels.Sorting;
using Umbraco.Cms.Core.Actions;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Security;
using Umbraco.Cms.Core.Security.Authorization;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Services.OperationStatus;
using Umbraco.Cms.Web.Common.Authorization;
using Umbraco.Extensions;
namespace Umbraco.Cms.Api.Management.Controllers.Document;
/// <summary>
/// Provides an API endpoint for sorting the children of a document by a system field.
/// </summary>
[ApiVersion("1.0")]
public class SortChildrenDocumentController : DocumentControllerBase
{
private readonly IAuthorizationService _authorizationService;
private readonly IContentEditingService _contentEditingService;
private readonly IEntityService _entityService;
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;
/// <summary>
/// Initializes a new instance of the <see cref="SortChildrenDocumentController"/> class.
/// </summary>
/// <param name="authorizationService">Service used to authorize user actions.</param>
/// <param name="contentEditingService">Service for editing and managing content.</param>
/// <param name="entityService">Service used to resolve the children to authorize.</param>
/// <param name="backOfficeSecurityAccessor">Accessor for back office security context.</param>
public SortChildrenDocumentController(
IAuthorizationService authorizationService,
IContentEditingService contentEditingService,
IEntityService entityService,
IBackOfficeSecurityAccessor backOfficeSecurityAccessor)
{
_authorizationService = authorizationService;
_contentEditingService = contentEditingService;
_entityService = entityService;
_backOfficeSecurityAccessor = backOfficeSecurityAccessor;
}
/// <summary>
/// Sorts the child documents of the specified parent document by a system field.
/// </summary>
/// <param name="cancellationToken">A <see cref="CancellationToken"/> to observe while waiting for the task to complete.</param>
/// <param name="id">The unique identifier of the parent document whose children should be sorted.</param>
/// <param name="requestModel">The field to sort by and the sort direction.</param>
/// <returns>
/// An <see cref="IActionResult"/> indicating the outcome of the operation:
/// returns <c>200 OK</c> if sorting succeeds, <c>400 Bad Request</c> if the field is not recognised, or <c>404 Not Found</c> if the parent document does not exist.
/// </returns>
[HttpPut("{id:guid}/sort-children")]
[MapToApiVersion("1.0")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[EndpointSummary("Sorts the children of a document by a field.")]
[EndpointDescription("Sorts the children of the specified parent document by a system field in the given direction. When sorting by name, an optional culture selects the variant name; the culture is not validated, so children that do not vary by it (or an unrecognised culture) fall back to the invariant name.")]
public async Task<IActionResult> SortChildren(CancellationToken cancellationToken, Guid id, SortDocumentChildrenByFieldRequestModel requestModel)
{
AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync(
User,
ContentPermissionResource.WithKeys(ActionSort.ActionLetter, id),
AuthorizationPolicies.ContentPermissionByResource);
if (!authorizationResult.Succeeded)
{
return Forbidden();
}
var childrenAuthorized = await AllChildrenAuthorizer.IsAuthorizedForChildrenAsync(
_authorizationService,
_entityService,
User,
id,
UmbracoObjectTypes.Document,
childKeys => ContentPermissionResource.WithKeys(ActionSort.ActionLetter, childKeys),
AuthorizationPolicies.ContentPermissionByResource);
if (!childrenAuthorized)
{
return Forbidden();
}
ContentEditingOperationStatus result = await _contentEditingService.SortByFieldAsync(
id,
requestModel.Field,
requestModel.Direction,
requestModel.Culture,
CurrentUserKey(_backOfficeSecurityAccessor));
return result == ContentEditingOperationStatus.Success
? Ok()
: ContentEditingOperationStatusResult(result);
}
}
@@ -20,6 +20,7 @@ namespace Umbraco.Cms.Api.Management.Controllers.Document.Tree;
[ApiVersion("1.0")]
public class AncestorsDocumentTreeController : DocumentTreeControllerBase
{
/// <summary>
/// Initializes a new instance of the <see cref="AncestorsDocumentTreeController"/> class.
/// </summary>
/// <param name="entityService">Service for managing and retrieving entities in the system.</param>
@@ -82,6 +83,46 @@ public class AncestorsDocumentTreeController : DocumentTreeControllerBase
}
/// <summary>
/// Initializes a new instance of the <see cref="Umbraco.Cms.Api.Management.Controllers.Document.Tree.AncestorsDocumentTreeController"/> class.
/// </summary>
/// <param name="entityService">Service used for entity operations within the Umbraco CMS.</param>
/// <param name="userStartNodeEntitiesService">Service for resolving user start nodes for entities.</param>
/// <param name="dataTypeService">Service for managing data types in the CMS.</param>
/// <param name="publicAccessService">Service for handling public access permissions on content.</param>
/// <param name="appCaches">Provides access to application-level caches.</param>
/// <param name="backofficeSecurityAccessor">Accessor for backoffice security context and operations.</param>
/// <param name="documentPresentationFactory">Factory for creating document presentation models.</param>
[Obsolete("Please use the constructor taking all parameters. Scheduled for removal in Umbraco 18.")]
public AncestorsDocumentTreeController(
IEntityService entityService,
IUserStartNodeEntitiesService userStartNodeEntitiesService,
IDataTypeService dataTypeService,
IPublicAccessService publicAccessService,
AppCaches appCaches,
IBackOfficeSecurityAccessor backofficeSecurityAccessor,
IDocumentPresentationFactory documentPresentationFactory)
: base(
entityService,
userStartNodeEntitiesService,
dataTypeService,
publicAccessService,
appCaches,
backofficeSecurityAccessor,
documentPresentationFactory)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="AncestorsDocumentTreeController"/> class.
/// </summary>
/// <param name="entityService">Service for managing and retrieving entities within Umbraco.</param>
/// <param name="flagProviders">A collection of providers for handling entity flags.</param>
/// <param name="userStartNodeEntitiesService">Service for resolving user start node entities.</param>
/// <param name="dataTypeService">Service for managing data types in Umbraco.</param>
/// <param name="publicAccessService">Service for handling public access permissions.</param>
/// <param name="appCaches">Provides access to application-level caches.</param>
/// <param name="backofficeSecurityAccessor">Accessor for backoffice security context and operations.</param>
/// <param name="documentPresentationFactory">Factory for creating document presentation models.</param>
[Obsolete("Please use the constructor accepting IDocumentStartNodeTreeFilterService. Scheduled for removal in Umbraco 19.")]
public AncestorsDocumentTreeController(
IEntityService entityService,
@@ -21,6 +21,10 @@ namespace Umbraco.Cms.Api.Management.Controllers.Document.Tree;
[ApiVersion("1.0")]
public class ChildrenDocumentTreeController : DocumentTreeControllerBase
{
/// <summary>
/// Initializes a new instance of the <see cref="ChildrenDocumentTreeController"/> class.
/// </summary>
/// <param name="entityService">Service for managing and retrieving entities in the system.</param>
/// <param name="flagProviders">A collection of providers that supply flags for document tree nodes.</param>
/// <param name="treeFilterService">Service for filtering document tree entities based on user start nodes.</param>
/// <param name="publicAccessService">Service for handling public access permissions on documents.</param>
@@ -83,6 +87,43 @@ public class ChildrenDocumentTreeController : DocumentTreeControllerBase
/// Initializes a new instance of the <see cref="ChildrenDocumentTreeController"/> class.
/// </summary>
/// <param name="entityService">Service for managing and retrieving entities in the system.</param>
/// <param name="userStartNodeEntitiesService">Service for resolving user start nodes for entities.</param>
/// <param name="dataTypeService">Service for accessing and managing data types.</param>
/// <param name="publicAccessService">Service for handling public access permissions and restrictions.</param>
/// <param name="appCaches">Provides application-level caching mechanisms.</param>
/// <param name="backofficeSecurityAccessor">Accessor for back office security context and operations.</param>
/// <param name="documentPresentationFactory">Factory for creating document presentation models.</param>
[Obsolete("Please use the constructor taking all parameters. Scheduled for removal in Umbraco 18.")]
public ChildrenDocumentTreeController(
IEntityService entityService,
IUserStartNodeEntitiesService userStartNodeEntitiesService,
IDataTypeService dataTypeService,
IPublicAccessService publicAccessService,
AppCaches appCaches,
IBackOfficeSecurityAccessor backofficeSecurityAccessor,
IDocumentPresentationFactory documentPresentationFactory)
: base(
entityService,
userStartNodeEntitiesService,
dataTypeService,
publicAccessService,
appCaches,
backofficeSecurityAccessor,
documentPresentationFactory)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="ChildrenDocumentTreeController"/> class, responsible for managing child document tree operations in the Umbraco backoffice API.
/// </summary>
/// <param name="entityService">Service for accessing and managing entities within Umbraco.</param>
/// <param name="flagProviders">A collection of providers that supply flags for document tree nodes.</param>
/// <param name="userStartNodeEntitiesService">Service for resolving user-specific start nodes in the content tree.</param>
/// <param name="dataTypeService">Service for managing data types in Umbraco.</param>
/// <param name="publicAccessService">Service for handling public access permissions on documents.</param>
/// <param name="appCaches">Provides application-level caching functionality.</param>
/// <param name="backofficeSecurityAccessor">Accessor for backoffice security context and authentication.</param>
/// <param name="documentPresentationFactory">Factory for creating document presentation models for the API.</param>
[Obsolete("Please use the constructor accepting IDocumentStartNodeTreeFilterService. Scheduled for removal in Umbraco 19.")]
public ChildrenDocumentTreeController(
IEntityService entityService,
@@ -37,6 +37,27 @@ public abstract class DocumentTreeControllerBase : UserStartNodeTreeControllerBa
private readonly AppCaches? _appCaches;
private readonly IBackOfficeSecurityAccessor? _backofficeSecurityAccessor;
[Obsolete("Please use the constructor taking all parameters. Scheduled for removal in Umbraco 18.")]
protected DocumentTreeControllerBase(
IEntityService entityService,
IUserStartNodeEntitiesService userStartNodeEntitiesService,
IDataTypeService dataTypeService,
IPublicAccessService publicAccessService,
AppCaches appCaches,
IBackOfficeSecurityAccessor backofficeSecurityAccessor,
IDocumentPresentationFactory documentPresentationFactory)
: this(
entityService,
StaticServiceProvider.Instance.GetRequiredService<FlagProviderCollection>(),
userStartNodeEntitiesService,
dataTypeService,
publicAccessService,
appCaches,
backofficeSecurityAccessor,
documentPresentationFactory)
{
}
[Obsolete("Please use the constructor accepting IDocumentStartNodeTreeFilterService. Scheduled for removal in Umbraco 19.")]
protected DocumentTreeControllerBase(
IEntityService entityService,
@@ -108,9 +129,9 @@ public abstract class DocumentTreeControllerBase : UserStartNodeTreeControllerBa
protected override Ordering ItemOrdering => Ordering.By(Infrastructure.Persistence.Dtos.NodeDto.SortOrderColumnName);
protected override async Task<DocumentTreeItemResponseModel> MapTreeItemViewModelAsync(Guid? parentId, IEntitySlim entity)
protected override DocumentTreeItemResponseModel MapTreeItemViewModel(Guid? parentId, IEntitySlim entity)
{
DocumentTreeItemResponseModel responseModel = await base.MapTreeItemViewModelAsync(parentId, entity);
DocumentTreeItemResponseModel responseModel = base.MapTreeItemViewModel(parentId, entity);
if (entity is IDocumentEntitySlim documentEntitySlim)
{
@@ -121,7 +142,7 @@ public abstract class DocumentTreeControllerBase : UserStartNodeTreeControllerBa
responseModel.Id = entity.Key;
responseModel.CreateDate = entity.CreateDate;
responseModel.Variants = await _documentPresentationFactory.CreateVariantsItemResponseModelsAsync(documentEntitySlim);
responseModel.Variants = _documentPresentationFactory.CreateVariantsItemResponseModels(documentEntitySlim);
responseModel.DocumentType = _documentPresentationFactory.CreateDocumentTypeReferenceResponseModel(documentEntitySlim);
}
@@ -21,6 +21,7 @@ namespace Umbraco.Cms.Api.Management.Controllers.Document.Tree;
[ApiVersion("1.0")]
public class RootDocumentTreeController : DocumentTreeControllerBase
{
/// <summary>
/// Initializes a new instance of the <see cref="RootDocumentTreeController"/> class.
/// </summary>
/// <param name="entityService">Service for managing and retrieving entities in the system.</param>
@@ -83,6 +84,46 @@ public class RootDocumentTreeController : DocumentTreeControllerBase
}
/// <summary>
/// Initializes a new instance of the <see cref="RootDocumentTreeController"/> class, which manages the root nodes of the document tree in the Umbraco backoffice.
/// </summary>
/// <param name="entityService">The service used for entity operations.</param>
/// <param name="userStartNodeEntitiesService">The service for resolving user start node entities.</param>
/// <param name="dataTypeService">The service for managing data types.</param>
/// <param name="publicAccessService">The service for handling public access permissions.</param>
/// <param name="appCaches">The application-level caches.</param>
/// <param name="backofficeSecurityAccessor">Accessor for backoffice security context.</param>
/// <param name="documentPresentationFactory">Factory for creating document presentation models.</param>
[Obsolete("Please use the constructor taking all parameters. Scheduled for removal in Umbraco 18.")]
public RootDocumentTreeController(
IEntityService entityService,
IUserStartNodeEntitiesService userStartNodeEntitiesService,
IDataTypeService dataTypeService,
IPublicAccessService publicAccessService,
AppCaches appCaches,
IBackOfficeSecurityAccessor backofficeSecurityAccessor,
IDocumentPresentationFactory documentPresentationFactory)
: base(
entityService,
userStartNodeEntitiesService,
dataTypeService,
publicAccessService,
appCaches,
backofficeSecurityAccessor,
documentPresentationFactory)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="Umbraco.Cms.Api.Management.Controllers.Document.Tree.RootDocumentTreeController"/> class.
/// </summary>
/// <param name="entityService">Service for managing and retrieving entities within Umbraco.</param>
/// <param name="flagProviders">A collection of providers that supply flags for document tree nodes.</param>
/// <param name="userStartNodeEntitiesService">Service for resolving user-specific start nodes in the content tree.</param>
/// <param name="dataTypeService">Service for accessing and managing data types.</param>
/// <param name="publicAccessService">Service for handling public access permissions on documents.</param>
/// <param name="appCaches">Provides application-level caching mechanisms.</param>
/// <param name="backofficeSecurityAccessor">Accessor for backoffice security context and operations.</param>
/// <param name="documentPresentationFactory">Factory for creating document presentation models.</param>
[Obsolete("Please use the constructor accepting IDocumentStartNodeTreeFilterService. Scheduled for removal in Umbraco 19.")]
public RootDocumentTreeController(
IEntityService entityService,
@@ -21,6 +21,10 @@ namespace Umbraco.Cms.Api.Management.Controllers.Document.Tree;
[ApiVersion("1.0")]
public class SiblingsDocumentTreeController : DocumentTreeControllerBase
{
/// <summary>
/// Initializes a new instance of the <see cref="SiblingsDocumentTreeController"/> class.
/// </summary>
/// <param name="entityService">Service for managing and retrieving entities in the system.</param>
/// <param name="flagProviders">A collection of providers that supply flags for document tree nodes.</param>
/// <param name="treeFilterService">Service for filtering document tree entities based on user start nodes.</param>
/// <param name="publicAccessService">Service for handling public access permissions on documents.</param>
@@ -83,6 +87,43 @@ public class SiblingsDocumentTreeController : DocumentTreeControllerBase
/// Initializes a new instance of the <see cref="SiblingsDocumentTreeController"/> class.
/// </summary>
/// <param name="entityService">Service for managing and retrieving entities in the system.</param>
/// <param name="userStartNodeEntitiesService">Service for resolving user start nodes for entities.</param>
/// <param name="dataTypeService">Service for accessing and managing data types.</param>
/// <param name="publicAccessService">Service for handling public access permissions.</param>
/// <param name="appCaches">Provides application-level caching functionality.</param>
/// <param name="backofficeSecurityAccessor">Accessor for backoffice security context and operations.</param>
/// <param name="documentPresentationFactory">Factory for creating document presentation models.</param>
[Obsolete("Please use the constructor taking all parameters. Scheduled for removal in Umbraco 18.")]
public SiblingsDocumentTreeController(
IEntityService entityService,
IUserStartNodeEntitiesService userStartNodeEntitiesService,
IDataTypeService dataTypeService,
IPublicAccessService publicAccessService,
AppCaches appCaches,
IBackOfficeSecurityAccessor backofficeSecurityAccessor,
IDocumentPresentationFactory documentPresentationFactory)
: base(
entityService,
userStartNodeEntitiesService,
dataTypeService,
publicAccessService,
appCaches,
backofficeSecurityAccessor,
documentPresentationFactory)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="SiblingsDocumentTreeController"/> class.
/// </summary>
/// <param name="entityService">Service used for entity operations within the Umbraco CMS.</param>
/// <param name="flagProviders">A collection of providers that supply flag information for entities.</param>
/// <param name="userStartNodeEntitiesService">Service for resolving user start node entities.</param>
/// <param name="dataTypeService">Service for managing data types in the CMS.</param>
/// <param name="publicAccessService">Service for handling public access permissions.</param>
/// <param name="appCaches">Provides access to application-level caches.</param>
/// <param name="backofficeSecurityAccessor">Accessor for backoffice security context and operations.</param>
/// <param name="documentPresentationFactory">Factory for creating document presentation models.</param>
[Obsolete("Please use the constructor accepting IDocumentStartNodeTreeFilterService. Scheduled for removal in Umbraco 19.")]
public SiblingsDocumentTreeController(
IEntityService entityService,
@@ -44,6 +44,16 @@ public class UpdateDomainsController : DocumentControllerBase
_domainPresentationFactory = domainPresentationFactory;
}
[Obsolete("Please use the constructor taking all parameters. Scheduled for removal in Umbraco 18.")]
public UpdateDomainsController(IDomainService domainService, IUmbracoMapper umbracoMapper, IDomainPresentationFactory domainPresentationFactory)
: this(
StaticServiceProvider.Instance.GetRequiredService<IAuthorizationService>(),
domainService,
umbracoMapper,
domainPresentationFactory)
{
}
/// <summary>
/// Updates the domains assigned to the specified document.
/// </summary>

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